Beobachtungsvorschläge rein

This commit is contained in:
Eskimue
2026-09-15 15:58:51 +02:00
parent 2396cac6db
commit 59cb076640
11 changed files with 1275 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
'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');
}
});
})();