1747 lines
55 KiB
PHP
1747 lines
55 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.6;
|
|
$starLabelMagnitudeLimit = 2.0;
|
|
$messierDatabaseMagnitudeLimit = 4.0;
|
|
$messierDisplayMagnitudeLimit = 4.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 = [];
|
|
$starDataError = null;
|
|
|
|
try {
|
|
$dbConfig = require __DIR__ . '/../config/database.php';
|
|
$dsn = sprintf(
|
|
'mysql:host=%s;dbname=%s;charset=%s',
|
|
$dbConfig['host'],
|
|
$dbConfig['dbname'],
|
|
$dbConfig['charset'] ?? 'utf8mb4'
|
|
);
|
|
$pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass'], [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
]);
|
|
|
|
$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'));
|
|
|
|
$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);
|
|
$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>
|
|
</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_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 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 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);
|
|
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 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 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);
|
|
drawStars(width, height);
|
|
statusEl.textContent = 'Südhimmel für ' + MONTH_CHART_CONFIG.localTimeLabel;
|
|
}
|
|
|
|
resizeCanvas();
|
|
window.addEventListener('resize', resizeCanvas);
|
|
})();
|
|
</script>
|
|
|
|
<?php require __DIR__ . '/footer.php'; ?>
|