2787 lines
93 KiB
PHP
2787 lines
93 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
session_start();
|
|
|
|
$loggedIn = isset($_SESSION['user_id']);
|
|
$pageTitle = 'Monatsvorhersage - AstroTools';
|
|
$bodyClass = 'admin-page monatsvorhersage-page';
|
|
$publicBasePath = './';
|
|
|
|
$currentYear = (int) date('Y');
|
|
$selectedYear = isset($_GET['year']) ? (int) $_GET['year'] : $currentYear;
|
|
$selectedMonth = isset($_GET['month']) ? max(1, min(12, (int) $_GET['month'])) : (int) date('n');
|
|
$starDatabaseMagnitudeLimit = 6.0;
|
|
$starDisplayMagnitudeLimit = 4.4;
|
|
$starLabelMagnitudeLimit = 2.0;
|
|
$messierDatabaseMagnitudeLimit = 4.0;
|
|
$messierDisplayMagnitudeLimit = 1.0;
|
|
$moonPlanetMaxSeparationDeg = 1.5;
|
|
$planetConjunctionMaxSeparationDeg = 1.0;
|
|
$moonOccultationMaxMag = 2.0;
|
|
|
|
$years = range($currentYear - 2, $currentYear + 5);
|
|
$months = [
|
|
1 => 'Januar',
|
|
2 => 'Februar',
|
|
3 => 'Maerz',
|
|
4 => 'April',
|
|
5 => 'Mai',
|
|
6 => 'Juni',
|
|
7 => 'Juli',
|
|
8 => 'August',
|
|
9 => 'September',
|
|
10 => 'Oktober',
|
|
11 => 'November',
|
|
12 => 'Dezember',
|
|
];
|
|
$monthSkyTimes = [
|
|
1 => ['hour' => 22, 'minute' => 0],
|
|
2 => ['hour' => 22, 'minute' => 0],
|
|
3 => ['hour' => 22, 'minute' => 0],
|
|
4 => ['hour' => 23, 'minute' => 0],
|
|
5 => ['hour' => 23, 'minute' => 0],
|
|
6 => ['hour' => 23, 'minute' => 30],
|
|
7 => ['hour' => 23, 'minute' => 30],
|
|
8 => ['hour' => 23, 'minute' => 0],
|
|
9 => ['hour' => 23, 'minute' => 0],
|
|
10 => ['hour' => 23, 'minute' => 0],
|
|
11 => ['hour' => 22, 'minute' => 0],
|
|
12 => ['hour' => 22, 'minute' => 0],
|
|
];
|
|
|
|
$starData = [];
|
|
$lineStarData = [];
|
|
$constellationLines = [];
|
|
$messierData = [];
|
|
$planetData = [];
|
|
$moonData = null;
|
|
$moonPhaseEvents = [];
|
|
$moonPlanetApproaches = [];
|
|
$planetConjunctions = [];
|
|
$eclipseEvents = [];
|
|
$moonApsisEvents = [];
|
|
$sunApsisEvents = [];
|
|
$innerPlanetElongationEvents = [];
|
|
$outerPlanetEvents = [];
|
|
$moonOccultations = [];
|
|
$seasonChanges = [];
|
|
$monthEventList = [];
|
|
$starDataError = null;
|
|
|
|
if (!function_exists('buildMonthOccultationStarLabel')) {
|
|
function buildMonthOccultationStarLabel(array $row): string
|
|
{
|
|
$proper = trim((string) ($row['proper'] ?? ''));
|
|
if ($proper !== '') {
|
|
return $proper;
|
|
}
|
|
|
|
$bayer = trim((string) ($row['bayer'] ?? ''));
|
|
$con = trim((string) ($row['con'] ?? ''));
|
|
if ($bayer !== '') {
|
|
return $con !== '' ? $bayer . ' ' . $con : $bayer;
|
|
}
|
|
|
|
$hip = isset($row['hip']) ? (int) $row['hip'] : 0;
|
|
return $hip > 0 ? 'HIP ' . $hip : 'Unbenannter Stern';
|
|
}
|
|
}
|
|
|
|
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,
|
|
]);
|
|
|
|
$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) $starDatabaseMagnitudeLimit . "
|
|
ORDER BY mag ASC"
|
|
);
|
|
|
|
while ($row = $starStmt->fetch()) {
|
|
$starData[] = [
|
|
'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,
|
|
];
|
|
}
|
|
|
|
$constellationIndex = [];
|
|
$ensureConstellation = static function (string $id) use (&$constellationIndex): void {
|
|
if (!isset($constellationIndex[$id])) {
|
|
$constellationIndex[$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) {
|
|
$constellationId = (string) $row['constellation_id'];
|
|
$ensureConstellation($constellationId);
|
|
$constellationIndex[$constellationId]['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) {
|
|
$constellationId = (string) $row['constellation_id'];
|
|
$ensureConstellation($constellationId);
|
|
$constellationIndex[$constellationId]['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) {
|
|
$constellationId = (string) $row['constellation_id'];
|
|
$lineIndex = (int) $row['line_index'];
|
|
|
|
$ensureConstellation($constellationId);
|
|
|
|
$constellationIndex[$constellationId]['lines'][$lineIndex] ??= [];
|
|
$constellationIndex[$constellationId]['lines'][$lineIndex][] = (int) $row['hip_number'];
|
|
}
|
|
|
|
foreach ($constellationIndex as &$constellation) {
|
|
if ($constellation['lines']) {
|
|
ksort($constellation['lines']);
|
|
$constellation['lines'] = array_values($constellation['lines']);
|
|
}
|
|
}
|
|
unset($constellation);
|
|
|
|
$constellationLines = array_values($constellationIndex);
|
|
|
|
$lineStarStmt = $pdo->query(
|
|
"SELECT DISTINCT sh.hip, sh.proper, sh.bayer, sh.con, sh.spect, sh.mag, sh.ci, sh.rarad, sh.decrad
|
|
FROM constellation_line_points clp
|
|
INNER JOIN star_hipparcos sh ON sh.hip = clp.hip_number
|
|
WHERE sh.rarad IS NOT NULL
|
|
AND sh.decrad IS NOT NULL"
|
|
);
|
|
|
|
while ($row = $lineStarStmt->fetch()) {
|
|
$lineStarData[] = [
|
|
'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' => $row['mag'] !== null ? (float) $row['mag'] : 99.0,
|
|
'ci' => $row['ci'] !== null ? (float) $row['ci'] : null,
|
|
];
|
|
}
|
|
|
|
$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
|
|
AND v_mag IS NOT NULL
|
|
AND v_mag <= " . (float) $messierDatabaseMagnitudeLimit . "
|
|
ORDER BY v_mag ASC, CAST(REGEXP_REPLACE(messier, '[^0-9]', '') AS UNSIGNED)"
|
|
);
|
|
|
|
while ($row = $messierStmt->fetch()) {
|
|
if (!preg_match('/(\d+)/', (string) $row['messier'], $match)) {
|
|
continue;
|
|
}
|
|
|
|
$messierData[] = [
|
|
'm' => (int) $match[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' => (float) $row['v_mag'],
|
|
'size' => $row['major_axis'] !== null ? (float) $row['major_axis'] : 8.0,
|
|
];
|
|
}
|
|
} catch (Throwable $e) {
|
|
$starDataError = $e->getMessage();
|
|
}
|
|
|
|
require __DIR__ . '/header.php';
|
|
|
|
$chartLocationName = (string) (($currentLocation['name'] ?? null) ?: 'Sternwarte Sonneberg');
|
|
$chartLatitude = isset($currentLocation['latitude']) ? (float) $currentLocation['latitude'] : 50.3547;
|
|
$chartLongitude = isset($currentLocation['longitude']) ? (float) $currentLocation['longitude'] : 11.1900;
|
|
$chartElevation = isset($currentLocation['elevation']) && $currentLocation['elevation'] !== null ? (float) $currentLocation['elevation'] : 640.0;
|
|
$chartTimezone = (string) (($currentLocation['timezone'] ?? null) ?: 'Europe/Berlin');
|
|
|
|
$monthTime = $monthSkyTimes[$selectedMonth];
|
|
$chartLocalTime = new DateTimeImmutable(
|
|
sprintf('%04d-%02d-15 %02d:%02d:00', $selectedYear, $selectedMonth, $monthTime['hour'], $monthTime['minute']),
|
|
new DateTimeZone($chartTimezone)
|
|
);
|
|
$chartUtcTime = $chartLocalTime->setTimezone(new DateTimeZone('UTC'));
|
|
|
|
$pythonExecutable = PHP_OS_FAMILY === 'Windows' ? 'python' : '/usr/bin/python';
|
|
$pythonScriptPath = __DIR__ . '/py/api.py';
|
|
$pythonOutput = [];
|
|
$pythonExitCode = 0;
|
|
$planetErrorText = null;
|
|
|
|
$pythonArgs = [
|
|
$pythonScriptPath,
|
|
'month_sky_context',
|
|
(string) $chartLatitude,
|
|
(string) $chartLongitude,
|
|
(string) $chartElevation,
|
|
(string) $selectedYear,
|
|
(string) $selectedMonth,
|
|
(string) $monthTime['hour'],
|
|
(string) $monthTime['minute'],
|
|
$chartTimezone,
|
|
];
|
|
|
|
$pythonCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $pythonArgs)) . ' 2>&1';
|
|
exec($pythonCommand, $pythonOutput, $pythonExitCode);
|
|
$pythonRaw = trim(implode("\n", $pythonOutput));
|
|
$pythonDecoded = json_decode($pythonRaw, true);
|
|
|
|
if (is_array($pythonDecoded) && ($pythonDecoded['ok'] ?? false)) {
|
|
if (is_array($pythonDecoded['bodies']['moon'] ?? null)) {
|
|
$moon = $pythonDecoded['bodies']['moon'];
|
|
$moonData = [
|
|
'label' => (string) ($moon['label'] ?? 'Mond'),
|
|
'az' => isset($moon['azimuth_deg']) ? (float) $moon['azimuth_deg'] : null,
|
|
'alt' => isset($moon['altitude_deg']) ? (float) $moon['altitude_deg'] : null,
|
|
'visible' => !empty($moon['visible']),
|
|
'phaseFraction' => isset($moon['phase_fraction']) ? (float) $moon['phase_fraction'] : null,
|
|
'waxing' => isset($moon['waxing']) ? (bool) $moon['waxing'] : null,
|
|
];
|
|
}
|
|
foreach (($pythonDecoded['bodies']['planets'] ?? []) as $planet) {
|
|
if (!is_array($planet)) {
|
|
continue;
|
|
}
|
|
$planetData[] = [
|
|
'key' => (string) ($planet['key'] ?? ''),
|
|
'label' => (string) ($planet['label'] ?? ''),
|
|
'az' => isset($planet['azimuth_deg']) ? (float) $planet['azimuth_deg'] : null,
|
|
'alt' => isset($planet['altitude_deg']) ? (float) $planet['altitude_deg'] : null,
|
|
'mag' => isset($planet['magnitude']) ? (float) $planet['magnitude'] : null,
|
|
'visible' => !empty($planet['visible']),
|
|
];
|
|
}
|
|
} elseif ($pythonRaw !== '') {
|
|
$planetErrorText = $pythonRaw;
|
|
}
|
|
|
|
$moonPhaseOutput = [];
|
|
$moonPhaseExitCode = 0;
|
|
$moonPhaseArgs = [
|
|
$pythonScriptPath,
|
|
'moon_phases_for_month',
|
|
(string) $selectedYear,
|
|
(string) $selectedMonth,
|
|
$chartTimezone,
|
|
];
|
|
$moonPhaseCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonPhaseArgs)) . ' 2>&1';
|
|
exec($moonPhaseCommand, $moonPhaseOutput, $moonPhaseExitCode);
|
|
$moonPhaseRaw = trim(implode("\n", $moonPhaseOutput));
|
|
$moonPhaseDecoded = json_decode($moonPhaseRaw, true);
|
|
if (is_array($moonPhaseDecoded) && ($moonPhaseDecoded['ok'] ?? false)) {
|
|
$moonPhaseEvents = is_array($moonPhaseDecoded['phases'] ?? null) ? $moonPhaseDecoded['phases'] : [];
|
|
}
|
|
|
|
$seasonOutput = [];
|
|
$seasonExitCode = 0;
|
|
$seasonArgs = [
|
|
$pythonScriptPath,
|
|
'season_changes_for_month',
|
|
(string) $selectedYear,
|
|
(string) $selectedMonth,
|
|
$chartTimezone,
|
|
];
|
|
$seasonCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $seasonArgs)) . ' 2>&1';
|
|
exec($seasonCommand, $seasonOutput, $seasonExitCode);
|
|
$seasonRaw = trim(implode("\n", $seasonOutput));
|
|
$seasonDecoded = json_decode($seasonRaw, true);
|
|
if (is_array($seasonDecoded) && ($seasonDecoded['ok'] ?? false)) {
|
|
$seasonChanges = is_array($seasonDecoded['changes'] ?? null) ? $seasonDecoded['changes'] : [];
|
|
}
|
|
|
|
$eclipseOutput = [];
|
|
$eclipseExitCode = 0;
|
|
$eclipseArgs = [
|
|
$pythonScriptPath,
|
|
'eclipses_for_month',
|
|
(string) $chartLatitude,
|
|
(string) $chartLongitude,
|
|
(string) $chartElevation,
|
|
(string) $selectedYear,
|
|
(string) $selectedMonth,
|
|
$chartTimezone,
|
|
];
|
|
$eclipseCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $eclipseArgs)) . ' 2>&1';
|
|
exec($eclipseCommand, $eclipseOutput, $eclipseExitCode);
|
|
$eclipseRaw = trim(implode("\n", $eclipseOutput));
|
|
$eclipseDecoded = json_decode($eclipseRaw, true);
|
|
if (is_array($eclipseDecoded) && ($eclipseDecoded['ok'] ?? false)) {
|
|
$eclipseEvents = is_array($eclipseDecoded['events'] ?? null) ? $eclipseDecoded['events'] : [];
|
|
}
|
|
|
|
$moonApsisOutput = [];
|
|
$moonApsisExitCode = 0;
|
|
$moonApsisArgs = [
|
|
$pythonScriptPath,
|
|
'moon_apsides_for_month',
|
|
(string) $selectedYear,
|
|
(string) $selectedMonth,
|
|
$chartTimezone,
|
|
];
|
|
$moonApsisCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonApsisArgs)) . ' 2>&1';
|
|
exec($moonApsisCommand, $moonApsisOutput, $moonApsisExitCode);
|
|
$moonApsisRaw = trim(implode("\n", $moonApsisOutput));
|
|
$moonApsisDecoded = json_decode($moonApsisRaw, true);
|
|
if (is_array($moonApsisDecoded) && ($moonApsisDecoded['ok'] ?? false)) {
|
|
$moonApsisEvents = is_array($moonApsisDecoded['events'] ?? null) ? $moonApsisDecoded['events'] : [];
|
|
}
|
|
|
|
$sunApsisOutput = [];
|
|
$sunApsisExitCode = 0;
|
|
$sunApsisArgs = [
|
|
$pythonScriptPath,
|
|
'sun_apsides_for_month',
|
|
(string) $selectedYear,
|
|
(string) $selectedMonth,
|
|
$chartTimezone,
|
|
];
|
|
$sunApsisCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $sunApsisArgs)) . ' 2>&1';
|
|
exec($sunApsisCommand, $sunApsisOutput, $sunApsisExitCode);
|
|
$sunApsisRaw = trim(implode("\n", $sunApsisOutput));
|
|
$sunApsisDecoded = json_decode($sunApsisRaw, true);
|
|
if (is_array($sunApsisDecoded) && ($sunApsisDecoded['ok'] ?? false)) {
|
|
$sunApsisEvents = is_array($sunApsisDecoded['events'] ?? null) ? $sunApsisDecoded['events'] : [];
|
|
}
|
|
|
|
$elongationOutput = [];
|
|
$elongationExitCode = 0;
|
|
$elongationArgs = [
|
|
$pythonScriptPath,
|
|
'inner_planet_elongations_for_month',
|
|
(string) $selectedYear,
|
|
(string) $selectedMonth,
|
|
$chartTimezone,
|
|
];
|
|
$elongationCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $elongationArgs)) . ' 2>&1';
|
|
exec($elongationCommand, $elongationOutput, $elongationExitCode);
|
|
$elongationRaw = trim(implode("\n", $elongationOutput));
|
|
$elongationDecoded = json_decode($elongationRaw, true);
|
|
if (is_array($elongationDecoded) && ($elongationDecoded['ok'] ?? false)) {
|
|
$innerPlanetElongationEvents = is_array($elongationDecoded['events'] ?? null) ? $elongationDecoded['events'] : [];
|
|
}
|
|
|
|
$outerPlanetOutput = [];
|
|
$outerPlanetExitCode = 0;
|
|
$outerPlanetArgs = [
|
|
$pythonScriptPath,
|
|
'outer_planet_events_for_month',
|
|
(string) $selectedYear,
|
|
(string) $selectedMonth,
|
|
$chartTimezone,
|
|
];
|
|
$outerPlanetCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $outerPlanetArgs)) . ' 2>&1';
|
|
exec($outerPlanetCommand, $outerPlanetOutput, $outerPlanetExitCode);
|
|
$outerPlanetRaw = trim(implode("\n", $outerPlanetOutput));
|
|
$outerPlanetDecoded = json_decode($outerPlanetRaw, true);
|
|
if (is_array($outerPlanetDecoded) && ($outerPlanetDecoded['ok'] ?? false)) {
|
|
$outerPlanetEvents = is_array($outerPlanetDecoded['events'] ?? null) ? $outerPlanetDecoded['events'] : [];
|
|
}
|
|
|
|
$moonPlanetOutput = [];
|
|
$moonPlanetExitCode = 0;
|
|
$moonPlanetArgs = [
|
|
$pythonScriptPath,
|
|
'moon_planet_approaches',
|
|
(string) $chartLatitude,
|
|
(string) $chartLongitude,
|
|
(string) $chartElevation,
|
|
(string) $selectedYear,
|
|
(string) $selectedMonth,
|
|
$chartTimezone,
|
|
(string) $moonPlanetMaxSeparationDeg,
|
|
];
|
|
$moonPlanetCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonPlanetArgs)) . ' 2>&1';
|
|
exec($moonPlanetCommand, $moonPlanetOutput, $moonPlanetExitCode);
|
|
$moonPlanetRaw = trim(implode("\n", $moonPlanetOutput));
|
|
$moonPlanetDecoded = json_decode($moonPlanetRaw, true);
|
|
if (is_array($moonPlanetDecoded) && ($moonPlanetDecoded['ok'] ?? false)) {
|
|
$moonPlanetApproaches = is_array($moonPlanetDecoded['approaches'] ?? null) ? $moonPlanetDecoded['approaches'] : [];
|
|
}
|
|
|
|
$planetConjunctionOutput = [];
|
|
$planetConjunctionExitCode = 0;
|
|
$planetConjunctionArgs = [
|
|
$pythonScriptPath,
|
|
'planet_conjunctions_for_month',
|
|
(string) $chartLatitude,
|
|
(string) $chartLongitude,
|
|
(string) $chartElevation,
|
|
(string) $selectedYear,
|
|
(string) $selectedMonth,
|
|
$chartTimezone,
|
|
(string) $planetConjunctionMaxSeparationDeg,
|
|
];
|
|
$planetConjunctionCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $planetConjunctionArgs)) . ' 2>&1';
|
|
exec($planetConjunctionCommand, $planetConjunctionOutput, $planetConjunctionExitCode);
|
|
$planetConjunctionRaw = trim(implode("\n", $planetConjunctionOutput));
|
|
$planetConjunctionDecoded = json_decode($planetConjunctionRaw, true);
|
|
if (is_array($planetConjunctionDecoded) && ($planetConjunctionDecoded['ok'] ?? false)) {
|
|
$planetConjunctions = is_array($planetConjunctionDecoded['conjunctions'] ?? null) ? $planetConjunctionDecoded['conjunctions'] : [];
|
|
}
|
|
|
|
$moonOccultationStars = [];
|
|
try {
|
|
if (isset($pdo)) {
|
|
$stmtOccultStars = $pdo->prepare(
|
|
"SELECT hip, proper, bayer, con, mag, rarad, decrad, dist
|
|
FROM star_hipparcos
|
|
WHERE rarad IS NOT NULL
|
|
AND decrad IS NOT NULL
|
|
AND mag IS NOT NULL
|
|
AND mag <= :max_mag
|
|
ORDER BY mag ASC, proper ASC, bayer ASC, hip ASC"
|
|
);
|
|
$stmtOccultStars->execute([':max_mag' => $moonOccultationMaxMag]);
|
|
|
|
foreach ($stmtOccultStars->fetchAll() as $row) {
|
|
$moonOccultationStars[] = [
|
|
'hip' => isset($row['hip']) ? (int) $row['hip'] : 0,
|
|
'label' => buildMonthOccultationStarLabel($row),
|
|
'constellation' => trim((string) ($row['con'] ?? '')),
|
|
'mag' => isset($row['mag']) ? (float) $row['mag'] : 99.0,
|
|
'ra' => isset($row['rarad']) ? (float) $row['rarad'] * (12 / M_PI) : 0.0,
|
|
'dec' => isset($row['decrad']) ? (float) $row['decrad'] * (180 / M_PI) : 0.0,
|
|
'distLy' => isset($row['dist']) && $row['dist'] !== null && (float) $row['dist'] > 0.0
|
|
? (float) $row['dist'] * 3.26156
|
|
: 1000.0,
|
|
];
|
|
}
|
|
}
|
|
} catch (Throwable $e) {
|
|
}
|
|
|
|
if ($moonOccultationStars) {
|
|
$tempOccultationFile = tempnam(sys_get_temp_dir(), 'month_occult_');
|
|
if ($tempOccultationFile !== false) {
|
|
try {
|
|
file_put_contents($tempOccultationFile, json_encode($moonOccultationStars, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
|
$moonOccultOutput = [];
|
|
$moonOccultExitCode = 0;
|
|
$moonOccultArgs = [
|
|
$pythonScriptPath,
|
|
'moon_star_occultations_for_month',
|
|
(string) $chartLatitude,
|
|
(string) $chartLongitude,
|
|
(string) $chartElevation,
|
|
(string) $selectedYear,
|
|
(string) $selectedMonth,
|
|
$chartTimezone,
|
|
$tempOccultationFile,
|
|
];
|
|
$moonOccultCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonOccultArgs)) . ' 2>&1';
|
|
exec($moonOccultCommand, $moonOccultOutput, $moonOccultExitCode);
|
|
$moonOccultRaw = trim(implode("\n", $moonOccultOutput));
|
|
$moonOccultDecoded = json_decode($moonOccultRaw, true);
|
|
if (is_array($moonOccultDecoded) && ($moonOccultDecoded['ok'] ?? false)) {
|
|
$moonOccultations = is_array($moonOccultDecoded['results'] ?? null) ? $moonOccultDecoded['results'] : [];
|
|
}
|
|
} catch (Throwable $e) {
|
|
} finally {
|
|
if (is_file($tempOccultationFile)) {
|
|
@unlink($tempOccultationFile);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($moonPhaseEvents as $phaseEvent) {
|
|
if (!is_array($phaseEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$monthEventList[] = [
|
|
'event' => (string) ($phaseEvent['label'] ?? ''),
|
|
'date' => (string) ($phaseEvent['local_date'] ?? ''),
|
|
'time' => (string) ($phaseEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($phaseEvent['local_iso'] ?? ''),
|
|
'type' => 'moon_phase',
|
|
];
|
|
}
|
|
|
|
foreach ($seasonChanges as $seasonChange) {
|
|
if (!is_array($seasonChange)) {
|
|
continue;
|
|
}
|
|
|
|
$monthEventList[] = [
|
|
'event' => (string) ($seasonChange['label'] ?? 'Jahreszeitenwechsel'),
|
|
'date' => (string) ($seasonChange['local_date'] ?? ''),
|
|
'time' => (string) ($seasonChange['local_time'] ?? ''),
|
|
'local_iso' => (string) ($seasonChange['local_iso'] ?? ''),
|
|
'type' => 'season_change',
|
|
];
|
|
}
|
|
|
|
foreach ($eclipseEvents as $eclipseEvent) {
|
|
if (!is_array($eclipseEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$monthEventList[] = [
|
|
'event' => (string) ($eclipseEvent['label'] ?? 'Finsternis'),
|
|
'date' => (string) ($eclipseEvent['local_date'] ?? ''),
|
|
'time' => (string) ($eclipseEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($eclipseEvent['local_iso'] ?? ''),
|
|
'type' => (string) ($eclipseEvent['category'] ?? 'eclipse'),
|
|
];
|
|
}
|
|
|
|
foreach ($moonApsisEvents as $moonApsisEvent) {
|
|
if (!is_array($moonApsisEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$moonApsisKind = (string) ($moonApsisEvent['kind'] ?? '');
|
|
$distanceText = isset($moonApsisEvent['distance_km'])
|
|
? number_format((float) $moonApsisEvent['distance_km'], 0, ',', '.') . ' km'
|
|
: '';
|
|
$moonApsisLabel = $moonApsisKind === 'perigee'
|
|
? 'Mond in Erdnähe (Perigäum)'
|
|
: ($moonApsisKind === 'apogee' ? 'Mond in Erdferne (Apogäum)' : (string) ($moonApsisEvent['label'] ?? 'Mondabstand'));
|
|
|
|
$monthEventList[] = [
|
|
'event' => trim($moonApsisLabel . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
|
'date' => (string) ($moonApsisEvent['local_date'] ?? ''),
|
|
'time' => (string) ($moonApsisEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($moonApsisEvent['local_iso'] ?? ''),
|
|
'type' => 'moon_apsis',
|
|
];
|
|
}
|
|
|
|
foreach ($sunApsisEvents as $sunApsisEvent) {
|
|
if (!is_array($sunApsisEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$sunApsisKind = (string) ($sunApsisEvent['kind'] ?? '');
|
|
$distanceText = isset($sunApsisEvent['distance_km'])
|
|
? number_format((float) $sunApsisEvent['distance_km'], 0, ',', '.') . ' km'
|
|
: '';
|
|
$sunApsisLabel = $sunApsisKind === 'perihelion'
|
|
? 'Erde erreicht sonnennächsten Punkt (Perihel)'
|
|
: ($sunApsisKind === 'aphelion' ? 'Erde erreicht sonnenfernsten Punkt (Aphel)' : (string) ($sunApsisEvent['label'] ?? 'Sonnenabstand'));
|
|
|
|
$monthEventList[] = [
|
|
'event' => trim($sunApsisLabel . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
|
'date' => (string) ($sunApsisEvent['local_date'] ?? ''),
|
|
'time' => (string) ($sunApsisEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($sunApsisEvent['local_iso'] ?? ''),
|
|
'type' => 'sun_apsis',
|
|
];
|
|
}
|
|
|
|
foreach ($innerPlanetElongationEvents as $elongationEvent) {
|
|
if (!is_array($elongationEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$elongationPlanet = (string) ($elongationEvent['planet_label'] ?? 'Planet');
|
|
$elongationKind = (string) ($elongationEvent['kind'] ?? '');
|
|
$elongationText = isset($elongationEvent['elongation_deg'])
|
|
? number_format((float) $elongationEvent['elongation_deg'], 2, ',', '') . ' Grad'
|
|
: '';
|
|
$elongationLabel = $elongationKind === 'western'
|
|
? $elongationPlanet . ' größte westliche Elongation'
|
|
: ($elongationKind === 'eastern'
|
|
? $elongationPlanet . ' größte östliche Elongation'
|
|
: (string) ($elongationEvent['label'] ?? 'Elongation'));
|
|
|
|
$monthEventList[] = [
|
|
'event' => trim($elongationLabel . ($elongationText !== '' ? ' (' . $elongationText . ')' : '')),
|
|
'date' => (string) ($elongationEvent['local_date'] ?? ''),
|
|
'time' => (string) ($elongationEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($elongationEvent['local_iso'] ?? ''),
|
|
'type' => 'planet_elongation',
|
|
];
|
|
}
|
|
|
|
foreach ($outerPlanetEvents as $outerPlanetEvent) {
|
|
if (!is_array($outerPlanetEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$monthEventList[] = [
|
|
'event' => (string) ($outerPlanetEvent['label'] ?? 'Planetenereignis'),
|
|
'date' => (string) ($outerPlanetEvent['local_date'] ?? ''),
|
|
'time' => (string) ($outerPlanetEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($outerPlanetEvent['local_iso'] ?? ''),
|
|
'type' => 'outer_planet_event',
|
|
];
|
|
}
|
|
|
|
foreach ($moonPlanetApproaches as $approachEvent) {
|
|
if (!is_array($approachEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$distanceText = isset($approachEvent['separation_deg'])
|
|
? number_format((float) $approachEvent['separation_deg'], 2, ',', '') . ' Grad'
|
|
: '';
|
|
|
|
$monthEventList[] = [
|
|
'event' => trim((string) ($approachEvent['label'] ?? 'Mond nahe Planet') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
|
'date' => (string) ($approachEvent['local_date'] ?? ''),
|
|
'time' => (string) ($approachEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($approachEvent['local_iso'] ?? ''),
|
|
'type' => 'moon_planet',
|
|
];
|
|
}
|
|
|
|
foreach ($planetConjunctions as $conjunctionEvent) {
|
|
if (!is_array($conjunctionEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$distanceText = isset($conjunctionEvent['separation_deg'])
|
|
? number_format((float) $conjunctionEvent['separation_deg'], 2, ',', '') . ' Grad'
|
|
: '';
|
|
|
|
$monthEventList[] = [
|
|
'event' => trim((string) ($conjunctionEvent['label'] ?? 'Planetenkonjunktion') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
|
'date' => (string) ($conjunctionEvent['local_date'] ?? ''),
|
|
'time' => (string) ($conjunctionEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($conjunctionEvent['local_iso'] ?? ''),
|
|
'type' => 'planet_conjunction',
|
|
];
|
|
}
|
|
|
|
foreach ($moonOccultations as $occultationEvent) {
|
|
if (!is_array($occultationEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$maximum = is_array($occultationEvent['maximum'] ?? null) ? $occultationEvent['maximum'] : null;
|
|
$star = is_array($occultationEvent['star'] ?? null) ? $occultationEvent['star'] : null;
|
|
if ($maximum === null || $star === null) {
|
|
continue;
|
|
}
|
|
|
|
$starLabel = (string) ($star['label'] ?? 'Stern');
|
|
$starMag = isset($star['mag']) ? number_format((float) $star['mag'], 1, ',', '') : '';
|
|
$monthEventList[] = [
|
|
'event' => 'Mond bedeckt ' . $starLabel . ($starMag !== '' ? ' (' . $starMag . ' mag)' : ''),
|
|
'date' => (string) ($maximum['local_date'] ?? ''),
|
|
'time' => (string) ($maximum['local_time'] ?? ''),
|
|
'local_iso' => (string) ($maximum['local_iso'] ?? ''),
|
|
'type' => 'moon_occultation',
|
|
];
|
|
}
|
|
|
|
usort(
|
|
$monthEventList,
|
|
static fn (array $a, array $b): int => strcmp((string) ($a['local_iso'] ?? ''), (string) ($b['local_iso'] ?? ''))
|
|
);
|
|
|
|
$chartConfig = [
|
|
'monthName' => $months[$selectedMonth],
|
|
'year' => $selectedYear,
|
|
'month' => $selectedMonth,
|
|
'location' => [
|
|
'name' => $chartLocationName,
|
|
'latitude' => $chartLatitude,
|
|
'longitude' => $chartLongitude,
|
|
'elevation' => $chartElevation,
|
|
'timezone' => $chartTimezone,
|
|
],
|
|
'localTimeLabel' => $chartLocalTime->format('d.m.Y H:i') . ' ' . $chartTimezone,
|
|
'utcTimeIso' => $chartUtcTime->format(DateTimeInterface::ATOM),
|
|
'starLimit' => $starDisplayMagnitudeLimit,
|
|
'starLabelLimit' => $starLabelMagnitudeLimit,
|
|
'messierLimit' => $messierDisplayMagnitudeLimit,
|
|
];
|
|
|
|
$chartConfigJson = json_encode($chartConfig, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$starDataJson = json_encode($starData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$lineStarDataJson = json_encode($lineStarData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$constellationLinesJson = json_encode($constellationLines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$messierDataJson = json_encode($messierData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$planetDataJson = json_encode($planetData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$moonDataJson = json_encode($moonData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$starDataErrorJson = json_encode($starDataError, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
?>
|
|
|
|
<section class="card monatsvorhersage-card">
|
|
<div class="monatsvorhersage-shell">
|
|
<form class="monatsvorhersage-form" method="get">
|
|
<div class="monatsvorhersage-form-header">
|
|
<h3>Zeitraum waehlen</h3>
|
|
<p>Die Sternkarte wird fuer den 15. des gewaehlten Monats mit der festen Monatsuhrzeit erzeugt.</p>
|
|
</div>
|
|
|
|
<label class="monatsvorhersage-field">
|
|
<span>Jahr</span>
|
|
<select name="year">
|
|
<?php foreach ($years as $year): ?>
|
|
<option value="<?= htmlspecialchars((string) $year, ENT_QUOTES, 'UTF-8') ?>" <?= $year === $selectedYear ? 'selected' : '' ?>>
|
|
<?= htmlspecialchars((string) $year, ENT_QUOTES, 'UTF-8') ?>
|
|
</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</label>
|
|
|
|
<label class="monatsvorhersage-field">
|
|
<span>Monat</span>
|
|
<select name="month">
|
|
<?php foreach ($months as $monthNumber => $monthLabel): ?>
|
|
<option value="<?= htmlspecialchars((string) $monthNumber, ENT_QUOTES, 'UTF-8') ?>" <?= $monthNumber === $selectedMonth ? 'selected' : '' ?>>
|
|
<?= htmlspecialchars($monthLabel, ENT_QUOTES, 'UTF-8') ?>
|
|
</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</label>
|
|
|
|
<div class="monatsvorhersage-actions">
|
|
<button type="submit">Sternkarte laden</button>
|
|
</div>
|
|
</form>
|
|
|
|
<section class="monatskarte-card">
|
|
<div class="monatskarte-header">
|
|
<div>
|
|
<h3>Zenitprojektion <?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h3>
|
|
<p id="monatskarteMeta">
|
|
<?= htmlspecialchars($chartConfig['localTimeLabel'], ENT_QUOTES, 'UTF-8') ?>,
|
|
<?= htmlspecialchars($chartLocationName, ENT_QUOTES, 'UTF-8') ?>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="monatskarte-stage">
|
|
<canvas id="monatskarteCanvas" width="900" height="900" aria-label="Sternkarte in Zenitprojektion"></canvas>
|
|
</div>
|
|
|
|
<div class="monatskarte-footer">
|
|
<p id="monatskarteStatus">Bereite Sternkarte vor...</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="monatskarte-card">
|
|
<div class="monatskarte-header">
|
|
<div>
|
|
<h3>Südhimmel <?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h3>
|
|
<p id="monatskarteSouthMeta">
|
|
<?= htmlspecialchars($chartConfig['localTimeLabel'], ENT_QUOTES, 'UTF-8') ?>,
|
|
<?= htmlspecialchars($chartLocationName, ENT_QUOTES, 'UTF-8') ?>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="monatskarte-stage monatskarte-stage-south">
|
|
<canvas id="monatskarteSouthCanvas" width="1200" height="720" aria-label="Südhimmel-Projektion"></canvas>
|
|
</div>
|
|
|
|
<div class="monatskarte-footer">
|
|
<p id="monatskarteSouthStatus">Bereite Südhimmelkarte vor...</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="monatskarte-card">
|
|
<div class="monatskarte-header">
|
|
<div>
|
|
<h3>Ereignisse <?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h3>
|
|
<p>Chronologische Monatsliste fuer <?= htmlspecialchars($chartTimezone, ENT_QUOTES, 'UTF-8') ?></p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="monatskarte-footer">
|
|
<?php if ($monthEventList): ?>
|
|
<div class="riseset-grid" style="grid-template-columns: 1.5fr 1fr 1fr; row-gap: 10px;">
|
|
<span class="riseset-label riseset-dim">Ereignis</span>
|
|
<span class="riseset-label riseset-dim">Datum</span>
|
|
<span class="riseset-label riseset-dim">Uhrzeit</span>
|
|
<?php foreach ($monthEventList as $eventItem): ?>
|
|
<span class="riseset-val"><?= htmlspecialchars((string) ($eventItem['event'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
|
|
<span class="riseset-val"><?= htmlspecialchars((string) ($eventItem['date'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
|
|
<span class="riseset-val"><?= htmlspecialchars((string) ($eventItem['time'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<?php else: ?>
|
|
<p>Fuer diesen Monat wurden keine Ereignisse gefunden.</p>
|
|
<?php endif; ?>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
</section>
|
|
|
|
<style>
|
|
.monatsvorhersage-page main > .container {
|
|
max-width: min(1280px, calc(100% - 28px));
|
|
}
|
|
|
|
.monatsvorhersage-card {
|
|
padding: 18px;
|
|
}
|
|
|
|
.monatsvorhersage-shell {
|
|
display: grid;
|
|
gap: 18px;
|
|
}
|
|
|
|
.monatsvorhersage-form,
|
|
.monatskarte-card {
|
|
border: 1px solid rgba(201, 168, 76, 0.14);
|
|
border-radius: calc(var(--radius) + 4px);
|
|
background:
|
|
radial-gradient(circle at top left, rgba(74, 158, 255, 0.1), transparent 34%),
|
|
linear-gradient(180deg, rgba(10, 15, 30, 0.96) 0%, rgba(7, 11, 22, 0.98) 100%);
|
|
}
|
|
|
|
.monatsvorhersage-form {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 16px;
|
|
padding: 22px 20px;
|
|
}
|
|
|
|
.monatsvorhersage-form-header,
|
|
.monatskarte-header,
|
|
.monatskarte-footer {
|
|
padding: 22px 20px;
|
|
}
|
|
|
|
.monatsvorhersage-form-header {
|
|
grid-column: 1 / -1;
|
|
padding: 0;
|
|
}
|
|
|
|
.monatsvorhersage-form-header h3,
|
|
.monatskarte-header h3 {
|
|
margin: 0 0 8px;
|
|
}
|
|
|
|
.monatsvorhersage-form-header p,
|
|
.monatskarte-header p,
|
|
.monatskarte-footer p {
|
|
margin: 0;
|
|
color: rgba(222, 232, 255, 0.86);
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.monatsvorhersage-field {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.monatsvorhersage-field span {
|
|
color: rgba(235, 242, 255, 0.95);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.monatsvorhersage-field select {
|
|
width: 100%;
|
|
min-height: 46px;
|
|
padding: 10px 12px;
|
|
border: 1px solid rgba(108, 145, 220, 0.28);
|
|
border-radius: 12px;
|
|
background: rgba(8, 14, 28, 0.92);
|
|
color: rgba(235, 242, 255, 0.95);
|
|
font: inherit;
|
|
}
|
|
|
|
.monatsvorhersage-actions {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.monatsvorhersage-actions button {
|
|
min-height: 44px;
|
|
padding: 10px 16px;
|
|
border: 1px solid rgba(150, 190, 255, 0.78);
|
|
border-radius: 999px;
|
|
background: rgba(38, 66, 122, 0.92);
|
|
color: rgba(245, 248, 255, 0.96);
|
|
font: inherit;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.monatskarte-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.monatskarte-stage {
|
|
padding: 0 20px 20px;
|
|
}
|
|
|
|
.monatskarte-stage canvas {
|
|
display: block;
|
|
width: min(100%, 900px);
|
|
aspect-ratio: 1;
|
|
margin: 0 auto;
|
|
border-radius: 20px;
|
|
background: #010203;
|
|
box-shadow: inset 0 0 0 1px rgba(140, 165, 220, 0.12);
|
|
}
|
|
|
|
.monatskarte-stage-south canvas {
|
|
width: min(100%, 1100px);
|
|
aspect-ratio: 5 / 3;
|
|
}
|
|
|
|
.monatskarte-footer {
|
|
padding-top: 0;
|
|
}
|
|
|
|
@media (max-width: 820px) {
|
|
.monatsvorhersage-form {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.monatskarte-header {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script src="<?= htmlspecialchars($publicBasePath . 'js/astronomy.browser.min.js', ENT_QUOTES, 'UTF-8') ?>"></script>
|
|
<script>
|
|
const MONTH_CHART_CONFIG = <?= $chartConfigJson ?: 'null' ?>;
|
|
const MONTH_CHART_STARS = <?= $starDataJson ?: '[]' ?>;
|
|
const MONTH_CHART_LINE_STARS = <?= $lineStarDataJson ?: '[]' ?>;
|
|
const MONTH_CHART_CONSTELLATIONS = <?= $constellationLinesJson ?: '[]' ?>;
|
|
const MONTH_CHART_MESSIER = <?= $messierDataJson ?: '[]' ?>;
|
|
const MONTH_CHART_PLANETS = <?= $planetDataJson ?: '[]' ?>;
|
|
const MONTH_CHART_MOON = <?= $moonDataJson ?: 'null' ?>;
|
|
const MONTH_CHART_ERROR = <?= $starDataErrorJson ?: 'null' ?>;
|
|
|
|
(function () {
|
|
const canvas = document.getElementById('monatskarteCanvas');
|
|
const statusEl = document.getElementById('monatskarteStatus');
|
|
|
|
if (!canvas || !statusEl) {
|
|
return;
|
|
}
|
|
|
|
if (!window.Astronomy) {
|
|
statusEl.textContent = 'Astronomy Engine konnte nicht geladen werden.';
|
|
return;
|
|
}
|
|
|
|
if (!MONTH_CHART_CONFIG) {
|
|
statusEl.textContent = 'Die Monatskonfiguration konnte nicht geladen werden.';
|
|
return;
|
|
}
|
|
|
|
if (MONTH_CHART_ERROR) {
|
|
statusEl.textContent = 'Stern-Datenbank konnte nicht geladen werden: ' + MONTH_CHART_ERROR;
|
|
return;
|
|
}
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
const date = new Date(MONTH_CHART_CONFIG.utcTimeIso);
|
|
const observer = new Astronomy.Observer(
|
|
Number(MONTH_CHART_CONFIG.location.latitude),
|
|
Number(MONTH_CHART_CONFIG.location.longitude),
|
|
Number(MONTH_CHART_CONFIG.location.elevation || 0)
|
|
);
|
|
const starByHip = new Map();
|
|
const constellationHipSet = new Set();
|
|
const displayStarMap = new Map();
|
|
let labelPlacer = null;
|
|
let projectionLayout = null;
|
|
const projCenterAzDeg = 180;
|
|
const projCenterAltDeg = 45;
|
|
|
|
for (const star of MONTH_CHART_LINE_STARS) {
|
|
if (Number.isFinite(Number(star.hip))) {
|
|
starByHip.set(Number(star.hip), star);
|
|
}
|
|
}
|
|
|
|
for (const star of MONTH_CHART_STARS) {
|
|
if (Number.isFinite(Number(star.hip))) {
|
|
displayStarMap.set(Number(star.hip), star);
|
|
} else {
|
|
displayStarMap.set(`star-${displayStarMap.size}`, star);
|
|
}
|
|
}
|
|
|
|
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
|
|
if (!Array.isArray(constellation.lines)) {
|
|
continue;
|
|
}
|
|
|
|
for (const segment of constellation.lines) {
|
|
if (!Array.isArray(segment)) {
|
|
continue;
|
|
}
|
|
|
|
for (const hip of segment) {
|
|
if (Number.isFinite(Number(hip))) {
|
|
constellationHipSet.add(Number(hip));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const hip of constellationHipSet) {
|
|
if (!displayStarMap.has(hip) && starByHip.has(hip)) {
|
|
displayStarMap.set(hip, starByHip.get(hip));
|
|
}
|
|
}
|
|
|
|
function clamp01(value) {
|
|
return Math.max(0, Math.min(1, value));
|
|
}
|
|
|
|
function createLabelPlacer(context) {
|
|
const occupiedBoxes = [];
|
|
|
|
function overlaps(box) {
|
|
return occupiedBoxes.some((other) =>
|
|
box.x < other.x + other.width &&
|
|
box.x + box.width > other.x &&
|
|
box.y < other.y + other.height &&
|
|
box.y + box.height > other.y
|
|
);
|
|
}
|
|
|
|
function insideStage(box) {
|
|
return box.x >= 0 &&
|
|
box.y >= 0 &&
|
|
box.x + box.width <= canvas.clientWidth &&
|
|
box.y + box.height <= canvas.clientHeight;
|
|
}
|
|
|
|
return {
|
|
reset() {
|
|
occupiedBoxes.length = 0;
|
|
},
|
|
drawText(options) {
|
|
const {
|
|
text,
|
|
x,
|
|
y,
|
|
font = '12px Georgia, serif',
|
|
color = 'rgba(232, 239, 255, 0.82)',
|
|
align = 'left',
|
|
baseline = 'middle',
|
|
padding = 2,
|
|
} = options;
|
|
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
|
|
context.save();
|
|
context.font = font;
|
|
context.textAlign = align;
|
|
context.textBaseline = baseline;
|
|
const metrics = context.measureText(text);
|
|
const textWidth = metrics.width;
|
|
const ascent = metrics.actualBoundingBoxAscent || 8;
|
|
const descent = metrics.actualBoundingBoxDescent || 4;
|
|
|
|
let boxX = x;
|
|
if (align === 'center') {
|
|
boxX = x - textWidth / 2;
|
|
} else if (align === 'right') {
|
|
boxX = x - textWidth;
|
|
}
|
|
|
|
let boxY = y - ascent;
|
|
if (baseline === 'middle') {
|
|
boxY = y - (ascent + descent) / 2;
|
|
} else if (baseline === 'bottom' || baseline === 'alphabetic') {
|
|
boxY = y - ascent - descent;
|
|
}
|
|
|
|
const box = {
|
|
x: boxX - padding,
|
|
y: boxY - padding,
|
|
width: textWidth + padding * 2,
|
|
height: ascent + descent + padding * 2,
|
|
};
|
|
|
|
if (!insideStage(box) || overlaps(box)) {
|
|
context.restore();
|
|
return false;
|
|
}
|
|
|
|
occupiedBoxes.push(box);
|
|
context.fillStyle = color;
|
|
context.fillText(text, x, y);
|
|
context.restore();
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
|
|
function placePlanetLabel(text, x, y, radius) {
|
|
const attempts = [
|
|
{ x: x + radius + 6, y: y - 1, align: 'left', baseline: 'middle' },
|
|
{ x: x - radius - 6, y: y - 1, align: 'right', baseline: 'middle' },
|
|
{ x, y: y - radius - 7, align: 'center', baseline: 'bottom' },
|
|
{ x, y: y + radius + 7, align: 'center', baseline: 'top' },
|
|
{ x: x + radius + 6, y: y - radius - 5, align: 'left', baseline: 'bottom' },
|
|
{ x: x - radius - 6, y: y - radius - 5, align: 'right', baseline: 'bottom' },
|
|
{ x: x + radius + 6, y: y + radius + 5, align: 'left', baseline: 'top' },
|
|
{ x: x - radius - 6, y: y + radius + 5, align: 'right', baseline: 'top' },
|
|
];
|
|
|
|
function drawLabelBackdrop(drawX, drawY, align, baseline) {
|
|
ctx.save();
|
|
ctx.font = '600 11px Rajdhani, sans-serif';
|
|
const metrics = ctx.measureText(text);
|
|
const textWidth = metrics.width;
|
|
const ascent = metrics.actualBoundingBoxAscent || 8;
|
|
const descent = metrics.actualBoundingBoxDescent || 4;
|
|
const padX = 5;
|
|
const padY = 3;
|
|
|
|
let boxX = drawX;
|
|
if (align === 'center') boxX = drawX - textWidth / 2;
|
|
else if (align === 'right') boxX = drawX - textWidth;
|
|
|
|
let boxY = drawY - ascent;
|
|
if (baseline === 'middle') boxY = drawY - (ascent + descent) / 2;
|
|
else if (baseline === 'bottom' || baseline === 'alphabetic') boxY = drawY - ascent - descent;
|
|
|
|
const rectX = boxX - padX;
|
|
const rectY = boxY - padY;
|
|
const rectW = textWidth + padX * 2;
|
|
const rectH = ascent + descent + padY * 2;
|
|
const radiusPx = 6;
|
|
|
|
ctx.fillStyle = 'rgba(3, 6, 12, 0.52)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(rectX + radiusPx, rectY);
|
|
ctx.lineTo(rectX + rectW - radiusPx, rectY);
|
|
ctx.quadraticCurveTo(rectX + rectW, rectY, rectX + rectW, rectY + radiusPx);
|
|
ctx.lineTo(rectX + rectW, rectY + rectH - radiusPx);
|
|
ctx.quadraticCurveTo(rectX + rectW, rectY + rectH, rectX + rectW - radiusPx, rectY + rectH);
|
|
ctx.lineTo(rectX + radiusPx, rectY + rectH);
|
|
ctx.quadraticCurveTo(rectX, rectY + rectH, rectX, rectY + rectH - radiusPx);
|
|
ctx.lineTo(rectX, rectY + radiusPx);
|
|
ctx.quadraticCurveTo(rectX, rectY, rectX + radiusPx, rectY);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
for (const attempt of attempts) {
|
|
if (labelPlacer.drawText({
|
|
text,
|
|
x: attempt.x,
|
|
y: attempt.y,
|
|
font: '600 11px Rajdhani, sans-serif',
|
|
color: 'rgba(246, 235, 214, 0.84)',
|
|
align: attempt.align,
|
|
baseline: attempt.baseline,
|
|
padding: 2,
|
|
})) {
|
|
drawLabelBackdrop(attempt.x, attempt.y, attempt.align, attempt.baseline);
|
|
ctx.save();
|
|
ctx.font = '600 11px Rajdhani, sans-serif';
|
|
ctx.textAlign = attempt.align;
|
|
ctx.textBaseline = attempt.baseline;
|
|
ctx.fillStyle = 'rgba(246, 235, 214, 0.84)';
|
|
ctx.fillText(text, attempt.x, attempt.y);
|
|
ctx.restore();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function placePlanetLabel(text, x, y, radius) {
|
|
const attempts = [
|
|
{ x: x + radius + 6, y: y - 1, align: 'left', baseline: 'middle' },
|
|
{ x: x - radius - 6, y: y - 1, align: 'right', baseline: 'middle' },
|
|
{ x, y: y - radius - 7, align: 'center', baseline: 'bottom' },
|
|
{ x, y: y + radius + 7, align: 'center', baseline: 'top' },
|
|
{ x: x + radius + 6, y: y - radius - 5, align: 'left', baseline: 'bottom' },
|
|
{ x: x - radius - 6, y: y - radius - 5, align: 'right', baseline: 'bottom' },
|
|
{ x: x + radius + 6, y: y + radius + 5, align: 'left', baseline: 'top' },
|
|
{ x: x - radius - 6, y: y + radius + 5, align: 'right', baseline: 'top' },
|
|
];
|
|
|
|
function drawLabelBackdrop(drawX, drawY, align, baseline) {
|
|
ctx.save();
|
|
ctx.font = '600 11px Rajdhani, sans-serif';
|
|
const metrics = ctx.measureText(text);
|
|
const textWidth = metrics.width;
|
|
const ascent = metrics.actualBoundingBoxAscent || 8;
|
|
const descent = metrics.actualBoundingBoxDescent || 4;
|
|
const padX = 5;
|
|
const padY = 3;
|
|
|
|
let boxX = drawX;
|
|
if (align === 'center') boxX = drawX - textWidth / 2;
|
|
else if (align === 'right') boxX = drawX - textWidth;
|
|
|
|
let boxY = drawY - ascent;
|
|
if (baseline === 'middle') boxY = drawY - (ascent + descent) / 2;
|
|
else if (baseline === 'bottom' || baseline === 'alphabetic') boxY = drawY - ascent - descent;
|
|
|
|
const rectX = boxX - padX;
|
|
const rectY = boxY - padY;
|
|
const rectW = textWidth + padX * 2;
|
|
const rectH = ascent + descent + padY * 2;
|
|
const radiusPx = 6;
|
|
|
|
ctx.fillStyle = 'rgba(3, 6, 12, 0.52)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(rectX + radiusPx, rectY);
|
|
ctx.lineTo(rectX + rectW - radiusPx, rectY);
|
|
ctx.quadraticCurveTo(rectX + rectW, rectY, rectX + rectW, rectY + radiusPx);
|
|
ctx.lineTo(rectX + rectW, rectY + rectH - radiusPx);
|
|
ctx.quadraticCurveTo(rectX + rectW, rectY + rectH, rectX + rectW - radiusPx, rectY + rectH);
|
|
ctx.lineTo(rectX + radiusPx, rectY + rectH);
|
|
ctx.quadraticCurveTo(rectX, rectY + rectH, rectX, rectY + rectH - radiusPx);
|
|
ctx.lineTo(rectX, rectY + radiusPx);
|
|
ctx.quadraticCurveTo(rectX, rectY, rectX + radiusPx, rectY);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
for (const attempt of attempts) {
|
|
if (labelPlacer.drawText({
|
|
text,
|
|
x: attempt.x,
|
|
y: attempt.y,
|
|
font: '600 11px Rajdhani, sans-serif',
|
|
color: 'rgba(246, 235, 214, 0.84)',
|
|
align: attempt.align,
|
|
baseline: attempt.baseline,
|
|
padding: 2,
|
|
})) {
|
|
drawLabelBackdrop(attempt.x, attempt.y, attempt.align, attempt.baseline);
|
|
ctx.save();
|
|
ctx.font = '600 11px Rajdhani, sans-serif';
|
|
ctx.textAlign = attempt.align;
|
|
ctx.textBaseline = attempt.baseline;
|
|
ctx.fillStyle = 'rgba(246, 235, 214, 0.84)';
|
|
ctx.fillText(text, attempt.x, attempt.y);
|
|
ctx.restore();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function resizeCanvas() {
|
|
const cssSize = Math.max(320, Math.min(canvas.parentElement.clientWidth, 900));
|
|
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
|
canvas.width = Math.round(cssSize * ratio);
|
|
canvas.height = Math.round(cssSize * ratio);
|
|
canvas.style.width = cssSize + 'px';
|
|
canvas.style.height = cssSize + 'px';
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
ctx.scale(ratio, ratio);
|
|
labelPlacer = createLabelPlacer(ctx);
|
|
draw();
|
|
}
|
|
|
|
function project(altDeg, azDeg, radius, cx, cy) {
|
|
if (!Number.isFinite(altDeg) || !Number.isFinite(azDeg) || altDeg < 0) {
|
|
return null;
|
|
}
|
|
const rho = (90 - altDeg) / 90;
|
|
const azRad = azDeg * Math.PI / 180;
|
|
return {
|
|
x: cx - rho * radius * Math.sin(azRad),
|
|
y: cy - rho * radius * Math.cos(azRad),
|
|
};
|
|
}
|
|
|
|
function starHorizon(star) {
|
|
const raHours = star.ra * (12 / Math.PI);
|
|
const decDeg = star.dec * (180 / Math.PI);
|
|
return Astronomy.Horizon(date, observer, raHours, decDeg, 'normal');
|
|
}
|
|
|
|
function starColor(star) {
|
|
if (typeof star.ci === 'number') {
|
|
if (star.ci < -0.1) return '#b9d4ff';
|
|
if (star.ci < 0.3) return '#dbe7ff';
|
|
if (star.ci < 0.7) return '#fff6e0';
|
|
if (star.ci < 1.1) return '#ffe0b5';
|
|
return '#ffc98a';
|
|
}
|
|
|
|
const spect = String(star.spect || '').trim().toUpperCase();
|
|
if (spect.startsWith('O') || spect.startsWith('B')) return '#b9d4ff';
|
|
if (spect.startsWith('A')) return '#dbe7ff';
|
|
if (spect.startsWith('F') || spect.startsWith('G')) return '#fff6e0';
|
|
if (spect.startsWith('K')) return '#ffd4a0';
|
|
if (spect.startsWith('M')) return '#ffbd85';
|
|
return '#f4f7ff';
|
|
}
|
|
|
|
function getStarLabel(star) {
|
|
const proper = String(star.proper || '').trim();
|
|
if (proper !== '') {
|
|
return proper;
|
|
}
|
|
|
|
const bayer = String(star.bayer || '').trim();
|
|
const con = String(star.con || '').trim();
|
|
if (bayer !== '') {
|
|
return con !== '' ? `${bayer} ${con}` : bayer;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function drawSkyBackground(cx, cy, radius) {
|
|
const baseGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
|
baseGrad.addColorStop(0, '#010203');
|
|
baseGrad.addColorStop(0.45, '#02050b');
|
|
baseGrad.addColorStop(0.78, '#06101d');
|
|
baseGrad.addColorStop(1, '#0d1f38');
|
|
ctx.fillStyle = baseGrad;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
const zenithDarkening = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius * 0.92);
|
|
zenithDarkening.addColorStop(0, 'rgba(0, 0, 0, 0.44)');
|
|
zenithDarkening.addColorStop(0.42, 'rgba(0, 0, 0, 0.2)');
|
|
zenithDarkening.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
|
ctx.fillStyle = zenithDarkening;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, radius * 0.92, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
const horizonGlow = ctx.createRadialGradient(cx, cy, radius * 0.74, cx, cy, radius * 1.04);
|
|
horizonGlow.addColorStop(0, 'rgba(0, 0, 0, 0)');
|
|
horizonGlow.addColorStop(0.72, 'rgba(78, 126, 214, 0.025)');
|
|
horizonGlow.addColorStop(0.9, 'rgba(118, 170, 255, 0.09)');
|
|
horizonGlow.addColorStop(1, 'rgba(168, 208, 255, 0.14)');
|
|
ctx.strokeStyle = horizonGlow;
|
|
ctx.lineWidth = Math.max(8, radius * 0.035);
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, radius * 0.97, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = 'rgba(183, 205, 255, 0.28)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
function drawGuides(cx, cy, radius) {
|
|
ctx.strokeStyle = 'rgba(140, 165, 220, 0.18)';
|
|
ctx.lineWidth = 1;
|
|
|
|
for (const frac of [0.33, 0.66]) {
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, radius * frac, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, cy - radius);
|
|
ctx.lineTo(cx, cy + radius);
|
|
ctx.moveTo(cx - radius, cy);
|
|
ctx.lineTo(cx + radius, cy);
|
|
ctx.stroke();
|
|
|
|
function drawDirectionLabel(angleDeg, label, offset, font, alpha) {
|
|
const angleRad = angleDeg * Math.PI / 180;
|
|
const x = cx + Math.sin(angleRad) * (radius + offset);
|
|
const y = cy - Math.cos(angleRad) * (radius + offset);
|
|
let rotation = angleRad;
|
|
|
|
// Text tangential zum Horizontkreis ausrichten, dabei moeglichst gut lesbar halten.
|
|
if (rotation > Math.PI / 2 && rotation < Math.PI * 1.5) {
|
|
rotation -= Math.PI;
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(rotation);
|
|
ctx.font = font;
|
|
ctx.fillStyle = `rgba(214, 226, 255, ${alpha})`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(label, 0, 0);
|
|
ctx.restore();
|
|
}
|
|
|
|
drawDirectionLabel(0, 'Norden', 18, '600 13px Rajdhani, sans-serif', 0.78);
|
|
drawDirectionLabel(90, 'Westen', 22, '600 13px Rajdhani, sans-serif', 0.78);
|
|
drawDirectionLabel(180, 'Süden', 18, '600 13px Rajdhani, sans-serif', 0.78);
|
|
drawDirectionLabel(270, 'Osten', 22, '600 13px Rajdhani, sans-serif', 0.78);
|
|
|
|
drawDirectionLabel(45, 'Nordosten', 18, '600 11px Rajdhani, sans-serif', 0.64);
|
|
drawDirectionLabel(315, 'Nordwesten', 18, '600 11px Rajdhani, sans-serif', 0.64);
|
|
drawDirectionLabel(135, 'Südosten', 18, '600 11px Rajdhani, sans-serif', 0.64);
|
|
drawDirectionLabel(225, 'Südwesten', 18, '600 11px Rajdhani, sans-serif', 0.64);
|
|
|
|
ctx.fillStyle = 'rgba(214, 226, 255, 0.62)';
|
|
ctx.font = '600 11px Rajdhani, sans-serif';
|
|
ctx.fillText('60°', cx, cy - radius * 0.66 - 6);
|
|
ctx.fillText('30°', cx, cy - radius * 0.33 - 6);
|
|
ctx.fillText('Zenit', cx, cy);
|
|
}
|
|
|
|
function drawConstellations(cx, cy, radius) {
|
|
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
|
|
if (!Array.isArray(constellation.lines)) {
|
|
continue;
|
|
}
|
|
|
|
for (const segment of constellation.lines) {
|
|
if (!Array.isArray(segment) || segment.length < 2) {
|
|
continue;
|
|
}
|
|
|
|
const points = [];
|
|
|
|
for (const hip of segment) {
|
|
const star = starByHip.get(Number(hip));
|
|
if (!star) {
|
|
continue;
|
|
}
|
|
|
|
const hor = starHorizon(star);
|
|
if (!hor || hor.altitude < 0) {
|
|
continue;
|
|
}
|
|
|
|
const pt = project(hor.altitude, hor.azimuth, radius, cx, cy);
|
|
if (!pt) {
|
|
continue;
|
|
}
|
|
points.push(pt);
|
|
}
|
|
|
|
if (points.length < 2) {
|
|
continue;
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
|
|
ctx.strokeStyle = 'rgba(210, 214, 224, 0.16)';
|
|
ctx.lineWidth = 2.1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(points[0].x, points[0].y);
|
|
for (let i = 1; i < points.length; i++) {
|
|
ctx.lineTo(points[i].x, points[i].y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = 'rgba(36, 40, 48, 0.34)';
|
|
ctx.lineWidth = 0.95;
|
|
ctx.beginPath();
|
|
ctx.moveTo(points[0].x + 0.18, points[0].y - 0.12);
|
|
for (let i = 1; i < points.length; i++) {
|
|
const jitterX = (i % 2 === 0 ? 0.22 : -0.12);
|
|
const jitterY = (i % 2 === 0 ? -0.14 : 0.2);
|
|
ctx.lineTo(points[i].x + jitterX, points[i].y + jitterY);
|
|
}
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawConstellationLabels(cx, cy, radius) {
|
|
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
|
|
const label = constellation.common_name?.german || constellation.common_name?.native || constellation.id;
|
|
if (!label) {
|
|
continue;
|
|
}
|
|
|
|
const labelPositions = Array.isArray(constellation.label_positions)
|
|
? constellation.label_positions
|
|
.filter((pos) => Array.isArray(pos) && pos.length >= 2)
|
|
.map((pos) => [Number(pos[0]), Number(pos[1])])
|
|
.filter(([raH, decD]) => Number.isFinite(raH) && Number.isFinite(decD))
|
|
: [];
|
|
|
|
for (const [raHours, decDeg] of labelPositions) {
|
|
const hor = equatorialHorizon(raHours, decDeg);
|
|
if (!hor || hor.altitude < 8) {
|
|
continue;
|
|
}
|
|
|
|
const pt = project(hor.altitude, hor.azimuth, radius, cx, cy);
|
|
if (!pt) {
|
|
continue;
|
|
}
|
|
|
|
const placed = labelPlacer.drawText({
|
|
text: label,
|
|
x: pt.x,
|
|
y: pt.y,
|
|
font: '600 11px Rajdhani, sans-serif',
|
|
color: 'rgba(198, 214, 255, 0.54)',
|
|
align: 'center',
|
|
baseline: 'middle',
|
|
padding: 3,
|
|
});
|
|
|
|
if (placed) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function equatorialHorizon(raHours, decDeg) {
|
|
return Astronomy.Horizon(date, observer, raHours, decDeg, 'normal');
|
|
}
|
|
|
|
function getMessierStyle(type) {
|
|
switch (type) {
|
|
case 'Gc':
|
|
return { stroke: 'rgba(255, 180, 96, 0.92)', fill: 'rgba(255, 180, 96, 0.12)' };
|
|
case 'Oc':
|
|
return { stroke: 'rgba(255, 224, 150, 0.84)', fill: 'rgba(255, 224, 150, 0.08)' };
|
|
case 'Gx':
|
|
return { stroke: 'rgba(255, 140, 188, 0.88)', fill: 'rgba(255, 140, 188, 0.08)' };
|
|
case 'Pn':
|
|
return { stroke: 'rgba(156, 255, 186, 0.88)', fill: 'rgba(156, 255, 186, 0.08)' };
|
|
case 'Nb':
|
|
case 'Sc':
|
|
return { stroke: 'rgba(118, 204, 255, 0.88)', fill: 'rgba(118, 204, 255, 0.08)' };
|
|
default:
|
|
return { stroke: 'rgba(210, 218, 232, 0.74)', fill: 'rgba(210, 218, 232, 0.06)' };
|
|
}
|
|
}
|
|
|
|
function getMessierLabel(obj) {
|
|
const messierId = Number(obj.m);
|
|
if (!Number.isFinite(messierId)) {
|
|
return '';
|
|
}
|
|
|
|
const name = String(obj.name || '').trim();
|
|
return name !== '' ? `M${messierId} ${name}` : `M${messierId}`;
|
|
}
|
|
|
|
function getPlanetStyle(planet) {
|
|
switch (String(planet.key || '')) {
|
|
case 'mercury':
|
|
return { fill: '#c9b9a7', glow: 'rgba(201, 185, 167, 0.34)' };
|
|
case 'venus':
|
|
return { fill: '#f4ddb2', glow: 'rgba(244, 221, 178, 0.4)' };
|
|
case 'mars':
|
|
return { fill: '#d7724c', glow: 'rgba(215, 114, 76, 0.34)' };
|
|
case 'jupiter':
|
|
return { fill: '#d9b07e', glow: 'rgba(217, 176, 126, 0.36)' };
|
|
case 'saturn':
|
|
return { fill: '#d8ca8b', glow: 'rgba(216, 202, 139, 0.34)' };
|
|
case 'uranus':
|
|
return { fill: '#8fd3df', glow: 'rgba(143, 211, 223, 0.3)' };
|
|
case 'neptune':
|
|
return { fill: '#5a78d6', glow: 'rgba(90, 120, 214, 0.3)' };
|
|
default:
|
|
return { fill: '#d8dde8', glow: 'rgba(216, 221, 232, 0.28)' };
|
|
}
|
|
}
|
|
|
|
function drawMoon(cx, cy, radius) {
|
|
if (!MONTH_CHART_MOON) {
|
|
return;
|
|
}
|
|
|
|
const alt = Number(MONTH_CHART_MOON.alt);
|
|
const az = Number(MONTH_CHART_MOON.az);
|
|
if (!Number.isFinite(alt) || !Number.isFinite(az) || alt < 0) {
|
|
return;
|
|
}
|
|
|
|
const pt = project(alt, az, radius, cx, cy);
|
|
if (!pt) {
|
|
return;
|
|
}
|
|
|
|
const phaseFraction = clamp01(Number(MONTH_CHART_MOON.phaseFraction ?? 0.5));
|
|
const waxing = Boolean(MONTH_CHART_MOON.waxing);
|
|
const moonRadius = 10.5;
|
|
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, moonRadius * 2.1);
|
|
glow.addColorStop(0, 'rgba(220, 226, 238, 0.28)');
|
|
glow.addColorStop(0.5, 'rgba(220, 226, 238, 0.08)');
|
|
glow.addColorStop(1, 'rgba(220, 226, 238, 0)');
|
|
ctx.fillStyle = glow;
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, moonRadius * 2.1, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.save();
|
|
ctx.translate(pt.x, pt.y);
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
|
|
ctx.clip();
|
|
|
|
ctx.fillStyle = '#515760';
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = '#d6d9df';
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
const phaseScale = 1 - 2 * phaseFraction;
|
|
ctx.fillStyle = '#515760';
|
|
ctx.beginPath();
|
|
ctx.ellipse((waxing ? -1 : 1) * moonRadius * phaseScale, 0, moonRadius, moonRadius, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, moonRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
function drawPlanets(cx, cy, radius) {
|
|
for (const planet of MONTH_CHART_PLANETS) {
|
|
const alt = Number(planet.alt);
|
|
const az = Number(planet.az);
|
|
if (!Number.isFinite(alt) || !Number.isFinite(az) || alt < 0) {
|
|
continue;
|
|
}
|
|
|
|
const pt = project(alt, az, radius, cx, cy);
|
|
if (!pt) {
|
|
continue;
|
|
}
|
|
|
|
const style = getPlanetStyle(planet);
|
|
const mag = Number(planet.mag);
|
|
const baseRadius = Number.isFinite(mag)
|
|
? Math.max(3.8, Math.min(8.6, 6.7 - mag * 0.72))
|
|
: 5.4;
|
|
const glowRadius = baseRadius * 2.2;
|
|
|
|
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, glowRadius);
|
|
glow.addColorStop(0, style.glow);
|
|
glow.addColorStop(0.45, style.glow.replace(/0\.\d+\)$/, '0.12)'));
|
|
glow.addColorStop(1, style.glow.replace(/0\.\d+\)$/, '0)'));
|
|
ctx.fillStyle = glow;
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, glowRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = style.fill;
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, baseRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.26)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, baseRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
if (planet.key === 'jupiter') {
|
|
ctx.save();
|
|
ctx.translate(pt.x, pt.y);
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, baseRadius - 0.3, 0, Math.PI * 2);
|
|
ctx.clip();
|
|
|
|
ctx.strokeStyle = 'rgba(116, 84, 54, 0.78)';
|
|
ctx.lineWidth = Math.max(1, baseRadius * 0.34);
|
|
ctx.beginPath();
|
|
ctx.moveTo(-baseRadius * 0.95, -baseRadius * 0.34);
|
|
ctx.lineTo(baseRadius * 0.95, -baseRadius * 0.34);
|
|
ctx.moveTo(-baseRadius * 0.95, baseRadius * 0.2);
|
|
ctx.lineTo(baseRadius * 0.95, baseRadius * 0.2);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = 'rgba(194, 92, 68, 0.95)';
|
|
ctx.beginPath();
|
|
ctx.ellipse(baseRadius * 0.42, baseRadius * 0.06, baseRadius * 0.3, baseRadius * 0.17, -0.18, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
if (planet.key === 'saturn') {
|
|
ctx.save();
|
|
ctx.translate(pt.x, pt.y);
|
|
ctx.rotate(-0.35);
|
|
ctx.strokeStyle = 'rgba(232, 221, 170, 0.72)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 0, baseRadius * 1.7, baseRadius * 0.72, 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
const label = String(planet.label || '').trim();
|
|
if (label !== '') {
|
|
placePlanetLabel(label, pt.x, pt.y, baseRadius);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawMessier(cx, cy, radius) {
|
|
for (const obj of MONTH_CHART_MESSIER) {
|
|
if (Number(obj.mag) > Number(MONTH_CHART_CONFIG.messierLimit)) {
|
|
continue;
|
|
}
|
|
|
|
const hor = equatorialHorizon(Number(obj.ra), Number(obj.dec));
|
|
if (!hor || hor.altitude < 0) {
|
|
continue;
|
|
}
|
|
|
|
const pt = project(hor.altitude, hor.azimuth, radius, cx, cy);
|
|
if (!pt) {
|
|
continue;
|
|
}
|
|
|
|
const style = getMessierStyle(String(obj.type || 'As'));
|
|
const symbolRadius = Math.max(3.4, Math.min(6.8, 3.2 + Math.max(0, 6.5 - Number(obj.mag)) * 0.5));
|
|
ctx.strokeStyle = style.stroke;
|
|
ctx.fillStyle = style.fill;
|
|
ctx.lineWidth = 1;
|
|
|
|
if (obj.type === 'Gc') {
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, symbolRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(pt.x - symbolRadius * 0.8, pt.y);
|
|
ctx.lineTo(pt.x + symbolRadius * 0.8, pt.y);
|
|
ctx.moveTo(pt.x, pt.y - symbolRadius * 0.8);
|
|
ctx.lineTo(pt.x, pt.y + symbolRadius * 0.8);
|
|
ctx.stroke();
|
|
} else if (obj.type === 'Oc') {
|
|
ctx.setLineDash([2, 2]);
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, symbolRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
} else if (obj.type === 'Gx') {
|
|
ctx.save();
|
|
ctx.translate(pt.x, pt.y);
|
|
ctx.rotate(Math.PI / 5);
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 0, symbolRadius, symbolRadius * 0.58, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
} else if (obj.type === 'Nb' || obj.type === 'Sc') {
|
|
ctx.beginPath();
|
|
ctx.rect(pt.x - symbolRadius, pt.y - symbolRadius * 0.62, symbolRadius * 2, symbolRadius * 1.24);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
} else if (obj.type === 'Pn') {
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, symbolRadius * 0.62, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
} else {
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, symbolRadius * 0.68, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
|
|
const label = getMessierLabel(obj);
|
|
if (label !== '') {
|
|
labelPlacer.drawText({
|
|
text: label,
|
|
x: pt.x + symbolRadius + 5,
|
|
y: pt.y,
|
|
font: '600 11px Rajdhani, sans-serif',
|
|
color: 'rgba(210, 224, 255, 0.78)',
|
|
align: 'left',
|
|
baseline: 'middle',
|
|
padding: 2,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawStars(cx, cy, radius) {
|
|
for (const star of displayStarMap.values()) {
|
|
const hip = Number(star.hip);
|
|
const isConstellationStar = Number.isFinite(hip) && constellationHipSet.has(hip);
|
|
|
|
if (!isConstellationStar && Number(star.mag) > Number(MONTH_CHART_CONFIG.starLimit)) {
|
|
continue;
|
|
}
|
|
|
|
const hor = starHorizon(star);
|
|
if (!hor || hor.altitude < 0) {
|
|
continue;
|
|
}
|
|
|
|
const pt = project(hor.altitude, hor.azimuth, radius, cx, cy);
|
|
if (!pt) {
|
|
continue;
|
|
}
|
|
|
|
const magRange = Math.max(0.5, Number(MONTH_CHART_CONFIG.starLimit) + 1.6);
|
|
const effectiveMagnitude = isConstellationStar
|
|
? Math.min(Number(star.mag), Number(MONTH_CHART_CONFIG.starLimit) + 0.35)
|
|
: Number(star.mag);
|
|
const brightness = clamp01((Number(MONTH_CHART_CONFIG.starLimit) - effectiveMagnitude + 1.6) / magRange);
|
|
const starRadius = (isConstellationStar ? 0.85 : 0.65) + Math.pow(brightness, 1.9) * 5.2;
|
|
const glowRadius = starRadius * (effectiveMagnitude <= 1 ? 2.8 : 1.9);
|
|
const color = starColor(star);
|
|
|
|
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, glowRadius);
|
|
glow.addColorStop(0, color + '66');
|
|
glow.addColorStop(0.35, color + '22');
|
|
glow.addColorStop(1, color + '00');
|
|
ctx.fillStyle = glow;
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, glowRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, starRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
if (star.mag <= 0.8) {
|
|
ctx.strokeStyle = color + '44';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pt.x - starRadius * 1.35, pt.y);
|
|
ctx.lineTo(pt.x + starRadius * 1.35, pt.y);
|
|
ctx.moveTo(pt.x, pt.y - starRadius * 1.35);
|
|
ctx.lineTo(pt.x, pt.y + starRadius * 1.35);
|
|
ctx.stroke();
|
|
}
|
|
|
|
if (Number(star.mag) <= Number(MONTH_CHART_CONFIG.starLabelLimit)) {
|
|
const label = getStarLabel(star);
|
|
if (label !== '') {
|
|
labelPlacer.drawText({
|
|
text: label,
|
|
x: pt.x + starRadius + 5,
|
|
y: pt.y - 1,
|
|
font: '600 11px Rajdhani, sans-serif',
|
|
color: 'rgba(232, 239, 255, 0.82)',
|
|
align: 'left',
|
|
baseline: 'middle',
|
|
padding: 2,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function draw() {
|
|
const width = canvas.clientWidth;
|
|
const height = canvas.clientHeight;
|
|
const cx = width / 2;
|
|
const cy = height / 2;
|
|
const radius = Math.min(width, height) * 0.44;
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
labelPlacer.reset();
|
|
drawSkyBackground(cx, cy, radius);
|
|
drawGuides(cx, cy, radius);
|
|
drawConstellations(cx, cy, radius);
|
|
drawConstellationLabels(cx, cy, radius);
|
|
drawMessier(cx, cy, radius);
|
|
drawMoon(cx, cy, radius);
|
|
drawPlanets(cx, cy, radius);
|
|
drawStars(cx, cy, radius);
|
|
|
|
statusEl.textContent = 'Sternkarte fuer ' + MONTH_CHART_CONFIG.localTimeLabel;
|
|
}
|
|
|
|
resizeCanvas();
|
|
window.addEventListener('resize', resizeCanvas);
|
|
})();
|
|
|
|
(function () {
|
|
const canvas = document.getElementById('monatskarteSouthCanvas');
|
|
const statusEl = document.getElementById('monatskarteSouthStatus');
|
|
|
|
if (!canvas || !statusEl || !window.Astronomy || !MONTH_CHART_CONFIG || MONTH_CHART_ERROR) {
|
|
return;
|
|
}
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
const date = new Date(MONTH_CHART_CONFIG.utcTimeIso);
|
|
const observer = new Astronomy.Observer(
|
|
Number(MONTH_CHART_CONFIG.location.latitude),
|
|
Number(MONTH_CHART_CONFIG.location.longitude),
|
|
Number(MONTH_CHART_CONFIG.location.elevation || 0)
|
|
);
|
|
const starByHip = new Map();
|
|
const constellationHipSet = new Set();
|
|
const displayStarMap = new Map();
|
|
let labelPlacer = null;
|
|
let projectionLayout = null;
|
|
const projCenterAzDeg = 180;
|
|
const projCenterAltDeg = 45;
|
|
|
|
for (const star of MONTH_CHART_LINE_STARS) {
|
|
if (Number.isFinite(Number(star.hip))) {
|
|
starByHip.set(Number(star.hip), star);
|
|
}
|
|
}
|
|
|
|
for (const star of MONTH_CHART_STARS) {
|
|
if (Number.isFinite(Number(star.hip))) {
|
|
displayStarMap.set(Number(star.hip), star);
|
|
} else {
|
|
displayStarMap.set(`south-star-${displayStarMap.size}`, star);
|
|
}
|
|
}
|
|
|
|
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
|
|
if (!Array.isArray(constellation.lines)) {
|
|
continue;
|
|
}
|
|
for (const segment of constellation.lines) {
|
|
if (!Array.isArray(segment)) {
|
|
continue;
|
|
}
|
|
for (const hip of segment) {
|
|
if (Number.isFinite(Number(hip))) {
|
|
constellationHipSet.add(Number(hip));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const hip of constellationHipSet) {
|
|
if (!displayStarMap.has(hip) && starByHip.has(hip)) {
|
|
displayStarMap.set(hip, starByHip.get(hip));
|
|
}
|
|
}
|
|
|
|
function clamp01(value) {
|
|
return Math.max(0, Math.min(1, value));
|
|
}
|
|
|
|
function createLabelPlacer(context) {
|
|
const occupiedBoxes = [];
|
|
|
|
function overlaps(box) {
|
|
return occupiedBoxes.some((other) =>
|
|
box.x < other.x + other.width &&
|
|
box.x + box.width > other.x &&
|
|
box.y < other.y + other.height &&
|
|
box.y + box.height > other.y
|
|
);
|
|
}
|
|
|
|
function insideStage(box) {
|
|
return box.x >= 0 &&
|
|
box.y >= 0 &&
|
|
box.x + box.width <= canvas.clientWidth &&
|
|
box.y + box.height <= canvas.clientHeight;
|
|
}
|
|
|
|
return {
|
|
reset() {
|
|
occupiedBoxes.length = 0;
|
|
},
|
|
drawText(options) {
|
|
const {
|
|
text,
|
|
x,
|
|
y,
|
|
font = '600 11px Rajdhani, sans-serif',
|
|
color = 'rgba(232, 239, 255, 0.82)',
|
|
align = 'left',
|
|
baseline = 'middle',
|
|
padding = 2,
|
|
} = options;
|
|
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
|
|
context.save();
|
|
context.font = font;
|
|
context.textAlign = align;
|
|
context.textBaseline = baseline;
|
|
const metrics = context.measureText(text);
|
|
const textWidth = metrics.width;
|
|
const ascent = metrics.actualBoundingBoxAscent || 8;
|
|
const descent = metrics.actualBoundingBoxDescent || 4;
|
|
|
|
let boxX = x;
|
|
if (align === 'center') boxX = x - textWidth / 2;
|
|
else if (align === 'right') boxX = x - textWidth;
|
|
|
|
let boxY = y - ascent;
|
|
if (baseline === 'middle') boxY = y - (ascent + descent) / 2;
|
|
else if (baseline === 'bottom' || baseline === 'alphabetic') boxY = y - ascent - descent;
|
|
|
|
const box = {
|
|
x: boxX - padding,
|
|
y: boxY - padding,
|
|
width: textWidth + padding * 2,
|
|
height: ascent + descent + padding * 2,
|
|
};
|
|
|
|
if (!insideStage(box) || overlaps(box)) {
|
|
context.restore();
|
|
return false;
|
|
}
|
|
|
|
occupiedBoxes.push(box);
|
|
context.fillStyle = color;
|
|
context.fillText(text, x, y);
|
|
context.restore();
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
|
|
function placePlanetLabel(text, x, y, radius) {
|
|
const attempts = [
|
|
{ x: x + radius + 6, y: y - 1, align: 'left', baseline: 'middle' },
|
|
{ x: x - radius - 6, y: y - 1, align: 'right', baseline: 'middle' },
|
|
{ x, y: y - radius - 7, align: 'center', baseline: 'bottom' },
|
|
{ x, y: y + radius + 7, align: 'center', baseline: 'top' },
|
|
{ x: x + radius + 6, y: y - radius - 5, align: 'left', baseline: 'bottom' },
|
|
{ x: x - radius - 6, y: y - radius - 5, align: 'right', baseline: 'bottom' },
|
|
{ x: x + radius + 6, y: y + radius + 5, align: 'left', baseline: 'top' },
|
|
{ x: x - radius - 6, y: y + radius + 5, align: 'right', baseline: 'top' },
|
|
];
|
|
|
|
for (const attempt of attempts) {
|
|
if (labelPlacer.drawText({
|
|
text,
|
|
x: attempt.x,
|
|
y: attempt.y,
|
|
font: '600 11px Rajdhani, sans-serif',
|
|
color: 'rgba(246, 235, 214, 0.84)',
|
|
align: attempt.align,
|
|
baseline: attempt.baseline,
|
|
padding: 2,
|
|
})) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function resizeCanvas() {
|
|
const cssWidth = Math.max(420, Math.min(canvas.parentElement.clientWidth, 1100));
|
|
const cssHeight = Math.round(cssWidth * 0.6);
|
|
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
|
canvas.width = Math.round(cssWidth * ratio);
|
|
canvas.height = Math.round(cssHeight * ratio);
|
|
canvas.style.width = cssWidth + 'px';
|
|
canvas.style.height = cssHeight + 'px';
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
ctx.scale(ratio, ratio);
|
|
labelPlacer = createLabelPlacer(ctx);
|
|
draw();
|
|
}
|
|
|
|
function vectorFromAltAz(altDeg, azDeg) {
|
|
const altRad = altDeg * Math.PI / 180;
|
|
const azRad = azDeg * Math.PI / 180;
|
|
const cosAlt = Math.cos(altRad);
|
|
return {
|
|
x: cosAlt * Math.sin(azRad),
|
|
y: cosAlt * Math.cos(azRad),
|
|
z: Math.sin(altRad),
|
|
};
|
|
}
|
|
|
|
function dot(a, b) {
|
|
return a.x * b.x + a.y * b.y + a.z * b.z;
|
|
}
|
|
|
|
function cross(a, b) {
|
|
return {
|
|
x: a.y * b.z - a.z * b.y,
|
|
y: a.z * b.x - a.x * b.z,
|
|
z: a.x * b.y - a.y * b.x,
|
|
};
|
|
}
|
|
|
|
function normalize(v) {
|
|
const len = Math.hypot(v.x, v.y, v.z) || 1;
|
|
return { x: v.x / len, y: v.y / len, z: v.z / len };
|
|
}
|
|
|
|
const projectionCenter = vectorFromAltAz(projCenterAltDeg, projCenterAzDeg);
|
|
const basisX = normalize(cross({ x: 0, y: 0, z: 1 }, projectionCenter));
|
|
const basisY = normalize(cross(projectionCenter, basisX));
|
|
|
|
function rawSouthProjection(altDeg, azDeg) {
|
|
if (!Number.isFinite(altDeg) || !Number.isFinite(azDeg) || altDeg < 0) {
|
|
return null;
|
|
}
|
|
|
|
const p = vectorFromAltAz(altDeg, azDeg);
|
|
const denom = 1 + dot(projectionCenter, p);
|
|
if (denom <= 1e-6) {
|
|
return null;
|
|
}
|
|
|
|
const k = 2 / denom;
|
|
return {
|
|
u: k * dot(p, basisX),
|
|
v: k * dot(p, basisY),
|
|
};
|
|
}
|
|
|
|
function computeProjectionLayout(width, height) {
|
|
let minU = Infinity;
|
|
let maxU = -Infinity;
|
|
let minV = Infinity;
|
|
let maxV = -Infinity;
|
|
|
|
for (let az = 90; az <= 270; az += 2) {
|
|
for (let alt = 0; alt <= 90; alt += 2) {
|
|
const raw = rawSouthProjection(alt, az);
|
|
if (!raw) {
|
|
continue;
|
|
}
|
|
minU = Math.min(minU, raw.u);
|
|
maxU = Math.max(maxU, raw.u);
|
|
minV = Math.min(minV, raw.v);
|
|
maxV = Math.max(maxV, raw.v);
|
|
}
|
|
}
|
|
|
|
const marginX = width * 0.08;
|
|
const marginY = height * 0.08;
|
|
const usableWidth = width - marginX * 2;
|
|
const usableHeight = height - marginY * 2;
|
|
const scale = Math.min(
|
|
usableWidth / Math.max(0.001, maxU - minU),
|
|
usableHeight / Math.max(0.001, maxV - minV)
|
|
);
|
|
|
|
return {
|
|
scale,
|
|
offsetX: width / 2 - ((minU + maxU) * 0.5) * scale,
|
|
offsetY: height / 2 + ((minV + maxV) * 0.5) * scale,
|
|
};
|
|
}
|
|
|
|
function southProject(altDeg, azDeg) {
|
|
const raw = rawSouthProjection(altDeg, azDeg);
|
|
if (!raw || !projectionLayout) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
x: projectionLayout.offsetX - raw.u * projectionLayout.scale,
|
|
y: projectionLayout.offsetY - raw.v * projectionLayout.scale,
|
|
};
|
|
}
|
|
|
|
function starHorizon(star) {
|
|
const raHours = star.ra * (12 / Math.PI);
|
|
const decDeg = star.dec * (180 / Math.PI);
|
|
return Astronomy.Horizon(date, observer, raHours, decDeg, 'normal');
|
|
}
|
|
|
|
function equatorialHorizon(raHours, decDeg) {
|
|
return Astronomy.Horizon(date, observer, raHours, decDeg, 'normal');
|
|
}
|
|
|
|
function starColor(star) {
|
|
if (typeof star.ci === 'number') {
|
|
if (star.ci < -0.1) return '#b9d4ff';
|
|
if (star.ci < 0.3) return '#dbe7ff';
|
|
if (star.ci < 0.7) return '#fff6e0';
|
|
if (star.ci < 1.1) return '#ffe0b5';
|
|
return '#ffc98a';
|
|
}
|
|
const spect = String(star.spect || '').trim().toUpperCase();
|
|
if (spect.startsWith('O') || spect.startsWith('B')) return '#b9d4ff';
|
|
if (spect.startsWith('A')) return '#dbe7ff';
|
|
if (spect.startsWith('F') || spect.startsWith('G')) return '#fff6e0';
|
|
if (spect.startsWith('K')) return '#ffd4a0';
|
|
if (spect.startsWith('M')) return '#ffbd85';
|
|
return '#f4f7ff';
|
|
}
|
|
|
|
function getStarLabel(star) {
|
|
const proper = String(star.proper || '').trim();
|
|
if (proper !== '') return proper;
|
|
const bayer = String(star.bayer || '').trim();
|
|
const con = String(star.con || '').trim();
|
|
if (bayer !== '') return con !== '' ? `${bayer} ${con}` : bayer;
|
|
return '';
|
|
}
|
|
|
|
function getMessierStyle(type) {
|
|
switch (type) {
|
|
case 'Gc': return { stroke: 'rgba(255, 180, 96, 0.92)', fill: 'rgba(255, 180, 96, 0.12)' };
|
|
case 'Oc': return { stroke: 'rgba(255, 224, 150, 0.84)', fill: 'rgba(255, 224, 150, 0.08)' };
|
|
case 'Gx': return { stroke: 'rgba(255, 140, 188, 0.88)', fill: 'rgba(255, 140, 188, 0.08)' };
|
|
case 'Pn': return { stroke: 'rgba(156, 255, 186, 0.88)', fill: 'rgba(156, 255, 186, 0.08)' };
|
|
case 'Nb':
|
|
case 'Sc': return { stroke: 'rgba(118, 204, 255, 0.88)', fill: 'rgba(118, 204, 255, 0.08)' };
|
|
default: return { stroke: 'rgba(210, 218, 232, 0.74)', fill: 'rgba(210, 218, 232, 0.06)' };
|
|
}
|
|
}
|
|
|
|
function getMessierLabel(obj) {
|
|
const messierId = Number(obj.m);
|
|
if (!Number.isFinite(messierId)) return '';
|
|
const name = String(obj.name || '').trim();
|
|
return name !== '' ? `M${messierId} ${name}` : `M${messierId}`;
|
|
}
|
|
|
|
function getPlanetStyle(planet) {
|
|
switch (String(planet.key || '')) {
|
|
case 'mercury':
|
|
return { fill: '#c9b9a7', glow: 'rgba(201, 185, 167, 0.34)' };
|
|
case 'venus':
|
|
return { fill: '#f4ddb2', glow: 'rgba(244, 221, 178, 0.4)' };
|
|
case 'mars':
|
|
return { fill: '#d7724c', glow: 'rgba(215, 114, 76, 0.34)' };
|
|
case 'jupiter':
|
|
return { fill: '#d9b07e', glow: 'rgba(217, 176, 126, 0.36)' };
|
|
case 'saturn':
|
|
return { fill: '#d8ca8b', glow: 'rgba(216, 202, 139, 0.34)' };
|
|
case 'uranus':
|
|
return { fill: '#8fd3df', glow: 'rgba(143, 211, 223, 0.3)' };
|
|
case 'neptune':
|
|
return { fill: '#5a78d6', glow: 'rgba(90, 120, 214, 0.3)' };
|
|
default:
|
|
return { fill: '#d8dde8', glow: 'rgba(216, 221, 232, 0.28)' };
|
|
}
|
|
}
|
|
|
|
function drawMoon(width, height) {
|
|
if (!MONTH_CHART_MOON) return;
|
|
|
|
const alt = Number(MONTH_CHART_MOON.alt);
|
|
const az = Number(MONTH_CHART_MOON.az);
|
|
if (!Number.isFinite(alt) || !Number.isFinite(az) || alt < 0) return;
|
|
|
|
const pt = southProject(alt, az);
|
|
if (!pt) return;
|
|
|
|
const phaseFraction = clamp01(Number(MONTH_CHART_MOON.phaseFraction ?? 0.5));
|
|
const waxing = Boolean(MONTH_CHART_MOON.waxing);
|
|
const moonRadius = 10.5;
|
|
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, moonRadius * 2.1);
|
|
glow.addColorStop(0, 'rgba(220, 226, 238, 0.28)');
|
|
glow.addColorStop(0.5, 'rgba(220, 226, 238, 0.08)');
|
|
glow.addColorStop(1, 'rgba(220, 226, 238, 0)');
|
|
ctx.fillStyle = glow;
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, moonRadius * 2.1, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.save();
|
|
ctx.translate(pt.x, pt.y);
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
|
|
ctx.clip();
|
|
|
|
ctx.fillStyle = '#515760';
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = '#d6d9df';
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
const phaseScale = 1 - 2 * phaseFraction;
|
|
ctx.fillStyle = '#515760';
|
|
ctx.beginPath();
|
|
ctx.ellipse((waxing ? -1 : 1) * moonRadius * phaseScale, 0, moonRadius, moonRadius, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, moonRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
function drawPlanets(width, height) {
|
|
for (const planet of MONTH_CHART_PLANETS) {
|
|
const alt = Number(planet.alt);
|
|
const az = Number(planet.az);
|
|
if (!Number.isFinite(alt) || !Number.isFinite(az) || alt < 0) continue;
|
|
|
|
const pt = southProject(alt, az);
|
|
if (!pt) continue;
|
|
|
|
const style = getPlanetStyle(planet);
|
|
const mag = Number(planet.mag);
|
|
const baseRadius = Number.isFinite(mag)
|
|
? Math.max(3.8, Math.min(8.6, 6.7 - mag * 0.72))
|
|
: 5.4;
|
|
const glowRadius = baseRadius * 2.2;
|
|
|
|
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, glowRadius);
|
|
glow.addColorStop(0, style.glow);
|
|
glow.addColorStop(0.45, style.glow.replace(/0\.\d+\)$/, '0.12)'));
|
|
glow.addColorStop(1, style.glow.replace(/0\.\d+\)$/, '0)'));
|
|
ctx.fillStyle = glow;
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, glowRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = style.fill;
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, baseRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.26)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(pt.x, pt.y, baseRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
if (planet.key === 'jupiter') {
|
|
ctx.save();
|
|
ctx.translate(pt.x, pt.y);
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, baseRadius - 0.3, 0, Math.PI * 2);
|
|
ctx.clip();
|
|
|
|
ctx.strokeStyle = 'rgba(116, 84, 54, 0.78)';
|
|
ctx.lineWidth = Math.max(1, baseRadius * 0.34);
|
|
ctx.beginPath();
|
|
ctx.moveTo(-baseRadius * 0.95, -baseRadius * 0.34);
|
|
ctx.lineTo(baseRadius * 0.95, -baseRadius * 0.34);
|
|
ctx.moveTo(-baseRadius * 0.95, baseRadius * 0.2);
|
|
ctx.lineTo(baseRadius * 0.95, baseRadius * 0.2);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = 'rgba(194, 92, 68, 0.95)';
|
|
ctx.beginPath();
|
|
ctx.ellipse(baseRadius * 0.42, baseRadius * 0.06, baseRadius * 0.3, baseRadius * 0.17, -0.18, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
if (planet.key === 'saturn') {
|
|
ctx.save();
|
|
ctx.translate(pt.x, pt.y);
|
|
ctx.rotate(-0.35);
|
|
ctx.strokeStyle = 'rgba(232, 221, 170, 0.72)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 0, baseRadius * 1.7, baseRadius * 0.72, 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
const label = String(planet.label || '').trim();
|
|
if (label !== '') {
|
|
placePlanetLabel(label, pt.x, pt.y, baseRadius);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawBackground(width, height) {
|
|
const zenitPt = southProject(88, 180) || { x: width / 2, y: height * 0.18 };
|
|
const horizonPt = southProject(0, 180) || { x: width / 2, y: height * 0.88 };
|
|
const baseRadius = Math.max(width, height) * 0.9;
|
|
|
|
const baseGrad = ctx.createRadialGradient(zenitPt.x, zenitPt.y, 0, zenitPt.x, zenitPt.y, baseRadius);
|
|
baseGrad.addColorStop(0, '#010203');
|
|
baseGrad.addColorStop(0.4, '#02050b');
|
|
baseGrad.addColorStop(0.74, '#06101d');
|
|
baseGrad.addColorStop(1, '#0d1f38');
|
|
ctx.fillStyle = baseGrad;
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
const zenithDarkening = ctx.createRadialGradient(zenitPt.x, zenitPt.y, 0, zenitPt.x, zenitPt.y, baseRadius * 0.62);
|
|
zenithDarkening.addColorStop(0, 'rgba(0, 0, 0, 0.44)');
|
|
zenithDarkening.addColorStop(0.42, 'rgba(0, 0, 0, 0.2)');
|
|
zenithDarkening.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
|
ctx.fillStyle = zenithDarkening;
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
const horizonGlow = ctx.createRadialGradient(
|
|
horizonPt.x,
|
|
horizonPt.y,
|
|
width * 0.14,
|
|
horizonPt.x,
|
|
horizonPt.y,
|
|
width * 0.62
|
|
);
|
|
horizonGlow.addColorStop(0, 'rgba(118, 170, 255, 0.11)');
|
|
horizonGlow.addColorStop(0.36, 'rgba(78, 126, 214, 0.055)');
|
|
horizonGlow.addColorStop(0.7, 'rgba(24, 46, 86, 0.02)');
|
|
horizonGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
|
ctx.fillStyle = horizonGlow;
|
|
ctx.fillRect(0, 0, width, height);
|
|
}
|
|
|
|
function strokeProjectedCurve(samples, strokeStyle, lineWidth) {
|
|
let penDown = false;
|
|
ctx.strokeStyle = strokeStyle;
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.beginPath();
|
|
for (const sample of samples) {
|
|
const pt = southProject(sample.alt, sample.az);
|
|
if (!pt) {
|
|
penDown = false;
|
|
continue;
|
|
}
|
|
if (!penDown) {
|
|
ctx.moveTo(pt.x, pt.y);
|
|
penDown = true;
|
|
} else {
|
|
ctx.lineTo(pt.x, pt.y);
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
|
|
function drawGuides(width, height) {
|
|
ctx.strokeStyle = 'rgba(140, 165, 220, 0.14)';
|
|
ctx.lineWidth = 1;
|
|
|
|
for (const alt of [0, 30, 60]) {
|
|
const samples = [];
|
|
for (let az = 90; az <= 270; az += 2) {
|
|
samples.push({ alt, az });
|
|
}
|
|
strokeProjectedCurve(samples, alt === 0 ? 'rgba(183, 205, 255, 0.18)' : 'rgba(140, 165, 220, 0.14)', 1);
|
|
}
|
|
|
|
for (const az of [90, 135, 180, 225, 270]) {
|
|
const samples = [];
|
|
for (let alt = 0; alt <= 90; alt += 2) {
|
|
samples.push({ alt, az });
|
|
}
|
|
strokeProjectedCurve(samples, 'rgba(140, 165, 220, 0.12)', 1);
|
|
}
|
|
|
|
function drawDirectionLabelOutside(azDeg, text, offset = 18) {
|
|
const horizonPt = southProject(0, azDeg);
|
|
const innerPt = southProject(4, azDeg);
|
|
const prevPt = southProject(0, azDeg - 2);
|
|
const nextPt = southProject(0, azDeg + 2);
|
|
if (!horizonPt || !innerPt || !prevPt || !nextPt) {
|
|
return;
|
|
}
|
|
|
|
let nx = horizonPt.x - innerPt.x;
|
|
let ny = horizonPt.y - innerPt.y;
|
|
const normalLen = Math.hypot(nx, ny) || 1;
|
|
nx /= normalLen;
|
|
ny /= normalLen;
|
|
|
|
let angle = Math.atan2(nextPt.y - prevPt.y, nextPt.x - prevPt.x);
|
|
if (angle > Math.PI / 2 || angle < -Math.PI / 2) {
|
|
angle += Math.PI;
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.translate(horizonPt.x + nx * offset, horizonPt.y + ny * offset);
|
|
ctx.rotate(angle);
|
|
ctx.fillStyle = 'rgba(214, 226, 255, 0.72)';
|
|
ctx.font = '600 12px Rajdhani, sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, 0, 0);
|
|
ctx.restore();
|
|
}
|
|
|
|
const directionLabels = [
|
|
{ az: 90, text: 'Osten' },
|
|
{ az: 135, text: 'Südosten' },
|
|
{ az: 180, text: 'Süden' },
|
|
{ az: 225, text: 'Südwesten' },
|
|
{ az: 270, text: 'Westen' },
|
|
];
|
|
|
|
for (const label of directionLabels) {
|
|
drawDirectionLabelOutside(label.az, label.text, label.az === 180 ? 22 : 18);
|
|
}
|
|
|
|
const zenitPt = southProject(88, 180);
|
|
if (zenitPt) {
|
|
ctx.fillStyle = 'rgba(214, 226, 255, 0.72)';
|
|
ctx.font = '600 12px Rajdhani, sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText('Zenit', zenitPt.x, zenitPt.y - 8);
|
|
}
|
|
|
|
for (const alt of [30, 60]) {
|
|
const pt = southProject(alt, 182);
|
|
if (!pt) {
|
|
continue;
|
|
}
|
|
ctx.fillStyle = 'rgba(214, 226, 255, 0.58)';
|
|
ctx.font = '600 11px Rajdhani, sans-serif';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(`${alt}°`, pt.x + 8, pt.y);
|
|
}
|
|
}
|
|
|
|
function drawConstellations(width, height) {
|
|
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
|
|
if (!Array.isArray(constellation.lines)) continue;
|
|
for (const segment of constellation.lines) {
|
|
if (!Array.isArray(segment) || segment.length < 2) continue;
|
|
const points = [];
|
|
for (const hip of segment) {
|
|
const star = starByHip.get(Number(hip));
|
|
if (!star) continue;
|
|
const hor = starHorizon(star);
|
|
const pt = southProject(hor.altitude, hor.azimuth);
|
|
if (pt) points.push(pt);
|
|
}
|
|
if (points.length < 2) continue;
|
|
|
|
ctx.save();
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.strokeStyle = 'rgba(210, 214, 224, 0.16)';
|
|
ctx.lineWidth = 2.1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(points[0].x, points[0].y);
|
|
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = 'rgba(36, 40, 48, 0.34)';
|
|
ctx.lineWidth = 0.95;
|
|
ctx.beginPath();
|
|
ctx.moveTo(points[0].x + 0.18, points[0].y - 0.12);
|
|
for (let i = 1; i < points.length; i++) {
|
|
const jitterX = (i % 2 === 0 ? 0.22 : -0.12);
|
|
const jitterY = (i % 2 === 0 ? -0.14 : 0.2);
|
|
ctx.lineTo(points[i].x + jitterX, points[i].y + jitterY);
|
|
}
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawConstellationLabels(width, height) {
|
|
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
|
|
const label = constellation.common_name?.german || constellation.common_name?.native || constellation.id;
|
|
if (!label) continue;
|
|
const labelPositions = Array.isArray(constellation.label_positions)
|
|
? constellation.label_positions.filter((pos) => Array.isArray(pos) && pos.length >= 2)
|
|
: [];
|
|
|
|
for (const [raH, decD] of labelPositions) {
|
|
const hor = equatorialHorizon(Number(raH), Number(decD));
|
|
const pt = southProject(hor.altitude, hor.azimuth);
|
|
if (!pt || hor.altitude < 8) continue;
|
|
if (labelPlacer.drawText({
|
|
text: label,
|
|
x: pt.x,
|
|
y: pt.y,
|
|
font: '600 11px Rajdhani, sans-serif',
|
|
color: 'rgba(198, 214, 255, 0.54)',
|
|
align: 'center',
|
|
baseline: 'middle',
|
|
padding: 3,
|
|
})) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawMessier(width, height) {
|
|
for (const obj of MONTH_CHART_MESSIER) {
|
|
if (Number(obj.mag) > Number(MONTH_CHART_CONFIG.messierLimit)) continue;
|
|
const hor = equatorialHorizon(Number(obj.ra), Number(obj.dec));
|
|
const pt = southProject(hor.altitude, hor.azimuth);
|
|
if (!pt) continue;
|
|
const style = getMessierStyle(String(obj.type || 'As'));
|
|
const symbolRadius = Math.max(3.4, Math.min(6.8, 3.2 + Math.max(0, 6.5 - Number(obj.mag)) * 0.5));
|
|
ctx.strokeStyle = style.stroke;
|
|
ctx.fillStyle = style.fill;
|
|
ctx.lineWidth = 1;
|
|
|
|
if (obj.type === 'Gc') {
|
|
ctx.beginPath(); ctx.arc(pt.x, pt.y, symbolRadius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(pt.x - symbolRadius * 0.8, pt.y); ctx.lineTo(pt.x + symbolRadius * 0.8, pt.y);
|
|
ctx.moveTo(pt.x, pt.y - symbolRadius * 0.8); ctx.lineTo(pt.x, pt.y + symbolRadius * 0.8);
|
|
ctx.stroke();
|
|
} else if (obj.type === 'Oc') {
|
|
ctx.setLineDash([2, 2]); ctx.beginPath(); ctx.arc(pt.x, pt.y, symbolRadius, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
|
} else if (obj.type === 'Gx') {
|
|
ctx.save(); ctx.translate(pt.x, pt.y); ctx.rotate(Math.PI / 5); ctx.beginPath(); ctx.ellipse(0, 0, symbolRadius, symbolRadius * 0.58, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.restore();
|
|
} else if (obj.type === 'Nb' || obj.type === 'Sc') {
|
|
ctx.beginPath(); ctx.rect(pt.x - symbolRadius, pt.y - symbolRadius * 0.62, symbolRadius * 2, symbolRadius * 1.24); ctx.fill(); ctx.stroke();
|
|
} else if (obj.type === 'Pn') {
|
|
ctx.beginPath(); ctx.arc(pt.x, pt.y, symbolRadius * 0.62, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
|
} else {
|
|
ctx.beginPath(); ctx.arc(pt.x, pt.y, symbolRadius * 0.68, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
|
}
|
|
|
|
const label = getMessierLabel(obj);
|
|
if (label) {
|
|
labelPlacer.drawText({
|
|
text: label,
|
|
x: pt.x + symbolRadius + 5,
|
|
y: pt.y,
|
|
font: '600 11px Rajdhani, sans-serif',
|
|
color: 'rgba(210, 224, 255, 0.78)',
|
|
align: 'left',
|
|
baseline: 'middle',
|
|
padding: 2,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawStars(width, height) {
|
|
for (const star of displayStarMap.values()) {
|
|
const hip = Number(star.hip);
|
|
const isConstellationStar = Number.isFinite(hip) && constellationHipSet.has(hip);
|
|
if (!isConstellationStar && Number(star.mag) > Number(MONTH_CHART_CONFIG.starLimit)) continue;
|
|
const hor = starHorizon(star);
|
|
const pt = southProject(hor.altitude, hor.azimuth);
|
|
if (!pt) continue;
|
|
|
|
const magRange = Math.max(0.5, Number(MONTH_CHART_CONFIG.starLimit) + 1.6);
|
|
const effectiveMagnitude = isConstellationStar
|
|
? Math.min(Number(star.mag), Number(MONTH_CHART_CONFIG.starLimit) + 0.35)
|
|
: Number(star.mag);
|
|
const brightness = clamp01((Number(MONTH_CHART_CONFIG.starLimit) - effectiveMagnitude + 1.6) / magRange);
|
|
const starRadius = (isConstellationStar ? 0.85 : 0.65) + Math.pow(brightness, 1.9) * 5.2;
|
|
const glowRadius = starRadius * (effectiveMagnitude <= 1 ? 2.8 : 1.9);
|
|
const color = starColor(star);
|
|
|
|
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, glowRadius);
|
|
glow.addColorStop(0, color + '66');
|
|
glow.addColorStop(0.35, color + '22');
|
|
glow.addColorStop(1, color + '00');
|
|
ctx.fillStyle = glow;
|
|
ctx.beginPath(); ctx.arc(pt.x, pt.y, glowRadius, 0, Math.PI * 2); ctx.fill();
|
|
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath(); ctx.arc(pt.x, pt.y, starRadius, 0, Math.PI * 2); ctx.fill();
|
|
|
|
if (star.mag <= 0.8) {
|
|
ctx.strokeStyle = color + '44';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pt.x - starRadius * 1.35, pt.y); ctx.lineTo(pt.x + starRadius * 1.35, pt.y);
|
|
ctx.moveTo(pt.x, pt.y - starRadius * 1.35); ctx.lineTo(pt.x, pt.y + starRadius * 1.35);
|
|
ctx.stroke();
|
|
}
|
|
|
|
if (Number(star.mag) <= Number(MONTH_CHART_CONFIG.starLabelLimit)) {
|
|
const label = getStarLabel(star);
|
|
if (label) {
|
|
labelPlacer.drawText({
|
|
text: label,
|
|
x: pt.x + starRadius + 5,
|
|
y: pt.y - 1,
|
|
font: '600 11px Rajdhani, sans-serif',
|
|
color: 'rgba(232, 239, 255, 0.82)',
|
|
align: 'left',
|
|
baseline: 'middle',
|
|
padding: 2,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function draw() {
|
|
const width = canvas.clientWidth;
|
|
const height = canvas.clientHeight;
|
|
projectionLayout = computeProjectionLayout(width, height);
|
|
ctx.clearRect(0, 0, width, height);
|
|
labelPlacer.reset();
|
|
drawBackground(width, height);
|
|
drawGuides(width, height);
|
|
drawConstellations(width, height);
|
|
drawConstellationLabels(width, height);
|
|
drawMessier(width, height);
|
|
drawMoon(width, height);
|
|
drawPlanets(width, height);
|
|
drawStars(width, height);
|
|
statusEl.textContent = 'Südhimmel für ' + MONTH_CHART_CONFIG.localTimeLabel;
|
|
}
|
|
|
|
resizeCanvas();
|
|
window.addEventListener('resize', resizeCanvas);
|
|
})();
|
|
</script>
|
|
|
|
<?php require __DIR__ . '/footer.php'; ?>
|