Files

1252 lines
44 KiB
PHP

<?php
declare(strict_types=1);
ini_set('memory_limit', '512M');
session_start();
$loggedIn = isset($_SESSION['user_id']);
$starsApiRequested = isset($_GET['stars_api']) && (string) $_GET['stars_api'] === '1';
$pageTitle = 'Drehbare Sternkarte - AstroTools';
$bodyClass = 'drehbare-sternkarte-page';
$headerIntroPre = 'Himmel & Planeten';
$headerIntroTitle = 'Drehbare Sternkarte';
$headerIntroSub = 'Grafik und Bedienung orientieren sich an einer echten drehbaren Sternkarte mit fester Maske und rotierender Sternscheibe.';
$constellationData = ['constellations' => []];
$constellationDataError = null;
$messierData = [];
$messierDataError = null;
$availableLocations = [];
$starDbMagLimit = 7.4;
$pdo = null;
try {
$dbConfig = require __DIR__ . '/../config/database.php';
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
$dbConfig['host'],
$dbConfig['dbname'],
$dbConfig['charset'] ?? 'utf8mb4'
);
$pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$constellations = [];
$ensureConstellation = static function (string $id) use (&$constellations): void {
if (!isset($constellations[$id])) {
$constellations[$id] = [
'id' => $id,
'lines' => [],
'label_positions' => [],
'common_name' => [
'german' => null,
'native' => null,
'byname' => null,
],
];
}
};
$nameStmt = $pdo->query(
"SELECT constellation_id, german_name, native_name, byname
FROM constellation_names
ORDER BY constellation_id"
);
foreach ($nameStmt as $row) {
$id = (string) $row['constellation_id'];
$ensureConstellation($id);
$constellations[$id]['common_name'] = [
'german' => $row['german_name'] !== null ? (string) $row['german_name'] : null,
'native' => $row['native_name'] !== null ? (string) $row['native_name'] : null,
'byname' => $row['byname'] !== null ? (string) $row['byname'] : null,
];
}
$labelStmt = $pdo->query(
"SELECT constellation_id, position_index, ra_hours, dec_degrees
FROM constellation_label_positions
ORDER BY constellation_id, position_index"
);
foreach ($labelStmt as $row) {
$id = (string) $row['constellation_id'];
$ensureConstellation($id);
$constellations[$id]['label_positions'][] = [
(float) $row['ra_hours'],
(float) $row['dec_degrees'],
];
}
$lineStmt = $pdo->query(
"SELECT cl.constellation_id, cl.line_index, clp.point_index, clp.hip_number
FROM constellation_lines cl
INNER JOIN constellation_line_points clp ON clp.line_id = cl.id
ORDER BY cl.constellation_id, cl.line_index, clp.point_index"
);
foreach ($lineStmt as $row) {
$id = (string) $row['constellation_id'];
$lineIndex = (int) $row['line_index'];
$ensureConstellation($id);
$constellations[$id]['lines'][$lineIndex] ??= [];
$constellations[$id]['lines'][$lineIndex][] = (int) $row['hip_number'];
}
foreach ($constellations as &$constellation) {
if ($constellation['lines']) {
ksort($constellation['lines']);
$constellation['lines'] = array_values($constellation['lines']);
}
}
unset($constellation);
uasort(
$constellations,
static fn(array $a, array $b): int => strcmp($a['id'], $b['id'])
);
if ($starsApiRequested) {
$stars = [];
$starStmt = $pdo->query(
"SELECT hip, proper, bayer, con, spect, mag, ci, rarad, decrad
FROM star_hipparcos
WHERE mag IS NOT NULL
AND rarad IS NOT NULL
AND decrad IS NOT NULL
AND mag <= " . (float) $starDbMagLimit . "
ORDER BY mag ASC"
);
while ($row = $starStmt->fetch()) {
$stars[] = [
'hip' => $row['hip'] !== null ? (int) $row['hip'] : null,
'proper' => trim((string) ($row['proper'] ?? '')),
'bayer' => trim((string) ($row['bayer'] ?? '')),
'con' => trim((string) ($row['con'] ?? '')),
'spect' => trim((string) ($row['spect'] ?? '')),
'ra' => (float) $row['rarad'],
'dec' => (float) $row['decrad'],
'mag' => (float) $row['mag'],
'ci' => $row['ci'] !== null ? (float) $row['ci'] : null,
];
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'stars' => $stars,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$constellationData['constellations'] = array_values($constellations);
$messierStmt = $pdo->query(
"SELECT messier, name, object_type_abrev, ra_deg, dec_deg, v_mag, major_axis
FROM dso_objects
WHERE messier IS NOT NULL
AND messier <> ''
AND ra_deg IS NOT NULL
AND dec_deg IS NOT NULL
ORDER BY CAST(REGEXP_REPLACE(messier, '[^0-9]', '') AS UNSIGNED)"
);
while ($row = $messierStmt->fetch()) {
if (!preg_match('/(\d+)/', (string) $row['messier'], $m)) {
continue;
}
$messierData[] = [
'm' => (int) $m[1],
'name' => trim((string) ($row['name'] ?? '')),
'type' => trim((string) ($row['object_type_abrev'] ?? 'As')),
'ra' => ((float) $row['ra_deg']) / 15.0,
'dec' => (float) $row['dec_deg'],
'mag' => $row['v_mag'] !== null ? (float) $row['v_mag'] : 99.0,
'size' => $row['major_axis'] !== null ? (float) $row['major_axis'] : 8.0,
];
}
if ($loggedIn && isset($_SESSION['user_id'])) {
$locationStmt = $pdo->prepare(
"SELECT id, name, latitude, longitude, elevation, timezone, is_default
FROM app_user_locations
WHERE user_id = ?
ORDER BY is_default DESC, name ASC, id ASC"
);
$locationStmt->execute([(int) $_SESSION['user_id']]);
while ($row = $locationStmt->fetch()) {
if ($row['latitude'] === null || $row['longitude'] === null) {
continue;
}
$availableLocations[] = [
'id' => $row['id'] !== null ? (int) $row['id'] : null,
'name' => trim((string) ($row['name'] ?? 'Standort')),
'latitude' => (float) $row['latitude'],
'longitude' => (float) $row['longitude'],
'elevation' => $row['elevation'] !== null ? (float) $row['elevation'] : 0.0,
'timezone' => trim((string) ($row['timezone'] ?? 'Europe/Berlin')),
'is_default' => !empty($row['is_default']),
];
}
}
} catch (Throwable $e) {
if ($starsApiRequested) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$constellationDataError = $e->getMessage();
$messierDataError = $e->getMessage();
}
$constellationDataJson = json_encode($constellationData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$constellationDataErrorJson = json_encode($constellationDataError, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$messierDataJson = json_encode($messierData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$messierDataErrorJson = json_encode($messierDataError, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
require __DIR__ . '/header.php';
$chartLocation = [
'id' => null,
'name' => 'Sternwarte Sonneberg',
'latitude' => 50.3547,
'longitude' => 11.1900,
'elevation' => 640.0,
'timezone' => 'Europe/Berlin',
];
if (!empty($currentLocation) && is_array($currentLocation)) {
$chartLocation = [
'id' => isset($currentLocation['id']) ? (int) $currentLocation['id'] : null,
'name' => (string) ($currentLocation['name'] ?? 'Standard-Standort'),
'latitude' => isset($currentLocation['latitude']) ? (float) $currentLocation['latitude'] : 50.3547,
'longitude' => isset($currentLocation['longitude']) ? (float) $currentLocation['longitude'] : 11.1900,
'elevation' => isset($currentLocation['elevation']) && $currentLocation['elevation'] !== null ? (float) $currentLocation['elevation'] : 0.0,
'timezone' => trim((string) ($currentLocation['timezone'] ?? 'Europe/Berlin')),
];
}
if (!$availableLocations) {
$availableLocations[] = [
'id' => $chartLocation['id'],
'name' => $chartLocation['name'],
'latitude' => $chartLocation['latitude'],
'longitude' => $chartLocation['longitude'],
'elevation' => $chartLocation['elevation'],
'timezone' => $chartLocation['timezone'],
'is_default' => true,
];
}
?>
<script src="<?= htmlspecialchars($publicBasePath . 'js/astronomy.browser.min.js', ENT_QUOTES, 'UTF-8') ?>"></script>
<style>
.drehbare-sternkarte-page .starchart-app {
position: relative;
min-height: 920px;
border-radius: 26px;
overflow: hidden;
background: radial-gradient(circle at 50% 38%, rgba(17, 39, 64, 0.95), rgba(4, 9, 15, 0.99) 72%);
border: 1px solid rgba(111, 165, 214, 0.18);
box-shadow: 0 24px 62px rgba(0, 0, 0, 0.36);
}
.drehbare-sternkarte-page #planisphereCanvas {
display: block;
width: 100%;
height: 920px;
touch-action: none;
cursor: grab;
}
.drehbare-sternkarte-page #planisphereCanvas.dragging {
cursor: grabbing;
}
.drehbare-sternkarte-page .hud-row {
position: absolute;
left: 1rem;
right: 1rem;
top: 1rem;
display: flex;
justify-content: space-between;
gap: 0.7rem;
pointer-events: none;
}
.drehbare-sternkarte-page .hud-chip {
padding: 0.5rem 0.78rem;
border-radius: 999px;
background: rgba(7, 12, 19, 0.84);
border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(237, 244, 255, 0.95);
font-size: 0.83rem;
line-height: 1.15;
backdrop-filter: blur(8px);
}
.drehbare-sternkarte-page .settings-panel {
position: absolute;
top: 4.5rem;
left: 1rem;
width: min(320px, calc(100% - 2rem));
padding: 1rem;
border-radius: 22px;
background: rgba(8, 12, 18, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(10px);
}
.drehbare-sternkarte-page .settings-title {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.drehbare-sternkarte-page .panel-section + .panel-section {
margin-top: 0.95rem;
padding-top: 0.9rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.drehbare-sternkarte-page .inline-grid {
display: grid;
gap: 0.65rem;
}
.drehbare-sternkarte-page .inline-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.drehbare-sternkarte-page .settings-panel label,
.drehbare-sternkarte-page .settings-panel .control-label {
display: block;
margin-bottom: 0.25rem;
color: rgba(219, 232, 245, 0.88);
font-size: 0.84rem;
}
.drehbare-sternkarte-page .settings-panel input[type="date"],
.drehbare-sternkarte-page .settings-panel input[type="time"],
.drehbare-sternkarte-page .settings-panel select,
.drehbare-sternkarte-page .settings-panel input[type="range"] {
width: 100%;
}
.drehbare-sternkarte-page .toggle-list {
display: grid;
gap: 0.45rem;
}
.drehbare-sternkarte-page .toggle-item {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 0.92rem;
}
.drehbare-sternkarte-page .button-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.drehbare-sternkarte-page .btn-small {
padding: 0.48rem 0.74rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: #f1f6fb;
cursor: pointer;
}
.drehbare-sternkarte-page .btn-small:hover {
background: rgba(255, 255, 255, 0.12);
}
.drehbare-sternkarte-page .micro-text {
margin-top: 0.45rem;
color: rgba(219, 232, 245, 0.84);
font-size: 0.83rem;
line-height: 1.3;
}
@media (max-width: 900px) {
.drehbare-sternkarte-page .starchart-app {
min-height: 760px;
}
.drehbare-sternkarte-page #planisphereCanvas {
height: 760px;
}
.drehbare-sternkarte-page .settings-panel {
position: static;
width: auto;
margin: 1rem;
}
}
</style>
<section class="starchart-app">
<canvas id="planisphereCanvas"></canvas>
<div class="hud-row">
<div class="hud-chip" id="chartChip">Sternscheibe wird geladen…</div>
<div class="hud-chip" id="dragChip">Ziehen zum Drehen</div>
</div>
<aside class="settings-panel">
<h3 class="settings-title">Einstellungen</h3>
<div class="panel-section">
<div class="inline-grid">
<div>
<label for="locationSelect">Standort</label>
<select id="locationSelect"></select>
</div>
<div class="inline-grid two">
<div>
<label for="chartDate">Datum</label>
<input type="date" id="chartDate">
</div>
<div>
<label for="chartTime">Zeit</label>
<input type="time" id="chartTime" step="60">
</div>
</div>
</div>
<div class="button-row" style="margin-top: 0.7rem;">
<button type="button" class="btn-small" id="btnNow">Now</button>
<button type="button" class="btn-small" id="btnMinus1h">-1h</button>
<button type="button" class="btn-small" id="btnPlus1h">+1h</button>
<button type="button" class="btn-small" id="btnReset">Reset</button>
</div>
</div>
<div class="panel-section">
<div class="toggle-list">
<label class="toggle-item"><input type="checkbox" id="toggleEcliptic"> Ekliptik</label>
<label class="toggle-item"><input type="checkbox" id="toggleEquator"> Himmelsäquator</label>
<label class="toggle-item"><input type="checkbox" id="toggleDeclinationLabels"> Deklinationsangaben</label>
<label class="toggle-item"><input type="checkbox" id="toggleConstLines" checked> Sternbildlinien</label>
<label class="toggle-item"><input type="checkbox" id="toggleConstLabels" checked> Sternbildnamen</label>
<label class="toggle-item"><input type="checkbox" id="toggleStarLabels"> Sternnamen</label>
<label class="toggle-item"><input type="checkbox" id="toggleMessier"> Messier</label>
<label class="toggle-item"><input type="checkbox" id="toggleMessierLabels"> Messier Labels</label>
</div>
</div>
<div class="panel-section">
<div class="control-label">Scale</div>
<input type="range" id="magLimit" min="3.5" max="7.4" step="0.1" value="6.0">
<div class="micro-text" id="magLabel">Aktuell bis 6.0 mag</div>
</div>
<div class="panel-section">
<div class="micro-text" id="summaryText">Die Planisphäre wird vorbereitet.</div>
</div>
</aside>
</section>
<script>
const DEFAULT_LOCATION = <?= json_encode($chartLocation, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const LOCATION_OPTIONS = <?= json_encode($availableLocations, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const EMBEDDED_CONSTELLATION_DATA = <?= $constellationDataJson ?: '{"constellations":[]}' ?>;
const EMBEDDED_CONSTELLATION_ERROR = <?= $constellationDataErrorJson ?: 'null' ?>;
const EMBEDDED_MESSIER_DATA = <?= $messierDataJson ?: '[]' ?>;
const EMBEDDED_MESSIER_ERROR = <?= $messierDataErrorJson ?: 'null' ?>;
const STAR_DATA_URL = 'drehbare_sternkarte.php?stars_api=1';
(function () {
const canvas = document.getElementById('planisphereCanvas');
const ctx = canvas.getContext('2d');
const chartChip = document.getElementById('chartChip');
const dragChip = document.getElementById('dragChip');
const locationSelect = document.getElementById('locationSelect');
const dateInput = document.getElementById('chartDate');
const timeInput = document.getElementById('chartTime');
const magInput = document.getElementById('magLimit');
const magLabel = document.getElementById('magLabel');
const summaryText = document.getElementById('summaryText');
const state = {
location: DEFAULT_LOCATION,
dateIso: '',
minutes: 0,
magLimit: Number(magInput.value),
showEcliptic: false,
showEquator: false,
showDeclinationLabels: false,
showConstLines: true,
showConstLabels: true,
showStarLabels: false,
showMessier: false,
showMessierLabels: false,
};
const stars = [];
const hipMap = new Map();
const constellationData = EMBEDDED_CONSTELLATION_DATA && Array.isArray(EMBEDDED_CONSTELLATION_DATA.constellations)
? EMBEDDED_CONSTELLATION_DATA
: { constellations: [] };
const messierData = Array.isArray(EMBEDDED_MESSIER_DATA) ? EMBEDDED_MESSIER_DATA : [];
const MONTHS = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
const DAYS_PER_MONTH_COMMON = [31,28,31,30,31,30,31,31,30,31,30,31];
const DAYS_PER_MONTH_LEAP = [31,29,31,30,31,30,31,31,30,31,30,31];
let W = 0;
let H = 0;
let cx = 0;
let cy = 0;
let skyR = 0;
let outerR = 0;
let dragActive = false;
let dragLastAngle = 0;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function mod(value, n) {
return ((value % n) + n) % n;
}
function degToRad(value) {
return value * Math.PI / 180;
}
function radToDeg(value) {
return value * 180 / Math.PI;
}
function pad2(value) {
return String(value).padStart(2, '0');
}
function parseDateIso(dateIso) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(dateIso || ''));
if (!match) return null;
return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) };
}
function isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
function dayOfYear(dateIso) {
const parts = parseDateIso(dateIso);
if (!parts) return 1;
const monthDays = isLeapYear(parts.year) ? DAYS_PER_MONTH_LEAP : DAYS_PER_MONTH_COMMON;
let total = parts.day;
for (let i = 0; i < parts.month - 1; i++) total += monthDays[i];
return total;
}
function daysInYear(dateIso) {
const parts = parseDateIso(dateIso);
return parts && isLeapYear(parts.year) ? 366 : 365;
}
function minutesToTimeText(minutes) {
const safe = mod(Math.round(minutes), 1440);
return pad2(Math.floor(safe / 60)) + ':' + pad2(safe % 60);
}
function getSelectedDate() {
return state.dateIso || new Date().toISOString().slice(0, 10);
}
function timeZoneOffsetMinutes(date, timeZone) {
try {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(date);
const map = {};
for (const part of parts) map[part.type] = part.value;
const utc = Date.UTC(
Number(map.year),
Number(map.month) - 1,
Number(map.day),
Number(map.hour),
Number(map.minute),
Number(map.second)
);
return Math.round((utc - date.getTime()) / 60000);
} catch {
return -date.getTimezoneOffset();
}
}
function buildZonedDate(dateIso, minutes, timeZone) {
const parts = parseDateIso(dateIso);
if (!parts) return new Date();
const hh = Math.floor(minutes / 60);
const mm = mod(minutes, 60);
const guess = new Date(Date.UTC(parts.year, parts.month - 1, parts.day, hh, mm, 0));
const offset = timeZoneOffsetMinutes(guess, timeZone || 'Europe/Berlin');
return new Date(Date.UTC(parts.year, parts.month - 1, parts.day, hh, mm, 0) - offset * 60000);
}
function julianDay(date) {
return date.getTime() / 86400000 + 2440587.5;
}
function localSiderealHours(date, longitudeDeg) {
const jd = julianDay(date);
const t = (jd - 2451545.0) / 36525.0;
let gmst = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + 0.000387933 * t * t - (t * t * t) / 38710000.0;
gmst = mod(gmst, 360);
return mod((gmst + Number(longitudeDeg)) / 15.0, 24);
}
function computeWheelRotationRad() {
const date = buildZonedDate(getSelectedDate(), state.minutes, state.location.timezone || 'Europe/Berlin');
const lstHours = localSiderealHours(date, Number(state.location.longitude || 0));
return (lstHours / 24) * Math.PI * 2;
}
function effectiveDeclinationFloor() {
const lat = Number(state.location.latitude || 0);
return lat >= 0 ? Math.max(-65, lat - 96) : Math.min(65, lat + 96);
}
function projectWheelPoint(raHours, decDeg, rotationRad) {
const lat = Number(state.location.latitude || 0);
const northern = lat >= 0;
const edgeDec = effectiveDeclinationFloor();
const span = northern ? (90 - edgeDec) : (edgeDec + 90);
const safeSpan = Math.max(25, span);
const angle = (raHours / 24) * Math.PI * 2 - rotationRad + (northern ? Math.PI : 0);
const radiusFactor = northern ? (90 - decDeg) / safeSpan : (decDeg + 90) / safeSpan;
const r = clamp(radiusFactor, -0.08, 1.18) * skyR;
return { x: cx + r * Math.sin(angle), y: cy - r * Math.cos(angle) };
}
function horizonRaDec(azDeg, lstHours) {
const latRad = degToRad(Number(state.location.latitude || 0));
const azRad = degToRad(azDeg);
const cosLat = Math.cos(latRad);
const sinDec = Math.cos(latRad) * Math.cos(azRad);
const decRad = Math.asin(clamp(sinDec, -1, 1));
const cosDec = Math.max(1e-9, Math.cos(decRad));
const sinH = -Math.sin(azRad) / cosDec;
const cosH = Math.abs(cosLat) < 1e-9 ? 0 : -(Math.sin(latRad) * Math.sin(decRad)) / (cosLat * cosDec);
const hRad = Math.atan2(clamp(sinH, -1, 1), clamp(cosH, -1, 1));
const hHours = mod(radToDeg(hRad) / 15, 24);
return { raHours: mod(lstHours - hHours, 24), decDeg: radToDeg(decRad) };
}
function buildHorizonPoints(rotationRad) {
const date = buildZonedDate(getSelectedDate(), state.minutes, state.location.timezone || 'Europe/Berlin');
const lstHours = localSiderealHours(date, Number(state.location.longitude || 0));
const points = [];
for (let az = 0; az <= 360; az += 2) {
const eq = horizonRaDec(az, lstHours);
points.push(projectWheelPoint(eq.raHours, eq.decDeg, rotationRad));
}
return points;
}
function createHorizonPath(points) {
const path = new Path2D();
if (!points.length) return path;
path.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) path.lineTo(points[i].x, points[i].y);
path.closePath();
return path;
}
function temperatureToColor(tempK) {
const temp = clamp(tempK, 1000, 40000) / 100;
let r, g, b;
if (temp <= 66) {
r = 255;
g = 99.4708025861 * Math.log(temp) - 161.1195681661;
b = temp <= 19 ? 0 : 138.5177312231 * Math.log(temp - 10) - 305.0447927307;
} else {
r = 329.698727446 * Math.pow(temp - 60, -0.1332047592);
g = 288.1221695283 * Math.pow(temp - 60, -0.0755148492);
b = 255;
}
r = clamp(r * 0.82 + 255 * 0.18, 0, 255);
g = clamp(g * 0.82 + 245 * 0.18, 0, 255);
b = clamp(b * 0.82 + 235 * 0.18, 0, 255);
return 'rgb(' + Math.round(r) + ',' + Math.round(g) + ',' + Math.round(b) + ')';
}
function spectralClassToColor(spect) {
const type = String(spect || '').trim().toUpperCase().charAt(0);
if (type === 'O') return temperatureToColor(32000);
if (type === 'B') return temperatureToColor(18000);
if (type === 'A') return temperatureToColor(9000);
if (type === 'F') return temperatureToColor(7200);
if (type === 'G') return temperatureToColor(5800);
if (type === 'K') return temperatureToColor(4500);
if (type === 'M') return temperatureToColor(3200);
return 'rgb(255,255,255)';
}
function starColor(star) {
const bv = Number(star.ci);
if (Number.isFinite(bv)) {
const x = clamp(bv, -0.4, 2.0);
return temperatureToColor(4600 * (1 / (0.92 * x + 1.7) + 1 / (0.92 * x + 0.62)));
}
return spectralClassToColor(star.spect);
}
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
W = Math.max(360, Math.round(rect.width));
H = Math.max(420, Math.round(rect.height));
canvas.width = W;
canvas.height = H;
cx = W / 2;
cy = H / 2 + 10;
skyR = Math.min(W, H) * 0.34;
outerR = skyR + 62;
}
function syncUi() {
dateInput.value = getSelectedDate();
timeInput.value = minutesToTimeText(state.minutes);
magLabel.textContent = 'Aktuell bis ' + state.magLimit.toFixed(1) + ' mag';
}
function populateLocationSelect() {
locationSelect.innerHTML = '';
for (const location of LOCATION_OPTIONS) {
const option = document.createElement('option');
option.value = String(location.id ?? location.name ?? '');
option.textContent = location.name || 'Standort';
if (String(location.id ?? '') === String(DEFAULT_LOCATION.id ?? '')) option.selected = true;
locationSelect.appendChild(option);
}
}
function selectedLocationByValue(value) {
return LOCATION_OPTIONS.find(item => String(item.id ?? item.name ?? '') === value) || DEFAULT_LOCATION;
}
function setNow() {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: state.location.timezone || 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(new Date());
const map = {};
for (const part of parts) map[part.type] = part.value;
state.dateIso = map.year + '-' + map.month + '-' + map.day;
state.minutes = Number(map.hour) * 60 + Number(map.minute);
render();
}
function resetView() {
state.showEcliptic = false;
state.showEquator = false;
state.showDeclinationLabels = false;
state.showConstLines = true;
state.showConstLabels = true;
state.showStarLabels = false;
state.showMessier = false;
state.showMessierLabels = false;
state.magLimit = 6.0;
document.getElementById('toggleEcliptic').checked = false;
document.getElementById('toggleEquator').checked = false;
document.getElementById('toggleDeclinationLabels').checked = false;
document.getElementById('toggleConstLines').checked = true;
document.getElementById('toggleConstLabels').checked = true;
document.getElementById('toggleStarLabels').checked = false;
document.getElementById('toggleMessier').checked = false;
document.getElementById('toggleMessierLabels').checked = false;
magInput.value = '6.0';
setNow();
}
function drawBase() {
ctx.clearRect(0, 0, W, H);
const bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, '#07111b');
bg.addColorStop(1, '#02060d');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
const halo = ctx.createRadialGradient(cx, cy, skyR * 0.08, cx, cy, outerR + 36);
halo.addColorStop(0, 'rgba(18,35,58,0.16)');
halo.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(cx, cy, outerR + 40, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(cx, cy, outerR + 24, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(8, 14, 23, 0.97)';
ctx.fill();
ctx.strokeStyle = 'rgba(120, 170, 220, 0.22)';
ctx.lineWidth = 2;
ctx.stroke();
}
function drawFixedTimeRing() {
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let hour = 0; hour < 24; hour++) {
const angle = (hour / 24) * Math.PI * 2;
const sin = Math.sin(angle);
const cos = Math.cos(angle);
const tickInner = outerR - 8;
const tickOuter = outerR + 10;
ctx.beginPath();
ctx.moveTo(cx + tickInner * sin, cy - tickInner * cos);
ctx.lineTo(cx + tickOuter * sin, cy - tickOuter * cos);
ctx.strokeStyle = hour % 6 === 0 ? 'rgba(255, 210, 125, 0.92)' : 'rgba(164, 199, 233, 0.42)';
ctx.lineWidth = hour % 6 === 0 ? 2 : 1;
ctx.stroke();
const labelRadius = outerR + 24;
ctx.fillStyle = 'rgba(232, 241, 252, 0.92)';
ctx.font = hour % 6 === 0 ? 'bold 13px Rajdhani, sans-serif' : '11px Rajdhani, sans-serif';
ctx.fillText(String(hour), cx + labelRadius * sin, cy - labelRadius * cos);
}
ctx.beginPath();
ctx.moveTo(cx, cy - outerR - 26);
ctx.lineTo(cx - 10, cy - outerR - 6);
ctx.lineTo(cx + 10, cy - outerR - 6);
ctx.closePath();
ctx.fillStyle = 'rgba(255, 214, 121, 0.96)';
ctx.fill();
ctx.restore();
}
function drawDateRing(rotationRad) {
const totalDays = daysInYear(getSelectedDate());
const selectedDay = dayOfYear(getSelectedDate());
const monthDays = totalDays === 366 ? DAYS_PER_MONTH_LEAP : DAYS_PER_MONTH_COMMON;
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
let dayCounter = 1;
for (let monthIndex = 0; monthIndex < 12; monthIndex++) {
const startDay = dayCounter;
const endDay = dayCounter + monthDays[monthIndex] - 1;
for (let day = startDay; day <= endDay; day++) {
const fraction = (day - 1) / totalDays;
const angle = fraction * Math.PI * 2 - rotationRad;
const sin = Math.sin(angle);
const cos = Math.cos(angle);
const tickOuter = skyR + 22;
const tickInner = tickOuter - (day === selectedDay ? 15 : (day % 5 === 0 ? 9 : 5));
ctx.beginPath();
ctx.moveTo(cx + tickInner * sin, cy - tickInner * cos);
ctx.lineTo(cx + tickOuter * sin, cy - tickOuter * cos);
ctx.strokeStyle = day === selectedDay ? 'rgba(255, 214, 121, 0.96)' : 'rgba(132, 173, 211, 0.35)';
ctx.lineWidth = day === selectedDay ? 2.3 : 1;
ctx.stroke();
}
const middleDay = startDay + (monthDays[monthIndex] - 1) / 2;
const monthAngle = ((middleDay - 1) / totalDays) * Math.PI * 2 - rotationRad;
const labelRadius = skyR + 36;
ctx.fillStyle = 'rgba(214, 229, 244, 0.9)';
ctx.font = '12px Rajdhani, sans-serif';
ctx.fillText(MONTHS[monthIndex], cx + labelRadius * Math.sin(monthAngle), cy - labelRadius * Math.cos(monthAngle));
dayCounter += monthDays[monthIndex];
}
ctx.restore();
}
function drawEquator(rotationRad) {
if (!state.showEquator) return;
ctx.beginPath();
const steps = 240;
let pen = false;
for (let i = 0; i <= steps; i++) {
const raHours = (i / steps) * 24;
const pt = projectWheelPoint(raHours, 0, rotationRad);
if (pen) ctx.lineTo(pt.x, pt.y);
else ctx.moveTo(pt.x, pt.y);
pen = true;
}
ctx.strokeStyle = 'rgba(244, 126, 126, 0.78)';
ctx.lineWidth = 1.1;
ctx.setLineDash([6, 5]);
ctx.stroke();
ctx.setLineDash([]);
}
function drawDeclinationGuides(rotationRad) {
if (!state.showDeclinationLabels) return;
ctx.save();
ctx.strokeStyle = 'rgba(162, 203, 237, 0.22)';
ctx.fillStyle = 'rgba(210, 228, 244, 0.85)';
ctx.font = '11px Rajdhani, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (const dec of [60, 30, 0, -30]) {
const radiusSample = projectWheelPoint(0, dec, rotationRad);
const r = Math.hypot(radiusSample.x - cx, radiusSample.y - cy);
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
ctx.fillText((dec > 0 ? '+' : '') + dec + '°', cx + r + 6, cy);
}
ctx.restore();
}
function drawEcliptic(rotationRad) {
if (!state.showEcliptic) return;
const date = buildZonedDate(getSelectedDate(), state.minutes, state.location.timezone || 'Europe/Berlin');
const atime = Astronomy.MakeTime(date);
const jd = atime.tt + 2451545.0;
const T = (jd - 2451545.0) / 36525.0;
const epsArcsec = 84381.406 - 46.836769 * T - 0.0001831 * T * T + 0.00200340 * T * T * T - 0.000000576 * T * T * T * T - 0.0000000434 * T * T * T * T * T;
const epsR = (epsArcsec / 3600.0) * Math.PI / 180.0;
ctx.beginPath();
let pen = false;
for (let i = 0; i <= 360; i++) {
const lam = (i / 360) * 2 * Math.PI;
const raR = Math.atan2(Math.cos(epsR) * Math.sin(lam), Math.cos(lam));
const decR = Math.asin(Math.sin(epsR) * Math.sin(lam));
const pt = projectWheelPoint(mod((raR * 12 / Math.PI), 24), decR * 180 / Math.PI, rotationRad);
if (pen) ctx.lineTo(pt.x, pt.y);
else ctx.moveTo(pt.x, pt.y);
pen = true;
}
ctx.strokeStyle = 'rgba(255, 186, 89, 0.82)';
ctx.lineWidth = 1.15;
ctx.setLineDash([5, 5]);
ctx.stroke();
ctx.setLineDash([]);
}
function drawSkyWheel(rotationRad, horizonPath) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, skyR + 8, 0, Math.PI * 2);
ctx.clip();
const skyGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, skyR);
skyGrad.addColorStop(0, '#0f2238');
skyGrad.addColorStop(1, '#040910');
ctx.fillStyle = skyGrad;
ctx.fillRect(cx - skyR - 20, cy - skyR - 20, (skyR + 20) * 2, (skyR + 20) * 2);
ctx.save();
ctx.clip(horizonPath);
drawDeclinationGuides(rotationRad);
drawEquator(rotationRad);
drawEcliptic(rotationRad);
drawConstellations(rotationRad);
drawMessier(rotationRad);
drawStars(rotationRad);
ctx.restore();
ctx.restore();
}
function drawConstellations(rotationRad) {
if (!Array.isArray(constellationData.constellations)) return;
for (const con of constellationData.constellations) {
if (state.showConstLines) {
for (const segment of con.lines || []) {
ctx.beginPath();
let pen = false;
for (const hip of segment) {
const star = hipMap.get(hip);
if (!star) { pen = false; continue; }
const pt = projectWheelPoint(star.ra * (12 / Math.PI), star.dec * (180 / Math.PI), rotationRad);
if (pen) ctx.lineTo(pt.x, pt.y); else ctx.moveTo(pt.x, pt.y);
pen = true;
}
ctx.strokeStyle = 'rgba(107, 167, 230, 0.42)';
ctx.lineWidth = 0.85;
ctx.stroke();
}
}
if (!state.showConstLabels) continue;
const label = (con.common_name && (con.common_name.german || con.common_name.native || con.id)) || con.id;
const pos = Array.isArray(con.label_positions) && con.label_positions.length ? con.label_positions[0] : null;
if (!label || !Array.isArray(pos) || pos.length < 2) continue;
const pt = projectWheelPoint(Number(pos[0]), Number(pos[1]), rotationRad);
ctx.fillStyle = 'rgba(171, 214, 248, 0.82)';
ctx.font = '11px Rajdhani, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, pt.x, pt.y);
}
}
function drawMessier(rotationRad) {
if (!state.showMessier) return;
ctx.font = '10px Rajdhani, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
for (const obj of messierData) {
if (Number(obj.mag) > 8.5) continue;
const pt = projectWheelPoint(Number(obj.ra), Number(obj.dec), rotationRad);
const r = Math.max(3, Math.min(7, Number(obj.size || 8) * 0.08 + 3));
ctx.beginPath();
ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 210, 123, 0.92)';
ctx.lineWidth = 1;
ctx.stroke();
if (state.showMessierLabels) {
ctx.fillStyle = 'rgba(255, 224, 163, 0.96)';
ctx.fillText('M' + obj.m, pt.x + r + 3, pt.y);
}
}
}
function drawStars(rotationRad) {
for (const star of stars) {
if (star.mag > state.magLimit) continue;
const pt = projectWheelPoint(star.ra * (12 / Math.PI), star.dec * (180 / Math.PI), rotationRad);
const t = Math.max(0.08, (state.magLimit - star.mag) / (state.magLimit + 1.6));
const radius = Math.max(0.5, Math.min(3.7, 0.4 + t * 3.4));
ctx.beginPath();
ctx.fillStyle = starColor(star);
ctx.globalAlpha = clamp(1.03 - star.mag * 0.08, 0.45, 1);
ctx.arc(pt.x, pt.y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
if (state.showStarLabels && star.proper && star.mag <= 2.2) {
ctx.fillStyle = 'rgba(239, 246, 255, 0.94)';
ctx.font = '11px Rajdhani, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(star.proper, pt.x + radius + 4, pt.y - 1);
}
}
}
function drawMaskAndHorizon(points, horizonPath, rotationRad) {
const maskPath = new Path2D();
maskPath.arc(cx, cy, skyR + 8, 0, Math.PI * 2);
for (let i = 0; i < points.length; i++) {
const pt = points[i];
if (i === 0) maskPath.moveTo(pt.x, pt.y);
else maskPath.lineTo(pt.x, pt.y);
}
maskPath.closePath();
ctx.fillStyle = 'rgba(2, 5, 10, 0.78)';
ctx.fill(maskPath, 'evenodd');
ctx.strokeStyle = 'rgba(91, 170, 108, 0.86)';
ctx.lineWidth = 3;
ctx.stroke(horizonPath);
const date = buildZonedDate(getSelectedDate(), state.minutes, state.location.timezone || 'Europe/Berlin');
const lstHours = localSiderealHours(date, Number(state.location.longitude || 0));
const cardinals = [['N',0],['O',90],['S',180],['W',270]];
ctx.fillStyle = 'rgba(145, 201, 255, 0.96)';
ctx.font = 'bold 15px Rajdhani, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const [label, az] of cardinals) {
const eq = horizonRaDec(az, lstHours);
const pt = projectWheelPoint(eq.raHours, eq.decDeg, rotationRad);
const vx = pt.x - cx;
const vy = pt.y - cy;
const len = Math.max(1, Math.hypot(vx, vy));
ctx.fillText(label, pt.x + (vx / len) * 18, pt.y + (vy / len) * 18);
}
}
function drawCenterBezel() {
ctx.beginPath();
ctx.arc(cx, cy, skyR + 8, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(136, 186, 228, 0.18)';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, 3, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 229, 166, 0.94)';
ctx.fill();
}
function updateSummary() {
summaryText.textContent =
state.location.name +
' · ' +
getSelectedDate() +
' · ' +
minutesToTimeText(state.minutes) +
' · Ziehen dreht die Sternscheibe unter der festen Maske wie bei einer echten Planisphäre.';
}
function render() {
syncUi();
resizeCanvas();
const rotationRad = computeWheelRotationRad();
const horizonPoints = buildHorizonPoints(rotationRad);
const horizonPath = createHorizonPath(horizonPoints);
drawBase();
drawFixedTimeRing();
drawDateRing(rotationRad);
drawSkyWheel(rotationRad, horizonPath);
drawMaskAndHorizon(horizonPoints, horizonPath, rotationRad);
drawCenterBezel();
updateSummary();
chartChip.textContent = state.location.name + ' · ' + getSelectedDate() + ' · ' + minutesToTimeText(state.minutes);
dragChip.textContent = dragActive ? 'Drehen…' : 'Ziehen zum Drehen';
}
function pointAngle(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
return Math.atan2(clientX - rect.left - cx, -(clientY - rect.top - cy));
}
function startDrag(clientX, clientY) {
dragActive = true;
dragLastAngle = pointAngle(clientX, clientY);
canvas.classList.add('dragging');
render();
}
function moveDrag(clientX, clientY) {
if (!dragActive) return;
const currentAngle = pointAngle(clientX, clientY);
let delta = currentAngle - dragLastAngle;
if (delta > Math.PI) delta -= Math.PI * 2;
if (delta < -Math.PI) delta += Math.PI * 2;
dragLastAngle = currentAngle;
state.minutes = mod(state.minutes - Math.round(delta / (Math.PI * 2) * 1440), 1440);
render();
}
function endDrag() {
if (!dragActive) return;
dragActive = false;
canvas.classList.remove('dragging');
render();
}
function bindUi() {
populateLocationSelect();
locationSelect.addEventListener('change', function () {
state.location = selectedLocationByValue(this.value);
if (!state.location.timezone) state.location.timezone = 'Europe/Berlin';
setNow();
});
dateInput.addEventListener('input', function () {
state.dateIso = this.value || state.dateIso;
render();
});
timeInput.addEventListener('input', function () {
const parts = String(this.value || '').split(':');
if (parts.length >= 2) {
state.minutes = Number(parts[0]) * 60 + Number(parts[1]);
render();
}
});
document.getElementById('btnNow').addEventListener('click', setNow);
document.getElementById('btnMinus1h').addEventListener('click', function () {
state.minutes = mod(state.minutes - 60, 1440);
render();
});
document.getElementById('btnPlus1h').addEventListener('click', function () {
state.minutes = mod(state.minutes + 60, 1440);
render();
});
document.getElementById('btnReset').addEventListener('click', resetView);
magInput.addEventListener('input', function () {
state.magLimit = Number(this.value);
render();
});
const toggleMap = [
['toggleEcliptic', 'showEcliptic'],
['toggleEquator', 'showEquator'],
['toggleDeclinationLabels', 'showDeclinationLabels'],
['toggleConstLines', 'showConstLines'],
['toggleConstLabels', 'showConstLabels'],
['toggleStarLabels', 'showStarLabels'],
['toggleMessier', 'showMessier'],
['toggleMessierLabels', 'showMessierLabels'],
];
for (const [id, key] of toggleMap) {
document.getElementById(id).addEventListener('change', function () {
state[key] = this.checked;
render();
});
}
canvas.addEventListener('mousedown', function (event) {
startDrag(event.clientX, event.clientY);
});
window.addEventListener('mousemove', function (event) {
moveDrag(event.clientX, event.clientY);
});
window.addEventListener('mouseup', endDrag);
canvas.addEventListener('touchstart', function (event) {
if (!event.touches.length) return;
startDrag(event.touches[0].clientX, event.touches[0].clientY);
event.preventDefault();
}, { passive: false });
canvas.addEventListener('touchmove', function (event) {
if (!event.touches.length) return;
moveDrag(event.touches[0].clientX, event.touches[0].clientY);
event.preventDefault();
}, { passive: false });
window.addEventListener('touchend', endDrag);
window.addEventListener('resize', render);
}
function loadStars() {
chartChip.textContent = 'Sternscheibe wird aus der Datenbank geladen…';
return fetch(STAR_DATA_URL)
.then(response => response.json())
.then(data => {
if (!data || !data.ok || !Array.isArray(data.stars)) {
throw new Error(data && data.error ? data.error : 'Sterndaten konnten nicht geladen werden.');
}
for (const row of data.stars) {
const star = {
hip: row.hip ?? null,
proper: row.proper || '',
bayer: row.bayer || '',
con: row.con || '',
spect: row.spect || '',
ra: Number(row.ra),
dec: Number(row.dec),
mag: Number(row.mag),
ci: row.ci === null || row.ci === undefined ? NaN : Number(row.ci),
};
if (!Number.isFinite(star.ra) || !Number.isFinite(star.dec) || !Number.isFinite(star.mag)) continue;
stars.push(star);
if (star.hip !== null) hipMap.set(star.hip, star);
}
});
}
if (EMBEDDED_CONSTELLATION_ERROR) console.warn('Sternbilddaten nicht geladen:', EMBEDDED_CONSTELLATION_ERROR);
if (EMBEDDED_MESSIER_ERROR) console.warn('Messier-Daten nicht geladen:', EMBEDDED_MESSIER_ERROR);
bindUi();
state.location = selectedLocationByValue(locationSelect.value);
resetView();
loadStars()
.then(render)
.catch(error => {
console.error(error);
chartChip.textContent = 'Fehler beim Laden der Sternscheibe';
summaryText.textContent = String(error.message || error);
render();
});
})();
</script>
<?php require __DIR__ . '/footer.php'; ?>