228 lines
12 KiB
JavaScript
228 lines
12 KiB
JavaScript
'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');
|
||
}
|
||
});
|
||
})();
|