'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'); } }); })();