1252 lines
44 KiB
PHP
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'; ?>
|