506 lines
18 KiB
PHP
506 lines
18 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
ini_set('display_errors', '1');
|
|
error_reporting(E_ALL);
|
|
session_start();
|
|
|
|
require_once __DIR__ . '/auth_helpers.php';
|
|
require_once __DIR__ . '/python_api.php';
|
|
applyLocalDevLoginBypass();
|
|
|
|
$loggedIn = isset($_SESSION['user_id']);
|
|
$pageTitle = 'Planetensichtbarkeit - AstroTools';
|
|
$bodyClass = 'planetvis-page';
|
|
|
|
if (!isset($currentLocation) && $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']]);
|
|
$currentLocation = $stmtLoc->fetch() ?: null;
|
|
} catch (Throwable $e) {
|
|
$currentLocation = null;
|
|
}
|
|
}
|
|
|
|
$pageLocation = null;
|
|
if (!empty($currentLocation) && is_array($currentLocation)) {
|
|
$pageLocation = [
|
|
'name' => (string) ($currentLocation['name'] ?? ''),
|
|
'latitude' => isset($currentLocation['latitude']) ? (float) $currentLocation['latitude'] : null,
|
|
'longitude' => isset($currentLocation['longitude']) ? (float) $currentLocation['longitude'] : null,
|
|
'elevation' => isset($currentLocation['elevation']) && $currentLocation['elevation'] !== null ? (float) $currentLocation['elevation'] : 0.0,
|
|
'timezone' => (string) ($currentLocation['timezone'] ?? 'Europe/Berlin'),
|
|
];
|
|
}
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['ajax'] ?? '') === '1') {
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
if ($pageLocation === null || $pageLocation['latitude'] === null || $pageLocation['longitude'] === null) {
|
|
echo json_encode([
|
|
'ok' => false,
|
|
'error' => 'Kein gueltiger Standard-Standort verfuegbar.',
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
|
|
$dateText = trim((string) ($_POST['date'] ?? ''));
|
|
$date = DateTimeImmutable::createFromFormat('Y-m-d', $dateText);
|
|
$dateErrors = DateTimeImmutable::getLastErrors();
|
|
if ($dateErrors === false) {
|
|
$dateErrors = ['warning_count' => 0, 'error_count' => 0];
|
|
}
|
|
if ($date === false || ($dateErrors['warning_count'] ?? 0) > 0 || ($dateErrors['error_count'] ?? 0) > 0) {
|
|
echo json_encode([
|
|
'ok' => false,
|
|
'error' => 'Datum muss im Format YYYY-MM-DD uebergeben werden.',
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
|
|
$pythonResult = runPythonApi('planet_visibility_chart', [
|
|
(string) $pageLocation['latitude'],
|
|
(string) $pageLocation['longitude'],
|
|
(string) $pageLocation['elevation'],
|
|
$dateText,
|
|
(string) $pageLocation['timezone'],
|
|
]);
|
|
|
|
if (!$pythonResult['ok']) {
|
|
echo json_encode([
|
|
'ok' => false,
|
|
'error' => (string) ($pythonResult['error'] ?? 'Python-Fehler'),
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
|
|
echo json_encode($pythonResult['data'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
?>
|
|
<?php require __DIR__ . '/header.php'; ?>
|
|
|
|
<div class="planetvis-tool">
|
|
<?php if ($pageLocation === null || $pageLocation['latitude'] === null || $pageLocation['longitude'] === null): ?>
|
|
<section class="card planetvis-card">
|
|
<h2>Kein Standard-Standort gesetzt</h2>
|
|
<p class="hint">
|
|
Diese Seite verwendet den im Header geladenen Standard-Standort aus der Datenbank.
|
|
Bitte waehle in den <a href="settings.php">Einstellungen</a> einen Standard-Standort aus.
|
|
</p>
|
|
</section>
|
|
<?php else: ?>
|
|
<section class="card planetvis-card">
|
|
<p class="planetvis-intro">
|
|
Sichtbarkeit der Planeten und des Mondes über 24 Stunden für deinen Standard-Standort aus der Datenbank.
|
|
</p>
|
|
|
|
<div class="date-section">
|
|
<span class="date-lbl">Datum:</span>
|
|
<button class="btn btn-secondary btn-sm" id="btnPrevMonth">-1 Monat</button>
|
|
<button class="btn btn-secondary btn-sm" id="btnPrevDay">-1 Tag</button>
|
|
<input type="date" id="datePicker">
|
|
<button class="btn btn-secondary btn-sm" id="btnNextDay">+1 Tag</button>
|
|
<button class="btn btn-secondary btn-sm" id="btnNextMonth">+1 Monat</button>
|
|
<button class="btn btn-secondary" id="btnToday">Heute</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card planetvis-card">
|
|
<div class="time-axis-wrap" id="timeAxis"></div>
|
|
|
|
<div class="legend">
|
|
<div class="legend-item">
|
|
<div class="leg-bar"></div>
|
|
<span>Tag / Nacht</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="leg-line"></div>
|
|
<span>Über Horizont</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="leg-line leg-line-best"></div>
|
|
<span>Beste Sichtbarkeit</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="leg-now"></div>
|
|
<span>Aktuelle Uhrzeit</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="loadOverlay" class="planetvis-overlay">
|
|
<div class="spin-ring"></div>
|
|
Berechne Positionen...
|
|
</div>
|
|
<div id="chartRows"></div>
|
|
</section>
|
|
|
|
<section>
|
|
<div id="infoCards" class="info-row planetvis-info-row"></div>
|
|
</section>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<?php if ($pageLocation !== null && $pageLocation['latitude'] !== null && $pageLocation['longitude'] !== null): ?>
|
|
<div class="tooltip" id="tooltip">
|
|
<div class="tt-title" id="tt-title"></div>
|
|
<div id="tt-body"></div>
|
|
</div>
|
|
|
|
<script>
|
|
'use strict';
|
|
|
|
const SITE_LOCATION = <?= json_encode($pageLocation, JSON_UNESCAPED_SLASHES) ?>;
|
|
const LAT = Number(SITE_LOCATION.latitude);
|
|
const LNG = Number(SITE_LOCATION.longitude);
|
|
const ELEV = Number(SITE_LOCATION.elevation);
|
|
|
|
const hToP = h => Math.min(100, Math.max(0, h / 24 * 100));
|
|
const fmt2 = n => String(n).padStart(2, '0');
|
|
const fmtDate = d => `${fmt2(d.getDate())}.${fmt2(d.getMonth() + 1)}.${d.getFullYear()}`;
|
|
const getNowH = () => {
|
|
const d = new Date();
|
|
return d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600;
|
|
};
|
|
|
|
function hStr(h) {
|
|
if (h === null || h === undefined || Number.isNaN(h)) {
|
|
return '--:--';
|
|
}
|
|
const t = ((h % 24) + 24) % 24;
|
|
return `${fmt2(Math.floor(t))}:${fmt2(Math.floor((t - Math.floor(t)) * 60))}`;
|
|
}
|
|
|
|
function buildGradient(tw) {
|
|
const p = hourValue => hourValue === null || hourValue === undefined
|
|
? null
|
|
: Math.max(0, Math.min(100, hourValue / 24 * 100)).toFixed(2);
|
|
const stops = [];
|
|
const add = (pos, col) => {
|
|
if (pos !== null) {
|
|
stops.push({ pos: parseFloat(pos), col });
|
|
}
|
|
};
|
|
|
|
const sr = p(tw.sunRiseH);
|
|
const ss = p(tw.sunSetH);
|
|
const cd = p(tw.civDawnH);
|
|
const cu = p(tw.civDuskH);
|
|
const nd = p(tw.nautDawnH);
|
|
const nu = p(tw.nautDuskH);
|
|
const ad = p(tw.astroDawnH);
|
|
const au = p(tw.astroDuskH);
|
|
|
|
add('0', '#010108');
|
|
if (ad) add(Math.max(0, parseFloat(ad) - 2).toFixed(2), '#010108');
|
|
if (ad) add(ad, '#06060f');
|
|
if (nd) add(nd, '#0d1430');
|
|
if (cd) add(cd, '#1e1a48');
|
|
if (cd) add((parseFloat(cd) + 0.4).toFixed(2), '#2e1858');
|
|
if (sr) {
|
|
add(sr, '#cc7038');
|
|
add((parseFloat(sr) + 0.45).toFixed(2), '#f0a860');
|
|
add((parseFloat(sr) + 2.5).toFixed(2), '#b8d0ea');
|
|
}
|
|
if (sr && ss) {
|
|
const noon = ((parseFloat(sr) + parseFloat(ss)) / 2).toFixed(2);
|
|
add(noon, '#e2f4ff');
|
|
}
|
|
if (ss) {
|
|
add((parseFloat(ss) - 2.5).toFixed(2), '#b8d0ea');
|
|
add((parseFloat(ss) - 0.45).toFixed(2), '#f0a860');
|
|
add(ss, '#cc7038');
|
|
}
|
|
if (cu) {
|
|
add((parseFloat(cu) - 0.4).toFixed(2), '#2e1858');
|
|
add(cu, '#1e1a48');
|
|
}
|
|
if (nu) add(nu, '#0d1430');
|
|
if (au) {
|
|
add(au, '#06060f');
|
|
add(Math.min(100, parseFloat(au) + 2).toFixed(2), '#010108');
|
|
}
|
|
add('100', '#010108');
|
|
|
|
stops.sort((a, b) => a.pos - b.pos);
|
|
const uniq = stops.filter((s, i) => i === 0 || stops[i - 1].pos !== s.pos);
|
|
return 'linear-gradient(to right,' + uniq.map(s => `${s.col} ${s.pos}%`).join(',') + ')';
|
|
}
|
|
|
|
const axisEl = document.getElementById('timeAxis');
|
|
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24].forEach(t => {
|
|
const d = document.createElement('div');
|
|
d.className = 'time-tick';
|
|
d.textContent = t === 24 ? '24:00' : `${fmt2(t)}:00`;
|
|
axisEl.appendChild(d);
|
|
});
|
|
|
|
let needleEls = [];
|
|
function updateNeedles() {
|
|
const pct = hToP(getNowH());
|
|
needleEls.forEach(n => {
|
|
n.style.left = `calc(${pct}% - 1px)`;
|
|
});
|
|
}
|
|
window.setInterval(updateNeedles, 15000);
|
|
|
|
function addStripe(bar, from, to, def) {
|
|
if (to - from < 0.05) {
|
|
return;
|
|
}
|
|
const s = document.createElement('div');
|
|
s.className = 'vis-stripe';
|
|
s.style.left = hToP(from) + '%';
|
|
s.style.width = (hToP(to) - hToP(from)) + '%';
|
|
s.style.background = `linear-gradient(to bottom,
|
|
rgba(0,0,0,.25) 0%,
|
|
${def.color}ee 15%,
|
|
${def.color}ff 50%,
|
|
${def.color}ee 85%,
|
|
rgba(0,0,0,.25) 100%)`;
|
|
s.style.boxShadow = `0 0 0 1px rgba(0,0,0,.5), 0 0 10px ${def.glow}`;
|
|
s.style.opacity = '0.85';
|
|
bar.appendChild(s);
|
|
}
|
|
|
|
function addBest(bar, from, to, def) {
|
|
if (to - from < 0.05) {
|
|
return;
|
|
}
|
|
const s = document.createElement('div');
|
|
s.className = 'vis-stripe-best';
|
|
s.style.left = hToP(from) + '%';
|
|
s.style.width = (hToP(to) - hToP(from)) + '%';
|
|
s.style.background = def.color;
|
|
s.style.boxShadow = `0 0 0 1px rgba(0,0,0,.6), 0 0 14px 4px ${def.glow}, 0 0 4px ${def.color}`;
|
|
s.style.opacity = '1';
|
|
bar.appendChild(s);
|
|
}
|
|
|
|
const tooltip = document.getElementById('tooltip');
|
|
const datePicker = document.getElementById('datePicker');
|
|
const btnPrevMonth = document.getElementById('btnPrevMonth');
|
|
const btnPrevDay = document.getElementById('btnPrevDay');
|
|
const btnNextDay = document.getElementById('btnNextDay');
|
|
const btnNextMonth = document.getElementById('btnNextMonth');
|
|
const localDateStr = d => `${d.getFullYear()}-${fmt2(d.getMonth() + 1)}-${fmt2(d.getDate())}`;
|
|
const pickerDate = () => {
|
|
const [y, m, d] = datePicker.value.split('-').map(Number);
|
|
return new Date(y, m - 1, d, 12);
|
|
};
|
|
|
|
function daysInMonth(year, monthIndex) {
|
|
return new Date(year, monthIndex + 1, 0).getDate();
|
|
}
|
|
|
|
function shiftPickerByDays(days) {
|
|
const base = pickerDate();
|
|
base.setDate(base.getDate() + days);
|
|
datePicker.value = localDateStr(base);
|
|
renderChart(base);
|
|
}
|
|
|
|
function shiftPickerByMonths(months) {
|
|
const base = pickerDate();
|
|
const year = base.getFullYear();
|
|
const monthIndex = base.getMonth();
|
|
const day = base.getDate();
|
|
const targetMonthIndex = monthIndex + months;
|
|
const targetYear = year + Math.floor(targetMonthIndex / 12);
|
|
const normalizedMonthIndex = ((targetMonthIndex % 12) + 12) % 12;
|
|
const clampedDay = Math.min(day, daysInMonth(targetYear, normalizedMonthIndex));
|
|
const shifted = new Date(targetYear, normalizedMonthIndex, clampedDay, 12);
|
|
datePicker.value = localDateStr(shifted);
|
|
renderChart(shifted);
|
|
}
|
|
|
|
async function fetchVisibilityData(localDate) {
|
|
const formData = new FormData();
|
|
formData.set('ajax', '1');
|
|
formData.set('date', localDateStr(localDate));
|
|
|
|
const response = await fetch('planetensichtbarkeit.php', {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
});
|
|
|
|
const payload = await response.json();
|
|
if (!response.ok || !payload.ok) {
|
|
throw new Error(payload.error || 'Die Sichtbarkeitsdaten konnten nicht geladen werden.');
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
function renderChartRows(rows, gradient) {
|
|
const container = document.getElementById('chartRows');
|
|
container.innerHTML = '';
|
|
needleEls = [];
|
|
|
|
rows.forEach((def, idx) => {
|
|
if (def.type === 'moon') {
|
|
container.appendChild(Object.assign(document.createElement('div'), { className: 'moon-sep' }));
|
|
}
|
|
|
|
const segs = Array.isArray(def.segs) ? def.segs : [];
|
|
const bestSegs = Array.isArray(def.bestSegs) ? def.bestSegs : [];
|
|
const magnitudeText = def.type === 'planet' && typeof def.magnitude === 'number'
|
|
? `${def.magnitude >= 0 ? '+' : ''}${def.magnitude.toFixed(1)}`
|
|
: '-';
|
|
const metaStr = def.type === 'moon'
|
|
? `${Math.round((def.illum || 0) * 100)}% ${def.waning ? 'abnehmend' : 'zunehmend'}`
|
|
: `Mag ${magnitudeText}`;
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'planet-row';
|
|
row.innerHTML = `
|
|
<div class="planet-label">
|
|
<div class="planet-orb">${def.symbol}</div>
|
|
<div><div class="planet-name">${def.name}</div><div class="planet-meta">${metaStr}</div></div>
|
|
</div>
|
|
`;
|
|
|
|
const orbEl = row.querySelector('.planet-orb');
|
|
orbEl.style.setProperty('--planet-orb-color', def.color);
|
|
orbEl.style.setProperty('--planet-orb-glow', def.glow);
|
|
|
|
const barOuter = document.createElement('div');
|
|
barOuter.className = 'bar-outer';
|
|
const barBg = document.createElement('div');
|
|
barBg.className = 'bar-bg';
|
|
barBg.style.background = gradient;
|
|
barOuter.appendChild(barBg);
|
|
|
|
segs.forEach(s => addStripe(barOuter, s.from, s.to, def));
|
|
bestSegs.forEach(s => addBest(barOuter, s.from, s.to, def));
|
|
|
|
const needle = document.createElement('div');
|
|
needle.className = 'needle';
|
|
needle.innerHTML = `${idx === 0 ? '<div class="needle-lbl">JETZT</div>' : ''}<div class="needle-glow"></div><div class="needle-cap"></div>`;
|
|
barOuter.appendChild(needle);
|
|
needleEls.push(needle);
|
|
|
|
barOuter.addEventListener('mousemove', e => {
|
|
const rect = barOuter.getBoundingClientRect();
|
|
const h = ((e.clientX - rect.left) / rect.width) * 24;
|
|
const above = segs.some(s => h >= s.from && h <= s.to);
|
|
document.getElementById('tt-title').textContent = def.name;
|
|
document.getElementById('tt-body').innerHTML =
|
|
`Uhrzeit: ${hStr(h)}<br>Aufgang: ${hStr(def.actualRiseH)}<br>Untergang: ${hStr(def.actualSetH)}<br>` +
|
|
(def.type === 'moon'
|
|
? `Beleuchtung: ${Math.round((def.illum || 0) * 100)}%<br>`
|
|
: `Magnitude: ${magnitudeText}<br>`) +
|
|
`Status: ${above ? '<span class="tt-up">über Horizont</span>' : '<span class="tt-down">unter Horizont</span>'}`;
|
|
tooltip.style.left = (e.clientX + 16) + 'px';
|
|
tooltip.style.top = (e.clientY - 14) + 'px';
|
|
tooltip.classList.add('vis');
|
|
});
|
|
barOuter.addEventListener('mouseleave', () => tooltip.classList.remove('vis'));
|
|
|
|
row.appendChild(barOuter);
|
|
container.appendChild(row);
|
|
if (idx < rows.length - 1 && rows[idx + 1].type !== 'moon' && def.type !== 'moon') {
|
|
container.appendChild(Object.assign(document.createElement('div'), { className: 'planet-div' }));
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderInfoCards(localDate, data) {
|
|
const infoDiv = document.getElementById('infoCards');
|
|
const tw = data.twilight || {};
|
|
const moon = data.moon || {};
|
|
const offset = Number(data.utc_offset_hours);
|
|
const tzLabel = Number.isFinite(offset)
|
|
? `${data.timezone_abbr} (UTC${offset >= 0 ? '+' : ''}${offset})`
|
|
: (data.timezone_abbr || SITE_LOCATION.timezone);
|
|
const hF = hourValue => hourValue === null || hourValue === undefined ? '-' : `${hStr(hourValue)} Uhr`;
|
|
|
|
infoDiv.innerHTML = `
|
|
<div class="info-card"><strong>Standort</strong><br>${SITE_LOCATION.name}<br>${LAT.toFixed(3)}deg N | ${LNG.toFixed(3)}deg O | ${ELEV} m</div>
|
|
<div class="info-card"><strong>Sonne (${fmtDate(localDate)})</strong><br>Aufgang: ${hF(tw.sunRiseH)}<br>Untergang: ${hF(tw.sunSetH)}</div>
|
|
<div class="info-card"><strong>Dämmerung</strong><br>Bürgerlich: ${hF(tw.civDawnH)} / ${hF(tw.civDuskH)}<br>Nautisch: ${hF(tw.nautDawnH)} / ${hF(tw.nautDuskH)}<br>Astronomisch: ${hF(tw.astroDawnH)} / ${hF(tw.astroDuskH)}</div>
|
|
<div class="info-card"><strong>Mond (${fmtDate(localDate)})</strong><br>${Math.round((moon.illum || 0) * 100)}% ${moon.waning ? 'abnehmend' : 'zunehmend'}<br>Phase: ${typeof moon.phase === 'number' ? moon.phase.toFixed(1) : '--'}deg</div>
|
|
<div class="info-card"><strong>Zeitzone</strong><br>${tzLabel}<br>${SITE_LOCATION.timezone}</div>
|
|
`;
|
|
}
|
|
|
|
async function renderChart(localDate) {
|
|
const loadDiv = document.getElementById('loadOverlay');
|
|
document.getElementById('chartRows').innerHTML = '';
|
|
document.getElementById('infoCards').innerHTML = '';
|
|
needleEls = [];
|
|
loadDiv.innerHTML = '<div class="spin-ring"></div>Berechne Positionen...';
|
|
loadDiv.classList.remove('is-hidden');
|
|
|
|
try {
|
|
const data = await fetchVisibilityData(localDate);
|
|
const gradient = buildGradient(data.twilight || {});
|
|
renderChartRows(Array.isArray(data.rows) ? data.rows : [], gradient);
|
|
renderInfoCards(localDate, data);
|
|
loadDiv.classList.add('is-hidden');
|
|
updateNeedles();
|
|
} catch (error) {
|
|
loadDiv.innerHTML = `<span class="planetvis-error-text">Fehler: ${error.message}</span>`;
|
|
loadDiv.classList.remove('is-hidden');
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
document.getElementById('btnToday').addEventListener('click', () => {
|
|
const t = new Date();
|
|
datePicker.value = localDateStr(t);
|
|
renderChart(new Date(t.getFullYear(), t.getMonth(), t.getDate(), 12));
|
|
});
|
|
|
|
btnPrevDay.addEventListener('click', () => {
|
|
shiftPickerByDays(-1);
|
|
});
|
|
|
|
btnNextDay.addEventListener('click', () => {
|
|
shiftPickerByDays(1);
|
|
});
|
|
|
|
btnPrevMonth.addEventListener('click', () => {
|
|
shiftPickerByMonths(-1);
|
|
});
|
|
|
|
btnNextMonth.addEventListener('click', () => {
|
|
shiftPickerByMonths(1);
|
|
});
|
|
|
|
datePicker.addEventListener('input', () => {
|
|
if (datePicker.value) {
|
|
renderChart(pickerDate());
|
|
}
|
|
});
|
|
|
|
function init() {
|
|
const today = new Date();
|
|
datePicker.value = localDateStr(today);
|
|
renderChart(new Date(today.getFullYear(), today.getMonth(), today.getDate(), 12));
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
<?php endif; ?>
|
|
|
|
<?php require __DIR__ . '/footer.php'; ?>
|