diff --git a/config/filter_colors.json b/config/filter_colors.json new file mode 100644 index 0000000..32bc51b --- /dev/null +++ b/config/filter_colors.json @@ -0,0 +1,67 @@ +{ + "U": { + "wavelength_nm": 366.3, + "color": "#1B2A6B", + "description": "Johnson U, Ultraviolett, ca. 366,3 nm" + }, + "B": { + "wavelength_nm": 436.1, + "color": "#1D00FF", + "description": "Johnson B, Blau, ca. 436,1 nm" + }, + "SG": { + "wavelength_nm": 477.0, + "color": "#00C8FF", + "description": "Sloan g, ca. 477,0 nm" + }, + "TB": { + "wavelength_nm": 440.0, + "color": "#0000FF", + "description": "DSLR/OSC Blaukanal, ca. 440,0 nm" + }, + "V": { + "wavelength_nm": 544.8, + "color": "#92FF00", + "description": "Johnson V, visuell/gruen, ca. 544,8 nm" + }, + "TG": { + "wavelength_nm": 544.8, + "color": "#92FF00", + "description": "DSLR/OSC Gruenkanal, ca. 544,8 nm" + }, + "CV": { + "wavelength_nm": 544.8, + "color": "#92FF00", + "description": "Clear auf V referenziert, ca. 544,8 nm" + }, + "SR": { + "wavelength_nm": 623.1, + "color": "#FF6B00", + "description": "Sloan r, ca. 623,1 nm" + }, + "R": { + "wavelength_nm": 640.7, + "color": "#FF1D00", + "description": "Cousins R, Rot, ca. 640,7 nm" + }, + "TR": { + "wavelength_nm": 640.7, + "color": "#FF1D00", + "description": "DSLR/OSC Rotkanal, ca. 640,7 nm" + }, + "SI": { + "wavelength_nm": 762.5, + "color": "#870000", + "description": "Sloan i, ca. 762,5 nm" + }, + "I": { + "wavelength_nm": 798.0, + "color": "#7A0018", + "description": "Cousins I, nahes Infrarot, ca. 798,0 nm" + }, + "SZ": { + "wavelength_nm": 909.7, + "color": "#7A0018", + "description": "Sloan z, ca. 909,7 nm" + } +} diff --git a/public/py/__pycache__/filter_colors.cpython-312.pyc b/public/py/__pycache__/filter_colors.cpython-312.pyc new file mode 100644 index 0000000..0ae38b0 Binary files /dev/null and b/public/py/__pycache__/filter_colors.cpython-312.pyc differ diff --git a/public/py/filter_colors.py b/public/py/filter_colors.py new file mode 100644 index 0000000..64b5645 --- /dev/null +++ b/public/py/filter_colors.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +FILTER_WAVELENGTHS_NM: dict[str, float] = { + "U": 366.3, + "B": 436.1, + "SG": 477.0, + "TB": 440.0, + "V": 544.8, + "TG": 544.8, + "CV": 544.8, + "SR": 623.1, + "R": 640.7, + "TR": 640.7, + "SI": 762.5, + "I": 798.0, + "SZ": 909.7, +} + + +FILTER_DESCRIPTIONS: dict[str, str] = { + "U": "Johnson U, Ultraviolett, ca. 366,3 nm", + "B": "Johnson B, Blau, ca. 436,1 nm", + "SG": "Sloan g, ca. 477,0 nm", + "TB": "DSLR/OSC Blaukanal, ca. 440,0 nm", + "V": "Johnson V, visuell/gruen, ca. 544,8 nm", + "TG": "DSLR/OSC Gruenkanal, ca. 544,8 nm", + "CV": "Clear auf V referenziert, ca. 544,8 nm", + "SR": "Sloan r, ca. 623,1 nm", + "R": "Cousins R, Rot, ca. 640,7 nm", + "TR": "DSLR/OSC Rotkanal, ca. 640,7 nm", + "SI": "Sloan i, ca. 762,5 nm", + "I": "Cousins I, nahes Infrarot, ca. 798,0 nm", + "SZ": "Sloan z, ca. 909,7 nm", +} + + +def wavelength_to_hex_color(wavelength_nm: float) -> str: + if wavelength_nm < 380: + return "#1B2A6B" + + if wavelength_nm > 780: + return "#7A0018" + + red = 0.0 + green = 0.0 + blue = 0.0 + + if wavelength_nm < 440: + red = -((wavelength_nm - 440) / (440 - 380)) + blue = 1.0 + elif wavelength_nm < 490: + green = (wavelength_nm - 440) / (490 - 440) + blue = 1.0 + elif wavelength_nm < 510: + green = 1.0 + blue = -((wavelength_nm - 510) / (510 - 490)) + elif wavelength_nm < 580: + red = (wavelength_nm - 510) / (580 - 510) + green = 1.0 + elif wavelength_nm < 645: + red = 1.0 + green = -((wavelength_nm - 645) / (645 - 580)) + else: + red = 1.0 + + factor = 1.0 + if wavelength_nm < 420: + factor = 0.3 + 0.7 * ((wavelength_nm - 380) / (420 - 380)) + elif wavelength_nm > 700: + factor = 0.3 + 0.7 * ((780 - wavelength_nm) / (780 - 700)) + + gamma = 0.8 + + def convert(channel: float) -> int: + if channel <= 0.0: + return 0 + return round(255 * pow(channel * factor, gamma)) + + return "#{:02X}{:02X}{:02X}".format(convert(red), convert(green), convert(blue)) + + +def build_filter_metadata() -> dict[str, dict[str, object]]: + metadata: dict[str, dict[str, object]] = {} + for filter_code, wavelength_nm in FILTER_WAVELENGTHS_NM.items(): + metadata[filter_code] = { + "wavelength_nm": wavelength_nm, + "color": wavelength_to_hex_color(wavelength_nm), + "description": FILTER_DESCRIPTIONS.get(filter_code, filter_code), + } + return metadata + + +def main() -> int: + parser = argparse.ArgumentParser(description="Erzeugt Filter-Metadaten mit Wellenlaengen und RGB-Farben.") + parser.add_argument("--output", help="Pfad zur JSON-Ausgabedatei.") + args = parser.parse_args() + + payload = build_filter_metadata() + json_text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + + if args.output: + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json_text, encoding="utf-8") + else: + print(json_text, end="") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/public/tcrb_lightcurve.php b/public/tcrb_lightcurve.php index 41056fe..5566630 100644 --- a/public/tcrb_lightcurve.php +++ b/public/tcrb_lightcurve.php @@ -108,8 +108,9 @@ $summary = [ 'max_mag' => null, ]; -if (empty($errors) && !empty($selectedFilters)) { - $placeholders = implode(',', array_fill(0, count($selectedFilters), '?')); +if (empty($errors) && !empty($availableFilters)) { + $queryFilters = array_column($availableFilters, 'code'); + $placeholders = implode(',', array_fill(0, count($queryFilters), '?')); $stmt = $pdo->prepare(" SELECT `observed_at_utc`, @@ -123,7 +124,7 @@ if (empty($errors) && !empty($selectedFilters)) { WHERE `filter_code` IN ({$placeholders}) ORDER BY `observed_at_utc` ASC, `jd` ASC "); - $stmt->execute($selectedFilters); + $stmt->execute($queryFilters); $rows = $stmt->fetchAll(); foreach ($rows as $row) { @@ -155,23 +156,33 @@ if (empty($errors) && empty($observations)) { $errors[] = 'Fuer die ausgewaehlten Filter wurden keine Daten gefunden.'; } -$filterPalette = [ - 'U' => '#4c6ef5', - 'B' => '#1c7ed6', - 'V' => '#2b8a3e', - 'R' => '#c92a2a', - 'I' => '#862e9c', - 'CV' => '#495057', - 'TG' => '#0b7285', - 'TB' => '#364fc7', - 'TR' => '#d9480f', -]; +$filterMetadata = []; +$filterMetadataPath = __DIR__ . '/../config/filter_colors.json'; +if (is_file($filterMetadataPath)) { + $filterMetadataRaw = file_get_contents($filterMetadataPath); + $filterMetadataDecoded = is_string($filterMetadataRaw) ? json_decode($filterMetadataRaw, true) : null; + if (is_array($filterMetadataDecoded)) { + $filterMetadata = $filterMetadataDecoded; + } +} + +$filterPalette = []; +$filterDescriptions = []; +foreach ($filterMetadata as $filterCode => $filterConfig) { + if (!is_array($filterConfig)) { + continue; + } + + $filterPalette[$filterCode] = (string) ($filterConfig['color'] ?? ''); + $filterDescriptions[$filterCode] = (string) ($filterConfig['description'] ?? $filterCode); +} $chartDatasets = []; -foreach ($selectedFilters as $selectedFilter) { - $chartDatasets[$selectedFilter] = [ - 'filter' => $selectedFilter, - 'color' => $filterPalette[$selectedFilter] ?? sprintf('#%06X', crc32($selectedFilter) & 0xFFFFFF), +foreach ($availableFilters as $availableFilter) { + $filterCode = $availableFilter['code']; + $chartDatasets[$filterCode] = [ + 'filter' => $filterCode, + 'color' => $filterPalette[$filterCode] ?? sprintf('#%06X', crc32($filterCode) & 0xFFFFFF), 'points' => [], ]; } @@ -247,74 +258,10 @@ foreach ($chartDatasets as $dataset) { ]; } -$meanSourcePoints = []; -foreach ($chartDatasets as $dataset) { - foreach ($dataset['points'] as $point) { - $meanSourcePoints[] = $point; - } -} - -usort($meanSourcePoints, static function (array $a, array $b): int { - return strcmp((string) $a['observed_at_utc'], (string) $b['observed_at_utc']); -}); - -if (count($meanSourcePoints) >= 3) { - $windowSize = min(11, count($meanSourcePoints)); - if ($windowSize % 2 === 0) { - $windowSize--; - } - - $halfWindow = intdiv($windowSize, 2); - $meanX = []; - $meanY = []; - $meanCustomData = []; - - foreach ($meanSourcePoints as $index => $point) { - $start = max(0, $index - $halfWindow); - $end = min(count($meanSourcePoints) - 1, $index + $halfWindow); - $sum = 0.0; - $count = 0; - - for ($i = $start; $i <= $end; $i++) { - $sum += (float) $meanSourcePoints[$i]['magnitude']; - $count++; - } - - if ($count === 0) { - continue; - } - - $meanValue = $sum / $count; - $meanX[] = (string) $point['observed_at_utc']; - $meanY[] = $meanValue; - $meanCustomData[] = [$count]; - } - - if (!empty($meanX)) { - $plotlyTraces[] = [ - 'type' => 'scatter', - 'mode' => 'lines', - 'name' => 'Mittelkurve', - 'x' => $meanX, - 'y' => $meanY, - 'line' => [ - 'color' => '#f4d35e', - 'width' => 3, - 'dash' => 'solid', - 'shape' => 'spline', - 'smoothing' => 0.7, - ], - 'customdata' => $meanCustomData, - 'hovertemplate' => 'Mittelkurve
' - . 'Magnitude %{y:.3f}
' - . 'UTC %{x}
' - . 'Fenster %{customdata[0]} Punkte', - ]; - } -} - $plotlyPayload = [ 'traces' => $plotlyTraces, + 'datasets' => $chartDatasets, + 'selectedFilters' => $selectedFilters, 'summary' => $summary, ]; ?> @@ -342,6 +289,17 @@ $plotlyPayload = [ gap: 0.55rem; } +.tcrb-filter-actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-bottom: 0.9rem; +} + +.tcrb-filter-actions .btn { + flex: 1 1 auto; +} + .tcrb-filter-option { display: flex; align-items: center; @@ -460,15 +418,20 @@ $plotlyPayload = [

Filter

-
+ +
+ + +
-
-
@@ -509,7 +471,7 @@ $plotlyPayload = [
format('d.m.Y')) ?> - Naechster moeglicher Ausbruch
(in Tagen)
+ Nächster möglicher Ausbruch
(in Tagen)
@@ -519,7 +481,8 @@ $plotlyPayload = [
-
+ +
@@ -536,11 +499,21 @@ $plotlyPayload = [ (function () { const payload = ; const plotEl = document.getElementById('tcrbPlot'); + const filterForm = document.getElementById('tcrbFilterForm'); + const selectAllEl = document.getElementById('tcrbSelectAll'); + const selectNoneEl = document.getElementById('tcrbSelectNone'); + const filterCheckboxes = filterForm ? Array.from(filterForm.querySelectorAll('input[name="filters[]"]')) : []; - if (!plotEl || typeof Plotly === 'undefined' || !payload || !Array.isArray(payload.traces) || payload.traces.length === 0) { + if (!plotEl || typeof Plotly === 'undefined' || !payload || !Array.isArray(payload.datasets) || payload.datasets.length === 0) { return; } + if (filterForm) { + filterForm.addEventListener('submit', function (event) { + event.preventDefault(); + }); + } + const layout = { margin: { t: 20, r: 24, b: 56, l: 72 }, paper_bgcolor: 'rgba(0,0,0,0)', @@ -556,7 +529,7 @@ $plotlyPayload = [ zeroline: false }, yaxis: { - title: { text: 'Magnitude (kleiner = heller)' }, + title: { text: 'Magnitude' }, autorange: 'reversed', gridcolor: 'rgba(255,255,255,0.12)', linecolor: '#f8fafc', @@ -574,7 +547,111 @@ $plotlyPayload = [ modeBarButtonsToRemove: ['select2d', 'lasso2d'] }; - Plotly.newPlot(plotEl, payload.traces, layout, config); + function buildDatasetTrace(dataset) { + const errorValues = []; + let hasErrors = false; + + dataset.points.forEach(function (point) { + const errorValue = point.magnitude_error !== null ? Number(point.magnitude_error) : 0; + errorValues.push(errorValue); + if (errorValue > 0) { + hasErrors = true; + } + }); + + return { + type: 'scatter', + mode: 'lines+markers', + name: dataset.filter, + x: dataset.points.map(function (point) { return point.observed_at_utc; }), + y: dataset.points.map(function (point) { return Number(point.magnitude); }), + line: { + color: dataset.color, + width: 2, + shape: 'spline', + smoothing: 0.65 + }, + marker: { + color: dataset.color, + size: 3, + line: { + color: '#0f172a', + width: 1 + } + }, + error_y: { + type: 'data', + array: errorValues, + visible: hasErrors, + thickness: 1.1, + width: 3, + color: dataset.color + }, + customdata: dataset.points.map(function (point) { + return [ + point.jd, + point.observer_code !== '' ? point.observer_code : '-', + point.magnitude_error !== null ? Number(point.magnitude_error).toFixed(3) : '-' + ]; + }), + hovertemplate: 'Filter %{fullData.name}
' + + 'Magnitude %{y:.3f}
' + + 'Fehler %{customdata[2]}
' + + 'UTC %{x}
' + + 'JD %{customdata[0]}
' + + 'Observer %{customdata[1]}' + }; + } + + function getActiveFilterCodes() { + return filterCheckboxes + .filter(function (checkbox) { return checkbox.checked; }) + .map(function (checkbox) { return checkbox.value; }); + } + + function renderPlot() { + const activeFilterCodes = getActiveFilterCodes(); + const activeDatasets = payload.datasets.filter(function (dataset) { + return activeFilterCodes.includes(dataset.filter); + }); + const traces = activeDatasets.map(buildDatasetTrace); + + if (traces.length === 0) { + Plotly.react(plotEl, [], layout, config); + return; + } + + Plotly.react(plotEl, traces, layout, config); + } + + const initiallySelected = Array.isArray(payload.selectedFilters) && payload.selectedFilters.length > 0 + ? payload.selectedFilters + : ['V']; + + filterCheckboxes.forEach(function (checkbox) { + checkbox.checked = initiallySelected.includes(checkbox.value); + checkbox.addEventListener('change', renderPlot); + }); + + if (selectAllEl) { + selectAllEl.addEventListener('click', function () { + filterCheckboxes.forEach(function (checkbox) { + checkbox.checked = true; + }); + renderPlot(); + }); + } + + if (selectNoneEl) { + selectNoneEl.addEventListener('click', function () { + filterCheckboxes.forEach(function (checkbox) { + checkbox.checked = false; + }); + renderPlot(); + }); + } + + renderPlot(); })(); diff --git a/scripts/telegram/telegram_cron.php b/scripts/telegram/telegram_cron.php index 4f12c30..c0eddbb 100644 --- a/scripts/telegram/telegram_cron.php +++ b/scripts/telegram/telegram_cron.php @@ -23,6 +23,7 @@ $stateFile = __DIR__ . DIRECTORY_SEPARATOR . 'telegram_cron_state.json'; $dbConfig = require $rootDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'database.php'; $telegramConfig = require $rootDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'telegram.php'; require_once $rootDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'telegram_message_footer.php'; +$filterColorConfigPath = $rootDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'filter_colors.json'; $dsn = sprintf( 'mysql:host=%s;port=%d;dbname=%s;charset=%s', @@ -547,6 +548,12 @@ function formatGermanDateTime(?string $value): string function buildTcrbVChartSvg(array $observations, array $latestObservation): string { + $filterColors = loadFilterColors(); + $vColor = (string) ($filterColors['V'] ?? '#92FF00'); + $vStrokeColor = darkenHexColor($vColor, 0.35); + $vHighlightColor = lightenHexColor($vColor, 0.35); + $vHighlightStrokeColor = lightenHexColor($vColor, 0.6); + $width = 1200; $height = 700; $paddingLeft = 90; @@ -639,14 +646,15 @@ function buildTcrbVChartSvg(array $observations, array $latestObservation): stri $yTop = $scaleY($magnitude - $error); $yBottom = $scaleY($magnitude + $error); $errorBars[] = sprintf( - '' . - '' . - '', + '' . + '' . + '', $x, $yTop, $yBottom, $x - 7, - $x + 7 + $x + 7, + htmlspecialchars($vHighlightColor, ENT_QUOTES, 'UTF-8') ); } @@ -658,8 +666,8 @@ function buildTcrbVChartSvg(array $observations, array $latestObservation): stri $x, $y, $isLatest ? 7.0 : 4.2, - $isLatest ? '#f4d35e' : '#7bd389', - $isLatest ? '#fff3bf' : '#d9f99d', + $isLatest ? $vHighlightColor : $vColor, + $isLatest ? $vHighlightStrokeColor : $vHighlightColor, $isLatest ? 2.6 : 1.4 ); } @@ -691,13 +699,13 @@ function buildTcrbVChartSvg(array $observations, array $latestObservation): stri letzte 2 Monate %s %s - Aktuell: %s%s + Aktuell: %s%s %s UTC %s %s - + %s SVG, @@ -720,6 +728,7 @@ SVG, $height - 28, htmlspecialchars($lastLabel, ENT_QUOTES, 'UTF-8'), $width - $paddingRight, + htmlspecialchars($vHighlightColor, ENT_QUOTES, 'UTF-8'), htmlspecialchars($latestMagnitude, ENT_QUOTES, 'UTF-8'), htmlspecialchars($latestError, ENT_QUOTES, 'UTF-8'), $width - $paddingRight, @@ -735,10 +744,75 @@ SVG, $plotBottomY, $errorBarsSvg, $pathSvg, + htmlspecialchars($vStrokeColor, ENT_QUOTES, 'UTF-8'), $pointsSvgMarkup ); } +function loadFilterColors(): array +{ + static $colors = null; + global $filterColorConfigPath; + + if (is_array($colors)) { + return $colors; + } + + $colors = []; + if (!is_string($filterColorConfigPath) || !is_file($filterColorConfigPath)) { + return $colors; + } + + $raw = file_get_contents($filterColorConfigPath); + $decoded = is_string($raw) ? json_decode($raw, true) : null; + if (!is_array($decoded)) { + return $colors; + } + + foreach ($decoded as $filterCode => $config) { + if (is_array($config) && !empty($config['color'])) { + $colors[(string) $filterCode] = (string) $config['color']; + } + } + + return $colors; +} + +function darkenHexColor(string $hexColor, float $amount): string +{ + return adjustHexColor($hexColor, -abs($amount)); +} + +function lightenHexColor(string $hexColor, float $amount): string +{ + return adjustHexColor($hexColor, abs($amount)); +} + +function adjustHexColor(string $hexColor, float $amount): string +{ + $hex = ltrim(trim($hexColor), '#'); + if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) { + return '#92FF00'; + } + + $channels = [ + hexdec(substr($hex, 0, 2)), + hexdec(substr($hex, 2, 2)), + hexdec(substr($hex, 4, 2)), + ]; + + foreach ($channels as $index => $value) { + if ($amount >= 0) { + $channels[$index] = (int) round($value + ((255 - $value) * $amount)); + } else { + $channels[$index] = (int) round($value * (1 + $amount)); + } + $channels[$index] = max(0, min(255, $channels[$index])); + } + + return sprintf('#%02X%02X%02X', $channels[0], $channels[1], $channels[2]); +} + function buildTcrbObservationSignature(array $observation): string { return implode('|', [