1603 lines
59 KiB
PHP
1603 lines
59 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
ini_set('display_errors', '1');
|
|
error_reporting(E_ALL);
|
|
session_start();
|
|
|
|
require_once __DIR__ . '/auth_helpers.php';
|
|
applyLocalDevLoginBypass();
|
|
|
|
$pageTitle = 'Ephemeriden - AstroTools';
|
|
$headerIntroPre = 'Berechnungen';
|
|
$headerIntroTitle = 'Ephemeriden';
|
|
$headerIntroSub = '';
|
|
|
|
$loggedIn = isset($_SESSION['user_id']);
|
|
$defaultLocation = null;
|
|
$favoriteMinorplanetOptions = [];
|
|
$favoriteCometOptions = [];
|
|
$minorplanetPayloads = [];
|
|
$cometPayloads = [];
|
|
$chartStarData = [];
|
|
$chartLineStarData = [];
|
|
$chartConstellationLines = [];
|
|
$ephemerisChartConfigJson = 'null';
|
|
$ephemerisStarDataJson = '[]';
|
|
$ephemerisLineStarDataJson = '[]';
|
|
$ephemerisConstellationLinesJson = '[]';
|
|
|
|
function decodeMpcPackedDateChar(string $value): ?int
|
|
{
|
|
if ($value >= '0' && $value <= '9') {
|
|
return (int) $value;
|
|
}
|
|
|
|
$upper = strtoupper($value);
|
|
if ($upper >= 'A' && $upper <= 'V') {
|
|
return ord($upper) - ord('A') + 10;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function decodeMpcPackedDate(?string $packed): ?string
|
|
{
|
|
if ($packed === null || strlen($packed) !== 5) {
|
|
return null;
|
|
}
|
|
|
|
$centuryMap = [
|
|
'I' => 1800,
|
|
'J' => 1900,
|
|
'K' => 2000,
|
|
];
|
|
|
|
$centuryCode = strtoupper($packed[0]);
|
|
if (!isset($centuryMap[$centuryCode])) {
|
|
return null;
|
|
}
|
|
|
|
$yearPart = substr($packed, 1, 2);
|
|
if (!ctype_digit($yearPart)) {
|
|
return null;
|
|
}
|
|
|
|
$month = decodeMpcPackedDateChar($packed[3]);
|
|
$day = decodeMpcPackedDateChar($packed[4]);
|
|
if ($month === null || $day === null || $month < 1 || $month > 12 || $day < 1 || $day > 31) {
|
|
return null;
|
|
}
|
|
|
|
return sprintf('%04d-%02d-%02d', $centuryMap[$centuryCode] + (int) $yearPart, $month, $day);
|
|
}
|
|
|
|
function buildCometPerihelionIso(array $comet): ?string
|
|
{
|
|
$year = isset($comet['year_of_perihelion']) ? (int) $comet['year_of_perihelion'] : 0;
|
|
$month = isset($comet['month_of_perihelion']) ? (int) $comet['month_of_perihelion'] : 0;
|
|
$dayValue = isset($comet['day_of_perihelion']) ? (float) $comet['day_of_perihelion'] : 0.0;
|
|
|
|
if ($year <= 0 || $month < 1 || $month > 12 || $dayValue <= 0.0) {
|
|
return null;
|
|
}
|
|
|
|
$day = (int) floor($dayValue);
|
|
if ($day < 1 || $day > 31) {
|
|
return null;
|
|
}
|
|
|
|
$fractionalDay = $dayValue - $day;
|
|
$seconds = (int) round($fractionalDay * 86400.0);
|
|
|
|
try {
|
|
$date = new DateTimeImmutable(sprintf('%04d-%02d-%02d 00:00:00', $year, $month, $day), new DateTimeZone('UTC'));
|
|
} catch (Throwable $e) {
|
|
return null;
|
|
}
|
|
|
|
return $date->modify(sprintf('+%d seconds', $seconds))->format('c');
|
|
}
|
|
|
|
if ($loggedIn && isset($_SESSION['user_id'])) {
|
|
try {
|
|
$cfg = require __DIR__ . '/../config/database.php';
|
|
$pdo = new PDO(
|
|
sprintf('mysql:host=%s;dbname=%s;charset=%s', $cfg['host'], $cfg['dbname'], $cfg['charset'] ?? 'utf8mb4'),
|
|
$cfg['user'],
|
|
$cfg['pass'],
|
|
[
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
]
|
|
);
|
|
|
|
$stmtLoc = $pdo->prepare(
|
|
'SELECT id, name, latitude, longitude, elevation, timezone
|
|
FROM app_user_locations
|
|
WHERE user_id = ? AND is_default = 1
|
|
LIMIT 1'
|
|
);
|
|
$stmtLoc->execute([(int) $_SESSION['user_id']]);
|
|
$defaultLocation = $stmtLoc->fetch() ?: null;
|
|
|
|
$stmtFavoriteMinorplanets = $pdo->prepare(
|
|
'SELECT
|
|
mp.id,
|
|
mp.mp_number,
|
|
mp.designation_text,
|
|
mp.desig_packed,
|
|
mp.epoch_packed,
|
|
mp.mean_anomaly_deg,
|
|
mp.arg_perihelion_deg,
|
|
mp.ascending_node_deg,
|
|
mp.inclination_deg,
|
|
mp.eccentricity,
|
|
mp.mean_motion_deg_per_day,
|
|
mp.semimajor_axis_au
|
|
FROM app_user_minorplanets aum
|
|
INNER JOIN minorplanets_mpc mp
|
|
ON mp.id = aum.minorplanet_id
|
|
WHERE aum.user_id = ?
|
|
ORDER BY aum.is_favorite DESC, mp.mp_number IS NULL ASC, mp.mp_number ASC, mp.designation_text ASC'
|
|
);
|
|
$stmtFavoriteMinorplanets->execute([(int) $_SESSION['user_id']]);
|
|
foreach ($stmtFavoriteMinorplanets->fetchAll() as $minorplanet) {
|
|
$minorplanetLabel = trim((string) ($minorplanet['designation_text'] ?? ''));
|
|
if ($minorplanetLabel === '') {
|
|
$minorplanetLabel = trim((string) ($minorplanet['desig_packed'] ?? ''));
|
|
}
|
|
if ($minorplanetLabel === '') {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
$minorplanet['mp_number'] !== null
|
|
&& !str_contains($minorplanetLabel, '(' . (string) $minorplanet['mp_number'] . ')')
|
|
) {
|
|
$minorplanetLabel = '(' . (string) $minorplanet['mp_number'] . ') ' . $minorplanetLabel;
|
|
}
|
|
|
|
$bodyKey = 'minorplanet:' . (string) $minorplanet['id'];
|
|
$favoriteMinorplanetOptions[$bodyKey] = $minorplanetLabel;
|
|
$minorplanetPayloads[$bodyKey] = [
|
|
'id' => (int) $minorplanet['id'],
|
|
'designation' => $minorplanetLabel,
|
|
'epochDateIso' => decodeMpcPackedDate((string) ($minorplanet['epoch_packed'] ?? '')),
|
|
'meanAnomalyDeg' => isset($minorplanet['mean_anomaly_deg']) ? (float) $minorplanet['mean_anomaly_deg'] : null,
|
|
'argPerihelionDeg' => isset($minorplanet['arg_perihelion_deg']) ? (float) $minorplanet['arg_perihelion_deg'] : null,
|
|
'ascendingNodeDeg' => isset($minorplanet['ascending_node_deg']) ? (float) $minorplanet['ascending_node_deg'] : null,
|
|
'inclinationDeg' => isset($minorplanet['inclination_deg']) ? (float) $minorplanet['inclination_deg'] : null,
|
|
'eccentricity' => isset($minorplanet['eccentricity']) ? (float) $minorplanet['eccentricity'] : null,
|
|
'meanMotionDegPerDay' => isset($minorplanet['mean_motion_deg_per_day']) ? (float) $minorplanet['mean_motion_deg_per_day'] : null,
|
|
'semimajorAxisAu' => isset($minorplanet['semimajor_axis_au']) ? (float) $minorplanet['semimajor_axis_au'] : null,
|
|
];
|
|
}
|
|
|
|
$stmtFavoriteComets = $pdo->prepare(
|
|
'SELECT
|
|
c.id,
|
|
c.designation_and_name,
|
|
c.year_of_perihelion,
|
|
c.month_of_perihelion,
|
|
c.day_of_perihelion,
|
|
c.perihelion_dist_au,
|
|
c.eccentricity,
|
|
c.arg_perihelion_deg,
|
|
c.ascending_node_deg,
|
|
c.inclination_deg
|
|
FROM app_user_comets auc
|
|
INNER JOIN comets_mpc c
|
|
ON c.id = auc.comet_id
|
|
WHERE auc.user_id = ?
|
|
ORDER BY auc.is_favorite DESC, c.designation_and_name ASC'
|
|
);
|
|
$stmtFavoriteComets->execute([(int) $_SESSION['user_id']]);
|
|
foreach ($stmtFavoriteComets->fetchAll() as $comet) {
|
|
$cometLabel = trim((string) ($comet['designation_and_name'] ?? ''));
|
|
if ($cometLabel === '') {
|
|
continue;
|
|
}
|
|
|
|
$bodyKey = 'comet:' . (string) $comet['id'];
|
|
$favoriteCometOptions[$bodyKey] = $cometLabel;
|
|
$cometPayloads[$bodyKey] = [
|
|
'id' => (int) $comet['id'],
|
|
'designation' => $cometLabel,
|
|
'perihelionIso' => buildCometPerihelionIso($comet),
|
|
'perihelionDistanceAu' => isset($comet['perihelion_dist_au']) ? (float) $comet['perihelion_dist_au'] : null,
|
|
'eccentricity' => isset($comet['eccentricity']) ? (float) $comet['eccentricity'] : null,
|
|
'argPerihelionDeg' => isset($comet['arg_perihelion_deg']) ? (float) $comet['arg_perihelion_deg'] : null,
|
|
'ascendingNodeDeg' => isset($comet['ascending_node_deg']) ? (float) $comet['ascending_node_deg'] : null,
|
|
'inclinationDeg' => isset($comet['inclination_deg']) ? (float) $comet['inclination_deg'] : null,
|
|
];
|
|
}
|
|
|
|
$stmtStars = $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 <= 6.0
|
|
ORDER BY mag ASC"
|
|
);
|
|
|
|
while ($row = $stmtStars->fetch()) {
|
|
$chartStarData[] = [
|
|
'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' => [],
|
|
];
|
|
}
|
|
};
|
|
|
|
$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);
|
|
|
|
$chartConstellationLines = 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()) {
|
|
$chartLineStarData[] = [
|
|
'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,
|
|
];
|
|
}
|
|
} catch (Throwable $e) {
|
|
$defaultLocation = null;
|
|
$favoriteMinorplanetOptions = [];
|
|
$favoriteCometOptions = [];
|
|
$minorplanetPayloads = [];
|
|
$cometPayloads = [];
|
|
$chartStarData = [];
|
|
$chartLineStarData = [];
|
|
$chartConstellationLines = [];
|
|
}
|
|
}
|
|
|
|
$planetOptions = [
|
|
'Sun' => 'Sonne',
|
|
'Moon' => 'Mond',
|
|
'Mercury' => 'Merkur',
|
|
'Venus' => 'Venus',
|
|
'Mars' => 'Mars',
|
|
'Jupiter' => 'Jupiter',
|
|
'Saturn' => 'Saturn',
|
|
'Uranus' => 'Uranus',
|
|
'Neptune' => 'Neptun',
|
|
];
|
|
|
|
$bodyOptions = $planetOptions + $favoriteMinorplanetOptions + $favoriteCometOptions;
|
|
|
|
$intervalOptions = [
|
|
'1' => '1 Minute',
|
|
'10' => '10 Minuten',
|
|
'30' => '30 Minuten',
|
|
'60' => '1 Stunde',
|
|
'360' => '6 Stunden',
|
|
'720' => '12 Stunden',
|
|
'1440' => '1 Tag',
|
|
'2880' => '2 Tage',
|
|
'4320' => '3 Tage',
|
|
'10080' => '7 Tage',
|
|
'20160' => '14 Tage',
|
|
'43200' => '30 Tage',
|
|
];
|
|
|
|
$rangeUnitOptions = [
|
|
'minutes' => 'Minuten',
|
|
'hours' => 'Stunden',
|
|
'days' => 'Tage',
|
|
'weeks' => 'Wochen',
|
|
'months' => 'Monate',
|
|
'years' => 'Jahre',
|
|
];
|
|
|
|
$selectedBody = (string) ($_POST['body'] ?? 'Mercury');
|
|
if (!isset($bodyOptions[$selectedBody])) {
|
|
$selectedBody = 'Mercury';
|
|
}
|
|
|
|
$selectedInterval = (string) ($_POST['interval_minutes'] ?? '1440');
|
|
if (!isset($intervalOptions[$selectedInterval])) {
|
|
$selectedInterval = '1440';
|
|
}
|
|
|
|
$selectedRangeUnit = (string) ($_POST['range_unit'] ?? 'days');
|
|
if (!isset($rangeUnitOptions[$selectedRangeUnit])) {
|
|
$selectedRangeUnit = 'days';
|
|
}
|
|
|
|
$rangeValueInput = trim((string) ($_POST['range_value'] ?? '7'));
|
|
if ($rangeValueInput === '') {
|
|
$rangeValueInput = '7';
|
|
}
|
|
|
|
$startDtInput = trim((string) ($_POST['start_dt'] ?? ''));
|
|
// Validierung: muss leer sein (= jetzt) oder dem Format YYYY-MM-DDTHH:MM entsprechen
|
|
if ($startDtInput !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/', $startDtInput)) {
|
|
$startDtInput = '';
|
|
}
|
|
|
|
function ephemeriden_h(mixed $value): string
|
|
{
|
|
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
function ephemeriden_slug(string $value): string
|
|
{
|
|
$value = strtolower(trim($value));
|
|
$value = str_replace(
|
|
['ä', 'ö', 'ü', 'ß', ' '],
|
|
['ae', 'oe', 'ue', 'ss', '-'],
|
|
$value
|
|
);
|
|
$value = preg_replace('/[^a-z0-9\-]+/', '-', $value) ?? $value;
|
|
$value = trim($value, '-');
|
|
return $value !== '' ? $value : 'ephemeriden';
|
|
}
|
|
|
|
function runEphemeridenPythonApi(string $action, array $args): array
|
|
{
|
|
$scriptPath = __DIR__ . '/py/ephemeriden_api.py';
|
|
$pythonCandidates = PHP_OS_FAMILY === 'Windows'
|
|
? ['python', 'py']
|
|
: ['/usr/bin/python3', '/usr/bin/python', 'python3', 'python'];
|
|
|
|
$escapedArguments = array_map('escapeshellarg', array_merge([$scriptPath, $action], $args));
|
|
$lastError = 'Python konnte nicht gestartet werden.';
|
|
|
|
foreach ($pythonCandidates as $pythonBinary) {
|
|
$command = $pythonBinary . ' ' . implode(' ', $escapedArguments) . ' 2>&1';
|
|
$output = [];
|
|
$resultCode = 0;
|
|
exec($command, $output, $resultCode);
|
|
|
|
$joined = trim(implode("\n", $output));
|
|
$decoded = json_decode($joined, true);
|
|
|
|
if (is_array($decoded)) {
|
|
return [
|
|
'ok' => (bool) ($decoded['ok'] ?? false),
|
|
'data' => $decoded,
|
|
'raw' => $joined,
|
|
'command' => $command,
|
|
];
|
|
}
|
|
|
|
if ($joined !== '') {
|
|
$lastError = $joined;
|
|
} elseif ($resultCode !== 0) {
|
|
$lastError = 'Fehlercode ' . $resultCode . ' bei ' . $pythonBinary;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'ok' => false,
|
|
'error' => $lastError,
|
|
];
|
|
}
|
|
|
|
$pageLocation = null;
|
|
if (is_array($defaultLocation) && $defaultLocation !== []) {
|
|
$pageLocation = [
|
|
'name' => trim((string) ($defaultLocation['name'] ?? '')),
|
|
'latitude' => isset($defaultLocation['latitude']) ? (float) $defaultLocation['latitude'] : null,
|
|
'longitude' => isset($defaultLocation['longitude']) ? (float) $defaultLocation['longitude'] : null,
|
|
'elevation' => isset($defaultLocation['elevation']) && $defaultLocation['elevation'] !== null ? (float) $defaultLocation['elevation'] : 0.0,
|
|
'timezone' => (string) ($defaultLocation['timezone'] ?? 'Europe/Berlin'),
|
|
];
|
|
}
|
|
|
|
$defaultLocationName = $pageLocation['name'] ?? '';
|
|
$errorMessage = '';
|
|
$ephemerisRows = [];
|
|
$ephemerisChartRowsJson = '[]';
|
|
$ephemerisWindowStartIso = null;
|
|
$exportFormat = (string) ($_POST['export_format'] ?? '');
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
if ($pageLocation === null || $pageLocation['latitude'] === null || $pageLocation['longitude'] === null) {
|
|
$errorMessage = 'Kein gültiger Standard-Standort verfügbar. Bitte zuerst in den Einstellungen einen Standard-Standort setzen.';
|
|
} elseif (!ctype_digit($rangeValueInput) || (int) $rangeValueInput < 1 || (int) $rangeValueInput > 100) {
|
|
$errorMessage = 'Der Zeitraum muss als ganze Zahl zwischen 1 und 100 angegeben werden.';
|
|
} else {
|
|
$pythonAction = 'planet_ephemeris';
|
|
$pythonArgs = [
|
|
(string) $pageLocation['latitude'],
|
|
(string) $pageLocation['longitude'],
|
|
(string) $pageLocation['elevation'],
|
|
(string) $pageLocation['timezone'],
|
|
$selectedBody,
|
|
$selectedInterval,
|
|
$rangeValueInput,
|
|
$selectedRangeUnit,
|
|
$startDtInput,
|
|
];
|
|
|
|
if (isset($minorplanetPayloads[$selectedBody])) {
|
|
$pythonAction = 'small_body_ephemeris';
|
|
$pythonArgs = [
|
|
(string) $pageLocation['latitude'],
|
|
(string) $pageLocation['longitude'],
|
|
(string) $pageLocation['elevation'],
|
|
(string) $pageLocation['timezone'],
|
|
'minorplanet',
|
|
base64_encode(json_encode($minorplanetPayloads[$selectedBody], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'),
|
|
$selectedInterval,
|
|
$rangeValueInput,
|
|
$selectedRangeUnit,
|
|
$startDtInput,
|
|
];
|
|
} elseif (isset($cometPayloads[$selectedBody])) {
|
|
$pythonAction = 'small_body_ephemeris';
|
|
$pythonArgs = [
|
|
(string) $pageLocation['latitude'],
|
|
(string) $pageLocation['longitude'],
|
|
(string) $pageLocation['elevation'],
|
|
(string) $pageLocation['timezone'],
|
|
'comet',
|
|
base64_encode(json_encode($cometPayloads[$selectedBody], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'),
|
|
$selectedInterval,
|
|
$rangeValueInput,
|
|
$selectedRangeUnit,
|
|
$startDtInput,
|
|
];
|
|
}
|
|
|
|
$pythonResult = runEphemeridenPythonApi($pythonAction, $pythonArgs);
|
|
|
|
if (!$pythonResult['ok']) {
|
|
$errorMessage = (string) (
|
|
$pythonResult['data']['error']
|
|
?? $pythonResult['error']
|
|
?? 'Die Ephemeriden konnten nicht berechnet werden.'
|
|
);
|
|
} else {
|
|
$ephemerisRows = $pythonResult['data']['rows'] ?? [];
|
|
$ephemerisWindowStartIso = isset($pythonResult['data']['window']['local_start'])
|
|
? (string) $pythonResult['data']['window']['local_start']
|
|
: null;
|
|
if (!is_array($ephemerisRows)) {
|
|
$ephemerisRows = [];
|
|
}
|
|
|
|
if ($exportFormat === 'csv' && $ephemerisRows !== []) {
|
|
$filename = 'ephemeriden-' . ephemeriden_slug((string) ($bodyOptions[$selectedBody] ?? 'objekt')) . '.csv';
|
|
|
|
header('Content-Type: text/csv; charset=utf-8');
|
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
|
|
|
echo "\xEF\xBB\xBF";
|
|
$handle = fopen('php://output', 'wb');
|
|
if ($handle === false) {
|
|
exit;
|
|
}
|
|
|
|
fputcsv($handle, ['Name des Objekts', 'Datum', 'Uhrzeit', 'RA (J2000)', 'RA (J2000, dezimal h)', 'Dek (J2000)', 'Dek (J2000, dezimal °)', 'Aufgang', 'Untergang'], ';', '"', '\\');
|
|
foreach ($ephemerisRows as $row) {
|
|
fputcsv($handle, [
|
|
(string) ($row['object_name'] ?? ''),
|
|
(string) ($row['date_local'] ?? ''),
|
|
(string) ($row['time_local'] ?? ''),
|
|
(string) ($row['ra'] ?? ''),
|
|
(string) ($row['ra_decimal_hours'] ?? ''),
|
|
(string) ($row['dec'] ?? ''),
|
|
(string) ($row['dec_decimal_deg'] ?? ''),
|
|
(string) (($row['rise'] ?? null) ?? '—'),
|
|
(string) (($row['set'] ?? null) ?? '—'),
|
|
], ';', '"', '\\');
|
|
}
|
|
|
|
fclose($handle);
|
|
exit;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($ephemerisRows !== []) {
|
|
$chartRows = [];
|
|
foreach ($ephemerisRows as $row) {
|
|
$azimuthDeg = isset($row['azimuth_deg']) ? (float) $row['azimuth_deg'] : null;
|
|
$altitudeDeg = isset($row['altitude_deg']) ? (float) $row['altitude_deg'] : null;
|
|
if ($azimuthDeg === null || $altitudeDeg === null) {
|
|
continue;
|
|
}
|
|
|
|
$chartRows[] = [
|
|
'object_name' => (string) ($row['object_name'] ?? ''),
|
|
'date_local' => (string) ($row['date_local'] ?? ''),
|
|
'time_local' => (string) ($row['time_local'] ?? ''),
|
|
'local_iso' => (string) ($row['local_iso'] ?? ''),
|
|
'ra_decimal_hours' => isset($row['ra_decimal_hours']) ? (float) $row['ra_decimal_hours'] : null,
|
|
'dec_decimal_deg' => isset($row['dec_decimal_deg']) ? (float) $row['dec_decimal_deg'] : null,
|
|
'azimuth_deg' => $azimuthDeg,
|
|
'altitude_deg' => $altitudeDeg,
|
|
];
|
|
}
|
|
|
|
$ephemerisChartRowsJson = json_encode($chartRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
|
|
$ephemerisChartConfigJson = json_encode([
|
|
'location' => [
|
|
'name' => $defaultLocationName,
|
|
'latitude' => $pageLocation['latitude'] ?? null,
|
|
'longitude' => $pageLocation['longitude'] ?? null,
|
|
'elevation' => $pageLocation['elevation'] ?? 0.0,
|
|
'timezone' => $pageLocation['timezone'] ?? 'Europe/Berlin',
|
|
],
|
|
'utcTimeIso' => $ephemerisWindowStartIso,
|
|
'starLimit' => 4.4,
|
|
'starLabelLimit' => 2.0,
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: 'null';
|
|
$ephemerisStarDataJson = json_encode($chartStarData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
|
|
$ephemerisLineStarDataJson = json_encode($chartLineStarData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
|
|
$ephemerisConstellationLinesJson = json_encode($chartConstellationLines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
|
|
}
|
|
|
|
require __DIR__ . '/header.php';
|
|
|
|
?>
|
|
|
|
<style>
|
|
.ephemeriden-form {
|
|
display: grid;
|
|
gap: 0.8rem;
|
|
}
|
|
|
|
.ephemeriden-fields-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto;
|
|
gap: 0.8rem;
|
|
align-items: end;
|
|
}
|
|
|
|
.ephemeriden-submit-group {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.ephemeriden-results-head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 0.9rem;
|
|
}
|
|
|
|
.ephemeriden-results-note {
|
|
color: var(--text-dim);
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
.ephemeriden-export-form {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.ephemeriden-results-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.65rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.ephemeriden-message {
|
|
margin-bottom: 0.9rem;
|
|
padding: 0.85rem 1rem;
|
|
border-radius: var(--radius);
|
|
border: 1px solid var(--border);
|
|
background: var(--bg-card2);
|
|
}
|
|
|
|
.ephemeriden-message.is-error {
|
|
border-color: rgba(190, 92, 92, 0.45);
|
|
color: #ffd7d7;
|
|
}
|
|
|
|
.ephemeriden-table-wrap {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.ephemeriden-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.ephemeriden-table th,
|
|
.ephemeriden-table td {
|
|
padding: 0.75rem 0.85rem;
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--border);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.ephemeriden-table th {
|
|
color: var(--gold-pale);
|
|
font-size: 0.82rem;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.ephemeriden-table-empty td {
|
|
color: var(--text-dim);
|
|
white-space: normal;
|
|
}
|
|
|
|
.ephemeriden-chart-card[hidden] {
|
|
display: none;
|
|
}
|
|
|
|
.ephemeriden-chart-head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 0.9rem;
|
|
}
|
|
|
|
.ephemeriden-chart-note {
|
|
color: var(--text-dim);
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
.ephemeriden-chart-controls {
|
|
display: flex;
|
|
align-items: end;
|
|
gap: 0.8rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.ephemeriden-chart-controls .form-group {
|
|
min-width: 220px;
|
|
margin: 0;
|
|
}
|
|
|
|
.ephemeriden-chart-stage {
|
|
display: flex;
|
|
justify-content: center;
|
|
width: 100%;
|
|
}
|
|
|
|
.ephemeriden-chart-stage canvas {
|
|
width: 100%;
|
|
max-width: 760px;
|
|
height: auto;
|
|
display: block;
|
|
}
|
|
|
|
.ephemeriden-chart-legend {
|
|
margin-top: 0.9rem;
|
|
color: var(--text-dim);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.ephemeriden-fields-row {
|
|
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.ephemeriden-fields-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<section class="card">
|
|
<form method="post" class="ephemeriden-form">
|
|
<div class="ephemeriden-fields-row">
|
|
<div class="form-group">
|
|
<label for="ephemeridenBody">Himmelskörper</label>
|
|
<select id="ephemeridenBody" name="body">
|
|
<optgroup label="Sonne, Mond und Planeten">
|
|
<?php foreach ($planetOptions as $bodyValue => $bodyLabel): ?>
|
|
<option value="<?= ephemeriden_h($bodyValue) ?>"<?= $bodyValue === $selectedBody ? ' selected' : '' ?>><?= ephemeriden_h($bodyLabel) ?></option>
|
|
<?php endforeach; ?>
|
|
</optgroup>
|
|
<?php if ($favoriteMinorplanetOptions !== []): ?>
|
|
<optgroup label="Meine Kleinplaneten">
|
|
<?php foreach ($favoriteMinorplanetOptions as $bodyValue => $bodyLabel): ?>
|
|
<option value="<?= ephemeriden_h($bodyValue) ?>"<?= $bodyValue === $selectedBody ? ' selected' : '' ?>><?= ephemeriden_h($bodyLabel) ?></option>
|
|
<?php endforeach; ?>
|
|
</optgroup>
|
|
<?php endif; ?>
|
|
<?php if ($favoriteCometOptions !== []): ?>
|
|
<optgroup label="Meine Kometen">
|
|
<?php foreach ($favoriteCometOptions as $bodyValue => $bodyLabel): ?>
|
|
<option value="<?= ephemeriden_h($bodyValue) ?>"<?= $bodyValue === $selectedBody ? ' selected' : '' ?>><?= ephemeriden_h($bodyLabel) ?></option>
|
|
<?php endforeach; ?>
|
|
</optgroup>
|
|
<?php endif; ?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="ephemeridenInterval">Intervall</label>
|
|
<select id="ephemeridenInterval" name="interval_minutes">
|
|
<?php foreach ($intervalOptions as $intervalValue => $intervalLabel): ?>
|
|
<option value="<?= ephemeriden_h($intervalValue) ?>"<?= (string) $intervalValue === $selectedInterval ? ' selected' : '' ?>><?= ephemeriden_h($intervalLabel) ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="ephemeridenStartDt">Startdatum & Uhrzeit</label>
|
|
<input
|
|
type="datetime-local"
|
|
id="ephemeridenStartDt"
|
|
name="start_dt"
|
|
value="<?= ephemeriden_h($startDtInput) ?>"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="ephemeridenRangeValue">Zeitraum</label>
|
|
<input
|
|
type="number"
|
|
id="ephemeridenRangeValue"
|
|
name="range_value"
|
|
min="1"
|
|
max="100"
|
|
step="1"
|
|
value="<?= ephemeriden_h($rangeValueInput) ?>"
|
|
inputmode="numeric"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="ephemeridenRangeUnit">Einheit</label>
|
|
<select id="ephemeridenRangeUnit" name="range_unit">
|
|
<?php foreach ($rangeUnitOptions as $rangeUnitValue => $rangeUnitLabel): ?>
|
|
<option value="<?= ephemeriden_h($rangeUnitValue) ?>"<?= $rangeUnitValue === $selectedRangeUnit ? ' selected' : '' ?>><?= ephemeriden_h($rangeUnitLabel) ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="ephemeriden-submit-group">
|
|
<button type="submit" class="btn btn-primary">Berechnen</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="card mt-md">
|
|
<div class="ephemeriden-results-head">
|
|
<div>
|
|
<h2>Ephemeriden</h2>
|
|
<div class="ephemeriden-results-note">
|
|
Standard-Standort:
|
|
<strong><?= ephemeriden_h($defaultLocationName !== '' ? $defaultLocationName : 'nicht verfügbar') ?></strong>
|
|
</div>
|
|
</div>
|
|
<?php if ($ephemerisRows !== []): ?>
|
|
<div class="ephemeriden-results-actions">
|
|
<button type="button" class="btn btn-secondary" id="ephemeridenChartToggle" aria-expanded="false" aria-controls="ephemeridenChartCard">Karte anzeigen</button>
|
|
<form method="post" class="ephemeriden-export-form">
|
|
<input type="hidden" name="body" value="<?= ephemeriden_h($selectedBody) ?>">
|
|
<input type="hidden" name="interval_minutes" value="<?= ephemeriden_h($selectedInterval) ?>">
|
|
<input type="hidden" name="range_value" value="<?= ephemeriden_h($rangeValueInput) ?>">
|
|
<input type="hidden" name="range_unit" value="<?= ephemeriden_h($selectedRangeUnit) ?>">
|
|
<input type="hidden" name="start_dt" value="<?= ephemeriden_h($startDtInput) ?>">
|
|
<input type="hidden" name="export_format" value="csv">
|
|
<button type="submit" class="btn btn-secondary">Export CSV</button>
|
|
</form>
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<?php if ($errorMessage !== ''): ?>
|
|
<div class="ephemeriden-message is-error"><?= ephemeriden_h($errorMessage) ?></div>
|
|
<?php endif; ?>
|
|
|
|
<div class="ephemeriden-table-wrap">
|
|
<table class="ephemeriden-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name des Objekts</th>
|
|
<th>Datum</th>
|
|
<th>Uhrzeit</th>
|
|
<th>RA (J2000)</th>
|
|
<th>Dek (J2000)</th>
|
|
<th>Aufgang</th>
|
|
<th>Untergang</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php if ($ephemerisRows !== []): ?>
|
|
<?php foreach ($ephemerisRows as $row): ?>
|
|
<tr>
|
|
<td><?= ephemeriden_h((string) ($row['object_name'] ?? '')) ?></td>
|
|
<td><?= ephemeriden_h((string) ($row['date_local'] ?? '')) ?></td>
|
|
<td><?= ephemeriden_h((string) ($row['time_local'] ?? '')) ?></td>
|
|
<td><?= ephemeriden_h((string) ($row['ra'] ?? '')) ?></td>
|
|
<td><?= ephemeriden_h((string) ($row['dec'] ?? '')) ?></td>
|
|
<td><?= ephemeriden_h((string) (($row['rise'] ?? null) ?? '—')) ?></td>
|
|
<td><?= ephemeriden_h((string) (($row['set'] ?? null) ?? '—')) ?></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
<?php elseif ($_SERVER['REQUEST_METHOD'] === 'POST' && $errorMessage === ''): ?>
|
|
<tr class="ephemeriden-table-empty">
|
|
<td colspan="7">Für die gewählte Anfrage wurden keine Tabellenzeilen erzeugt.</td>
|
|
</tr>
|
|
<?php else: ?>
|
|
<tr class="ephemeriden-table-empty">
|
|
<td colspan="7">Noch keine Berechnung ausgeführt.</td>
|
|
</tr>
|
|
<?php endif; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<?php if ($ephemerisRows !== []): ?>
|
|
<section class="card mt-md ephemeriden-chart-card" id="ephemeridenChartCard" hidden>
|
|
<div class="ephemeriden-chart-head">
|
|
<div>
|
|
<h2>Ephemeridenkarte</h2>
|
|
<div class="ephemeriden-chart-note">
|
|
Polare Himmelsansicht für <strong><?= ephemeriden_h((string) ($bodyOptions[$selectedBody] ?? 'Objekt')) ?></strong> am Standort <strong><?= ephemeriden_h($defaultLocationName !== '' ? $defaultLocationName : 'nicht verfügbar') ?></strong>
|
|
</div>
|
|
</div>
|
|
<div class="ephemeriden-chart-controls">
|
|
<div class="form-group">
|
|
<label for="ephemeridenChartPoint">Kartenzeitpunkt</label>
|
|
<select id="ephemeridenChartPoint">
|
|
<?php foreach ($ephemerisRows as $rowIndex => $row): ?>
|
|
<option value="<?= ephemeriden_h((string) $rowIndex) ?>"><?= ephemeriden_h((string) ($row['date_local'] ?? '') . ' ' . (string) ($row['time_local'] ?? '')) ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="ephemeridenChartTime">Kartenuhrzeit</label>
|
|
<input type="time" id="ephemeridenChartTime" step="60" value="<?= ephemeriden_h((string) ($ephemerisRows[0]['time_local'] ?? '00:00')) ?>">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="ephemeridenChartLabelStep">Beschriftung</label>
|
|
<select id="ephemeridenChartLabelStep">
|
|
<?php foreach ([2, 3, 4, 5, 6, 8, 10] as $labelStepOption): ?>
|
|
<option value="<?= ephemeriden_h((string) $labelStepOption) ?>"<?= $labelStepOption === 4 ? ' selected' : '' ?>>jeder <?= ephemeriden_h((string) $labelStepOption) ?>. Punkt</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="ephemeridenChartLabelRotation">Textausrichtung</label>
|
|
<select id="ephemeridenChartLabelRotation">
|
|
<?php foreach (range(-165, 165, 15) as $deg): ?>
|
|
<option value="<?= ephemeriden_h((string) $deg) ?>"<?= $deg === -45 ? ' selected' : '' ?>><?= ephemeriden_h($deg > 0 ? "+$deg" : (string) $deg) ?>°</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
<div class="ephemeriden-submit-group">
|
|
<button type="button" class="btn btn-secondary" id="ephemeridenChartApply">Karte neu berechnen</button>
|
|
<button type="button" class="btn btn-secondary" id="ephemeridenChartExport">Als PNG exportieren</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="ephemeriden-chart-stage">
|
|
<canvas id="ephemeridenPolarCanvas" width="900" height="900" aria-label="Polarkarte der berechneten Ephemeriden"></canvas>
|
|
</div>
|
|
<div class="ephemeriden-chart-legend">
|
|
Der äußere Kreis ist der Horizont, die Mitte ist der Zenit. Die Bahn wird in der Reihenfolge der Ephemeridenpunkte eingezeichnet.
|
|
</div>
|
|
</section>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($ephemerisRows !== []): ?>
|
|
<script src="js/astronomy.browser.min.js"></script>
|
|
<script>
|
|
const EPHEMERIDEN_CHART_ROWS = <?= $ephemerisChartRowsJson ?>;
|
|
const EPHEMERIDEN_CHART_CONFIG = <?= $ephemerisChartConfigJson ?>;
|
|
const EPHEMERIDEN_CHART_STARS = <?= $ephemerisStarDataJson ?>;
|
|
const EPHEMERIDEN_CHART_LINE_STARS = <?= $ephemerisLineStarDataJson ?>;
|
|
const EPHEMERIDEN_CHART_CONSTELLATIONS = <?= $ephemerisConstellationLinesJson ?>;
|
|
|
|
(function () {
|
|
const toggleButton = document.getElementById('ephemeridenChartToggle');
|
|
const chartCard = document.getElementById('ephemeridenChartCard');
|
|
const canvas = document.getElementById('ephemeridenPolarCanvas');
|
|
const pointSelect = document.getElementById('ephemeridenChartPoint');
|
|
const timeInput = document.getElementById('ephemeridenChartTime');
|
|
const labelStepSelect = document.getElementById('ephemeridenChartLabelStep');
|
|
const labelRotationSelect = document.getElementById('ephemeridenChartLabelRotation');
|
|
const applyButton = document.getElementById('ephemeridenChartApply');
|
|
|
|
const logoImage = new Image();
|
|
logoImage.src = 'images/logoAM.png';
|
|
logoImage.onload = () => { if (!chartCard.hidden) { drawChart(); } };
|
|
|
|
if (!toggleButton || !chartCard || !canvas || !pointSelect || !timeInput || !labelStepSelect || !labelRotationSelect || !applyButton || !Array.isArray(EPHEMERIDEN_CHART_ROWS) || EPHEMERIDEN_CHART_ROWS.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
const observer = window.Astronomy && EPHEMERIDEN_CHART_CONFIG && EPHEMERIDEN_CHART_CONFIG.location
|
|
? new Astronomy.Observer(
|
|
Number(EPHEMERIDEN_CHART_CONFIG.location.latitude),
|
|
Number(EPHEMERIDEN_CHART_CONFIG.location.longitude),
|
|
Number(EPHEMERIDEN_CHART_CONFIG.location.elevation || 0)
|
|
)
|
|
: null;
|
|
let occupiedBoxes = [];
|
|
const starByHip = new Map();
|
|
const constellationHipSet = new Set();
|
|
const displayStarMap = new Map();
|
|
|
|
for (const star of EPHEMERIDEN_CHART_LINE_STARS) {
|
|
if (Number.isFinite(Number(star.hip))) {
|
|
starByHip.set(Number(star.hip), star);
|
|
}
|
|
}
|
|
|
|
for (const star of EPHEMERIDEN_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 EPHEMERIDEN_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 projectPoint(azimuthDeg, altitudeDeg, radius, centerX, centerY) {
|
|
if (!Number.isFinite(azimuthDeg) || !Number.isFinite(altitudeDeg) || altitudeDeg < 0) {
|
|
return null;
|
|
}
|
|
|
|
const rho = (90 - altitudeDeg) / 90;
|
|
const azimuthRad = azimuthDeg * Math.PI / 180;
|
|
|
|
return {
|
|
x: centerX - rho * radius * Math.sin(azimuthRad),
|
|
y: centerY - rho * radius * Math.cos(azimuthRad),
|
|
};
|
|
}
|
|
|
|
function clamp01(value) {
|
|
return Math.max(0, Math.min(1, value));
|
|
}
|
|
|
|
function resetLabelBoxes() {
|
|
occupiedBoxes = [];
|
|
}
|
|
|
|
function getSelectedIndex() {
|
|
const rawValue = Number(pointSelect.value);
|
|
if (!Number.isInteger(rawValue) || rawValue < 0 || rawValue >= EPHEMERIDEN_CHART_ROWS.length) {
|
|
return 0;
|
|
}
|
|
|
|
return rawValue;
|
|
}
|
|
|
|
function getSelectedRow() {
|
|
return EPHEMERIDEN_CHART_ROWS[getSelectedIndex()] || null;
|
|
}
|
|
|
|
function extractRowDateParts(localIso) {
|
|
const isoText = String(localIso || '');
|
|
const match = isoText.match(/^(\d{4}-\d{2}-\d{2})T\d{2}:\d{2}(?::\d{2})?(.+)$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
datePart: match[1],
|
|
offsetPart: match[2],
|
|
};
|
|
}
|
|
|
|
function getChartTimeValue() {
|
|
const value = String(timeInput.value || '').trim();
|
|
return /^\d{2}:\d{2}$/.test(value) ? value : null;
|
|
}
|
|
|
|
function getLabelStepValue() {
|
|
const value = Number(labelStepSelect.value);
|
|
return Number.isInteger(value) && value >= 1 ? value : 4;
|
|
}
|
|
|
|
function getLabelRotationValue() {
|
|
const value = Number(labelRotationSelect.value);
|
|
return Number.isFinite(value) ? value : 45;
|
|
}
|
|
|
|
function syncChartTimeFromSelectedRow() {
|
|
const selectedRow = getSelectedRow();
|
|
if (!selectedRow || !selectedRow.time_local) {
|
|
return;
|
|
}
|
|
|
|
timeInput.value = String(selectedRow.time_local).slice(0, 5);
|
|
}
|
|
|
|
function getChartDate() {
|
|
const selectedRow = getSelectedRow();
|
|
const timeValue = getChartTimeValue();
|
|
if (!selectedRow || !selectedRow.local_iso || !timeValue) {
|
|
return null;
|
|
}
|
|
|
|
const dateParts = extractRowDateParts(selectedRow.local_iso);
|
|
if (!dateParts) {
|
|
return null;
|
|
}
|
|
|
|
const chartDate = new Date(`${dateParts.datePart}T${timeValue}:00${dateParts.offsetPart}`);
|
|
return Number.isNaN(chartDate.getTime()) ? null : chartDate;
|
|
}
|
|
|
|
function drawLabel(text, x, y, options = {}) {
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
|
|
const font = options.font || '600 11px Rajdhani, sans-serif';
|
|
const color = options.color || 'rgba(232, 239, 255, 0.82)';
|
|
const align = options.align || 'left';
|
|
const baseline = options.baseline || 'middle';
|
|
const padding = options.padding ?? 2;
|
|
const rotationDeg = Number(options.rotationDeg || 0);
|
|
const backgroundColor = options.backgroundColor || '';
|
|
const force = options.force === true;
|
|
|
|
ctx.save();
|
|
ctx.font = font;
|
|
const metrics = ctx.measureText(text);
|
|
const textWidth = metrics.width;
|
|
const ascent = metrics.actualBoundingBoxAscent || 8;
|
|
const descent = metrics.actualBoundingBoxDescent || 4;
|
|
const textHeight = ascent + descent;
|
|
|
|
let localX = 0;
|
|
if (align === 'center') {
|
|
localX = -textWidth / 2;
|
|
} else if (align === 'right') {
|
|
localX = -textWidth;
|
|
}
|
|
|
|
let localY = -ascent;
|
|
if (baseline === 'middle') {
|
|
localY = -(ascent + descent) / 2;
|
|
} else if (baseline === 'bottom' || baseline === 'alphabetic') {
|
|
localY = -ascent - descent;
|
|
}
|
|
|
|
const rectX = localX - padding;
|
|
const rectY = localY - padding;
|
|
const rectWidth = textWidth + padding * 2;
|
|
const rectHeight = textHeight + padding * 2;
|
|
const rotationRad = rotationDeg * Math.PI / 180;
|
|
const corners = [
|
|
{ x: rectX, y: rectY },
|
|
{ x: rectX + rectWidth, y: rectY },
|
|
{ x: rectX + rectWidth, y: rectY + rectHeight },
|
|
{ x: rectX, y: rectY + rectHeight },
|
|
].map((corner) => ({
|
|
x: x + corner.x * Math.cos(rotationRad) - corner.y * Math.sin(rotationRad),
|
|
y: y + corner.x * Math.sin(rotationRad) + corner.y * Math.cos(rotationRad),
|
|
}));
|
|
|
|
const box = {
|
|
x: Math.min(...corners.map((corner) => corner.x)),
|
|
y: Math.min(...corners.map((corner) => corner.y)),
|
|
width: Math.max(...corners.map((corner) => corner.x)) - Math.min(...corners.map((corner) => corner.x)),
|
|
height: Math.max(...corners.map((corner) => corner.y)) - Math.min(...corners.map((corner) => corner.y)),
|
|
};
|
|
|
|
const overlaps = 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
|
|
);
|
|
|
|
if (!overlaps || force) {
|
|
occupiedBoxes.push(box);
|
|
ctx.translate(x, y);
|
|
if (rotationDeg !== 0) {
|
|
ctx.rotate(rotationRad);
|
|
}
|
|
ctx.textAlign = align;
|
|
ctx.textBaseline = baseline;
|
|
if (backgroundColor !== '') {
|
|
ctx.fillStyle = backgroundColor;
|
|
ctx.fillRect(rectX, rectY, rectWidth, rectHeight);
|
|
}
|
|
ctx.fillStyle = color;
|
|
ctx.fillText(text, 0, 0);
|
|
ctx.restore();
|
|
return true;
|
|
}
|
|
|
|
ctx.restore();
|
|
return false;
|
|
}
|
|
|
|
function starHorizon(star) {
|
|
const chartDate = getChartDate();
|
|
if (!window.Astronomy || !observer || !chartDate) {
|
|
return null;
|
|
}
|
|
|
|
const raHours = Number(star.ra) * (12 / Math.PI);
|
|
const decDeg = Number(star.dec) * (180 / Math.PI);
|
|
return Astronomy.Horizon(chartDate, observer, raHours, decDeg, 'normal');
|
|
}
|
|
|
|
function ephemerisHorizon(row) {
|
|
const chartDate = getChartDate();
|
|
if (!window.Astronomy || !observer || !chartDate) {
|
|
return null;
|
|
}
|
|
|
|
const raHours = Number(row.ra_decimal_hours);
|
|
const decDeg = Number(row.dec_decimal_deg);
|
|
if (!Number.isFinite(raHours) || !Number.isFinite(decDeg)) {
|
|
return null;
|
|
}
|
|
|
|
return Astronomy.Horizon(chartDate, 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 drawStars(centerX, centerY, radius, scale) {
|
|
if (!window.Astronomy || !observer || !getChartDate() || !Array.isArray(EPHEMERIDEN_CHART_STARS)) {
|
|
return;
|
|
}
|
|
|
|
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(EPHEMERIDEN_CHART_CONFIG.starLimit || 4.4)) {
|
|
continue;
|
|
}
|
|
|
|
const hor = starHorizon(star);
|
|
if (!hor || hor.altitude < 0) {
|
|
continue;
|
|
}
|
|
|
|
const pt = projectPoint(hor.azimuth, hor.altitude, radius, centerX, centerY);
|
|
if (!pt) {
|
|
continue;
|
|
}
|
|
|
|
const magRange = Math.max(0.5, Number(EPHEMERIDEN_CHART_CONFIG.starLimit || 4.4) + 1.6);
|
|
const effectiveMagnitude = isConstellationStar
|
|
? Math.min(Number(star.mag), Number(EPHEMERIDEN_CHART_CONFIG.starLimit || 4.4) + 0.35)
|
|
: Number(star.mag);
|
|
const brightness = clamp01((Number(EPHEMERIDEN_CHART_CONFIG.starLimit || 4.4) - effectiveMagnitude + 1.6) / magRange);
|
|
const starRadius = ((isConstellationStar ? 0.85 : 0.65) + Math.pow(brightness, 1.9) * 5.2) * scale;
|
|
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 (Number(star.mag) <= 0.8) {
|
|
ctx.strokeStyle = color + '44';
|
|
ctx.lineWidth = scale;
|
|
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(EPHEMERIDEN_CHART_CONFIG.starLabelLimit || 2.0)) {
|
|
const label = getStarLabel(star);
|
|
if (label !== '') {
|
|
drawLabel(label, pt.x + starRadius + 5 * scale, pt.y - 1, {
|
|
font: `600 ${Math.round(11 * scale)}px Rajdhani, sans-serif`,
|
|
color: 'rgba(232, 239, 255, 0.82)',
|
|
align: 'left',
|
|
baseline: 'middle',
|
|
padding: 2 * scale,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawConstellationLines(centerX, centerY, radius, scale) {
|
|
if (!window.Astronomy || !observer || !getChartDate() || !Array.isArray(EPHEMERIDEN_CHART_CONSTELLATIONS)) {
|
|
return;
|
|
}
|
|
|
|
for (const constellation of EPHEMERIDEN_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 = projectPoint(hor.azimuth, hor.altitude, radius, centerX, centerY);
|
|
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 * scale;
|
|
ctx.beginPath();
|
|
ctx.moveTo(points[0].x, points[0].y);
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
ctx.lineTo(points[index].x, points[index].y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = 'rgba(36, 40, 48, 0.34)';
|
|
ctx.lineWidth = 0.95 * scale;
|
|
ctx.beginPath();
|
|
ctx.moveTo(points[0].x + 0.18, points[0].y - 0.12);
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
const jitterX = index % 2 === 0 ? 0.22 : -0.12;
|
|
const jitterY = index % 2 === 0 ? -0.14 : 0.2;
|
|
ctx.lineTo(points[index].x + jitterX, points[index].y + jitterY);
|
|
}
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
|
|
function resizeCanvas() {
|
|
const cssSize = Math.max(320, Math.min(canvas.parentElement.clientWidth, 760));
|
|
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);
|
|
drawChart();
|
|
}
|
|
|
|
function drawCircleLabel(text, x, y, scale) {
|
|
ctx.save();
|
|
ctx.font = `${Math.round(12 * scale)}px Space Mono, monospace`;
|
|
ctx.fillStyle = 'rgba(232, 239, 255, 0.76)';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, x, y);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawChart(overrideSize) {
|
|
const width = overrideSize ?? canvas.clientWidth;
|
|
const height = overrideSize ?? canvas.clientHeight;
|
|
const scale = width / (canvas.clientWidth || width);
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
const radius = Math.min(width, height) * 0.43;
|
|
const selectedRow = getSelectedRow();
|
|
resetLabelBoxes();
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
ctx.fillStyle = '#06080f';
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
const background = ctx.createRadialGradient(centerX, centerY, radius * 0.05, centerX, centerY, radius);
|
|
background.addColorStop(0, 'rgba(8, 12, 22, 0.98)');
|
|
background.addColorStop(0.68, 'rgba(5, 10, 18, 0.94)');
|
|
background.addColorStop(1, 'rgba(4, 8, 15, 0.98)');
|
|
ctx.fillStyle = background;
|
|
ctx.beginPath();
|
|
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.strokeStyle = 'rgba(130, 170, 255, 0.18)';
|
|
ctx.lineWidth = scale;
|
|
[0, 30, 60].forEach((altitude) => {
|
|
const ringRadius = radius * ((90 - altitude) / 90);
|
|
ctx.beginPath();
|
|
ctx.arc(centerX, centerY, ringRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
drawCircleLabel(`${altitude}°`, centerX, centerY - ringRadius - 12 * scale, scale);
|
|
});
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(centerX, centerY - radius);
|
|
ctx.lineTo(centerX, centerY + radius);
|
|
ctx.moveTo(centerX - radius, centerY);
|
|
ctx.lineTo(centerX + radius, centerY);
|
|
ctx.strokeStyle = 'rgba(130, 170, 255, 0.14)';
|
|
ctx.stroke();
|
|
|
|
drawCircleLabel('N', centerX, centerY - radius - 20 * scale, scale);
|
|
drawCircleLabel('W', centerX + radius + 20 * scale, centerY, scale);
|
|
drawCircleLabel('S', centerX, centerY + radius + 20 * scale, scale);
|
|
drawCircleLabel('O', centerX - radius - 20 * scale, centerY, scale);
|
|
drawCircleLabel('Zenit', centerX, centerY, scale);
|
|
drawConstellationLines(centerX, centerY, radius, scale);
|
|
drawStars(centerX, centerY, radius, scale);
|
|
|
|
const visiblePoints = EPHEMERIDEN_CHART_ROWS
|
|
.map((row, rowIndex) => {
|
|
const horizon = ephemerisHorizon(row);
|
|
const azimuthDeg = horizon ? Number(horizon.azimuth) : Number(row.azimuth_deg);
|
|
const altitudeDeg = horizon ? Number(horizon.altitude) : Number(row.altitude_deg);
|
|
|
|
return {
|
|
index: rowIndex,
|
|
label: `${row.date_local}`,
|
|
azimuthDeg,
|
|
altitudeDeg,
|
|
point: projectPoint(azimuthDeg, altitudeDeg, radius, centerX, centerY),
|
|
};
|
|
})
|
|
.filter((entry) => entry.point !== null);
|
|
|
|
if (visiblePoints.length === 0) {
|
|
ctx.save();
|
|
ctx.font = '14px Space Mono, monospace';
|
|
ctx.fillStyle = 'rgba(232, 239, 255, 0.72)';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('Für die berechneten Punkte liegt das Objekt unter dem Horizont.', centerX, centerY);
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
ctx.beginPath();
|
|
visiblePoints.forEach((entry, index) => {
|
|
if (index === 0) {
|
|
ctx.moveTo(entry.point.x, entry.point.y);
|
|
} else {
|
|
ctx.lineTo(entry.point.x, entry.point.y);
|
|
}
|
|
});
|
|
ctx.strokeStyle = 'rgba(246, 206, 97, 0.92)';
|
|
ctx.lineWidth = 2.6 * scale;
|
|
ctx.shadowColor = 'rgba(246, 206, 97, 0.32)';
|
|
ctx.shadowBlur = 10 * scale;
|
|
ctx.stroke();
|
|
ctx.shadowBlur = 0;
|
|
|
|
const labelStep = getLabelStepValue();
|
|
const labelRotation = getLabelRotationValue();
|
|
resetLabelBoxes();
|
|
visiblePoints.forEach((entry, index) => {
|
|
const isFirst = index === 0;
|
|
const isLast = index === visiblePoints.length - 1;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(entry.point.x, entry.point.y, 3.5 * scale, 0, Math.PI * 2);
|
|
ctx.fillStyle = 'rgba(255, 236, 190, 0.96)';
|
|
ctx.fill();
|
|
|
|
if (isFirst || isLast || (index % labelStep === 0)) {
|
|
drawLabel(entry.label, entry.point.x + 10 * scale, entry.point.y - 8 * scale, {
|
|
font: `${Math.round(11 * scale)}px Space Mono, monospace`,
|
|
color: 'rgba(232, 239, 255, 0.88)',
|
|
align: 'left',
|
|
baseline: 'bottom',
|
|
padding: 4 * scale,
|
|
rotationDeg: labelRotation,
|
|
backgroundColor: 'rgba(7, 10, 18, 0.82)',
|
|
force: true,
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
if (logoImage.complete && logoImage.naturalWidth > 0) {
|
|
const logoH = Math.round(width * 0.07);
|
|
const logoW = Math.round(logoH * logoImage.naturalWidth / logoImage.naturalHeight);
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.82;
|
|
ctx.drawImage(logoImage, width - logoW - 14, height - logoH - 14, logoW, logoH);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
toggleButton.addEventListener('click', () => {
|
|
const willOpen = chartCard.hidden;
|
|
chartCard.hidden = !willOpen;
|
|
toggleButton.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
|
toggleButton.textContent = willOpen ? 'Karte ausblenden' : 'Karte anzeigen';
|
|
|
|
if (willOpen) {
|
|
resizeCanvas();
|
|
chartCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
});
|
|
|
|
window.addEventListener('resize', () => {
|
|
if (!chartCard.hidden) {
|
|
resizeCanvas();
|
|
}
|
|
});
|
|
|
|
pointSelect.addEventListener('change', () => {
|
|
syncChartTimeFromSelectedRow();
|
|
});
|
|
|
|
applyButton.addEventListener('click', () => {
|
|
if (!chartCard.hidden) {
|
|
drawChart();
|
|
}
|
|
});
|
|
|
|
const exportButton = document.getElementById('ephemeridenChartExport');
|
|
if (exportButton) {
|
|
exportButton.addEventListener('click', () => {
|
|
const exportSize = 2000;
|
|
const sw = canvas.width;
|
|
const sh = canvas.height;
|
|
|
|
canvas.width = exportSize;
|
|
canvas.height = exportSize;
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
drawChart(exportSize);
|
|
|
|
const link = document.createElement('a');
|
|
link.download = 'ephemeriden.png';
|
|
link.href = canvas.toDataURL('image/png');
|
|
link.click();
|
|
|
|
canvas.width = sw;
|
|
canvas.height = sh;
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
|
ctx.scale(ratio, ratio);
|
|
drawChart();
|
|
});
|
|
}
|
|
|
|
syncChartTimeFromSelectedRow();
|
|
})();
|
|
</script>
|
|
<?php endif; ?>
|
|
|
|
<?php require __DIR__ . '/footer.php'; ?>
|