Files
skyview.astronomiemuseum.de/public/js/observation_suggestions.js
T
2026-09-15 15:58:51 +02:00

228 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
(() => {
const form = document.getElementById('observation-form');
const submit = document.getElementById('observation-submit');
const status = document.getElementById('observation-status');
const error = document.getElementById('observation-error');
const results = document.getElementById('observation-results');
const tbody = document.getElementById('observation-rows');
const previous = document.getElementById('observation-previous');
const next = document.getElementById('observation-next');
const pageSize = 50;
let data = null;
let displayedRows = [];
let groupLimit = 30;
let page = 0;
let formatter = null;
let sortCriteria = [{key: 'altitude', direction: -1}];
const collator = new Intl.Collator('de-DE', {numeric: true, sensitivity: 'base'});
const sortButtons = [...document.querySelectorAll('.observation-sort')];
const sortLabels = Object.fromEntries(sortButtons.map(button => [button.dataset.sort, button.firstChild.textContent.trim()]));
const sortValue = {
name: row => row.name,
type: row => row.type,
magnitude: row => row.best.magnitude,
size: row => row.size_arcsec,
window: row => row.intervals.length ? Date.parse(row.intervals[0].start) : null,
time: row => Date.parse(row.best.time),
altitude: row => row.best.altitude,
azimuth: row => row.best.azimuth,
};
function selectRows() {
if (groupLimit === null) {
displayedRows = [...data.rows];
} else {
const counts = new Map();
displayedRows = data.rows.filter(row => Number.isFinite(row.best.magnitude))
.sort((a, b) => a.best.magnitude - b.best.magnitude || b.best.altitude - a.best.altitude
|| collator.compare(a.name, b.name) || collator.compare(String(a.id), String(b.id)))
.filter(row => {
const count = counts.get(row.group) || 0;
if (count >= groupLimit) return false;
counts.set(row.group, count + 1);
return true;
});
}
document.getElementById('observation-limit-status').textContent = groupLimit === null
? `Alle ${data.rows.length} Treffer werden angezeigt.`
: `${displayedRows.length} von ${data.rows.length} Treffern: maximal ${groupLimit} hellste je Objektgruppe. Die Tabellensortierung gilt für diese Auswahl.`;
page = 0;
applySort();
render();
}
function applyGroupLimit() {
const input = document.getElementById('observation-group-limit');
const enabled = document.getElementById('observation-limit-enabled').checked;
input.disabled = !enabled;
if (!enabled) {
groupLimit = null;
if (data) selectRows();
return true;
}
if (!input.reportValidity()) return false;
const value = input.valueAsNumber;
if (!Number.isSafeInteger(value) || value < 1) {
input.setCustomValidity('Bitte eine positive ganze Zahl eingeben.');
input.reportValidity();
return false;
}
groupLimit = value;
if (data) selectRows();
return true;
}
document.getElementById('observation-group-limit').addEventListener('input', event => event.target.setCustomValidity(''));
document.getElementById('observation-group-limit').addEventListener('change', applyGroupLimit);
document.getElementById('observation-limit-enabled').addEventListener('change', applyGroupLimit);
document.getElementById('observation-display-options').addEventListener('submit', event => {
event.preventDefault();
applyGroupLimit();
});
function applySort() {
// Sort the complete result set before slicing it into display pages.
displayedRows.sort((a, b) => {
for (const {key, direction} of sortCriteria) {
const av = sortValue[key](a);
const bv = sortValue[key](b);
const missingA = av == null || (typeof av === 'number' && !Number.isFinite(av));
const missingB = bv == null || (typeof bv === 'number' && !Number.isFinite(bv));
if (missingA !== missingB) return missingA ? 1 : -1;
const comparison = missingA ? 0 : (typeof av === 'string' ? collator.compare(av, bv) : av - bv);
if (comparison) return comparison * direction;
}
return collator.compare(a.name, b.name) || collator.compare(String(a.id), String(b.id));
});
for (const button of sortButtons) {
const index = sortCriteria.findIndex(item => item.key === button.dataset.sort);
const criterion = sortCriteria[index];
if (index === 0) button.closest('th').setAttribute('aria-sort', criterion.direction === 1 ? 'ascending' : 'descending');
else button.closest('th').removeAttribute('aria-sort');
button.querySelector('.observation-sort-indicator').textContent = criterion ? `${index + 1} ${criterion.direction === 1 ? '↑' : '↓'}` : '↕';
button.setAttribute('aria-label', sortLabels[button.dataset.sort] + (criterion
? `, Sortierkriterium ${index + 1}, ${criterion.direction === 1 ? 'aufsteigend' : 'absteigend'}`
: ', nicht sortiert'));
}
document.getElementById('observation-sort-summary').textContent = 'Sortierung: ' + sortCriteria.map(({key, direction}, index) =>
`${index + 1}. ${sortLabels[key]} ${direction === 1 ? 'aufsteigend' : 'absteigend'}`).join(' → ');
}
const number = (value, digits = 1) => new Intl.NumberFormat('de-DE', {maximumFractionDigits: digits}).format(value);
const time = value => formatter.format(new Date(value));
function cell(row, text, note = '') {
const td = document.createElement('td');
td.textContent = text;
if (note) {
const small = document.createElement('small');
small.textContent = note;
td.append(small);
}
row.append(td);
}
function render() {
tbody.replaceChildren();
const fragment = document.createDocumentFragment();
for (const item of displayedRows.slice(page * pageSize, (page + 1) * pageSize)) {
const row = document.createElement('tr');
row.dataset.objectId = item.id;
cell(row, item.name, item.note);
cell(row, item.type);
cell(row, item.best.magnitude === null ? 'Helligkeit unbekannt' : `${number(item.best.magnitude)} mag`, item.magnitude_note);
cell(row, item.size_arcsec === null ? '—' : `${number(item.size_arcsec)}″`);
const windows = document.createElement('td');
for (const interval of item.intervals) {
const line = document.createElement('div');
line.textContent = interval.start === interval.end
? `${time(interval.start)} (ein Messpunkt)`
: `${time(interval.start)} – ${time(interval.end)}`;
windows.append(line);
}
row.append(windows);
cell(row, time(item.best.time));
cell(row, `${number(item.best.altitude)}°`);
cell(row, `${number(item.best.azimuth)}°`);
fragment.append(row);
}
tbody.append(fragment);
const pages = Math.max(1, Math.ceil(displayedRows.length / pageSize));
document.getElementById('observation-pagination').textContent = `Seite ${page + 1} von ${pages} · ${displayedRows.length} Ziele`;
previous.disabled = page === 0;
next.disabled = page + 1 >= pages;
}
previous.addEventListener('click', () => { if (page > 0) { page--; render(); } });
next.addEventListener('click', () => { if ((page + 1) * pageSize < displayedRows.length) { page++; render(); } });
for (const button of sortButtons) {
button.addEventListener('click', event => {
if (!data) return;
const key = button.dataset.sort;
const existing = sortCriteria.find(item => item.key === key);
const direction = existing ? -existing.direction : (key === 'altitude' ? -1 : 1);
if (event.shiftKey || document.getElementById('observation-multi-sort').checked) {
if (existing) existing.direction = direction;
else sortCriteria.push({key, direction});
} else {
sortCriteria = [{key, direction}];
}
page = 0;
applySort();
render();
});
}
form.addEventListener('submit', async event => {
event.preventDefault();
if (submit.disabled) return;
if (!applyGroupLimit()) return;
error.hidden = true;
const payload = new FormData(form);
if (!payload.getAll('object_types[]').length || !payload.getAll('night_halves[]').length) {
error.textContent = 'Bitte mindestens einen Objekttyp und eine Nachthälfte auswählen.';
error.hidden = false;
return;
}
for (const prefix of ['object-size', 'object-altitude']) {
const min = document.getElementById(`${prefix}-min`);
const max = document.getElementById(`${prefix}-max`);
if (min.value !== '' && max.value !== '' && Number(min.value) > Number(max.value)) {
error.textContent = 'Das Minimum darf nicht größer als das Maximum sein.';
error.hidden = false;
min.focus();
return;
}
}
submit.disabled = true;
results.hidden = true;
form.setAttribute('aria-busy', 'true');
status.textContent = 'Die Beobachtungsnacht und passende Ziele werden berechnet …';
try {
const response = await fetch(form.action, {method: 'POST', body: payload, credentials: 'same-origin'});
if (!(response.headers.get('content-type') || '').includes('application/json')) {
throw new Error('Keine gültige Antwort. Bitte die Seite neu laden und erneut anmelden.');
}
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Die Berechnung ist fehlgeschlagen.');
data = result;
page = 0;
formatter = new Intl.DateTimeFormat('de-DE', {timeZone: data.timezone, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZoneName: 'short'});
status.textContent = data.message || (data.count ? `${data.count} passende Ziele gefunden.` : 'Keine Ziele erfüllen die gewählten Bedingungen.');
const summary = document.getElementById('observation-summary');
summary.textContent = `Nacht ab ${payload.get('observation_date')} · ${data.timezone}. `
+ (data.windows.length ? `Gewählte Nachtabschnitte: ${data.windows.map(w => `${time(w.start)} – ${time(w.end)}`).join('; ')}. ` : '')
+ '„Zeit der größten Höhe“ bezeichnet den höchsten geprüften Punkt innerhalb deiner Filter und der gewählten Nachtabschnitte. Höhe, Azimut und Helligkeit beziehen sich auf diesen Zeitpunkt. Fünf-Minuten-Raster: sehr kurze Sichtfenster können fehlen.';
const warnings = document.getElementById('observation-warnings');
warnings.replaceChildren();
for (const warning of data.warnings) {
const item = document.createElement('li');
item.textContent = warning;
warnings.append(item);
}
selectRows();
results.hidden = false;
} catch (failure) {
status.textContent = 'Berechnung nicht abgeschlossen.';
error.textContent = failure.message || 'Verbindungsfehler. Bitte erneut versuchen.';
error.hidden = false;
} finally {
submit.disabled = false;
form.removeAttribute('aria-busy');
}
});
})();