(() => { 'use strict'; const config = JSON.parse(document.getElementById('dashboard-config').textContent); const root = document.body; function node(tag, className, text) { const element = document.createElement(tag); element.className = className; if (text !== undefined) element.textContent = text; return element; } function empty(stage, text) { stage.replaceChildren(node('p', 'empty-state', text)); } // Timeout und HTTP-Prüfung gelten für Inhalte und Bilder gleichermaßen. async function request(url, read, options = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.REQUEST_TIMEOUT_MS); try { const response = await fetch(url, { ...options, signal: controller.signal }); if (!response.ok) throw new Error('HTTP ' + response.status); return await read(response); } finally { clearTimeout(timeout); } } const imageSize = item => item.crop || item; const aspectRatio = pages => pages.reduce((sum, item) => sum + imageSize(item).width / imageSize(item).height, 0); const layoutKey = layout => [layout.width, layout.height, layout.groups.map(group => group.length)].join('/'); const dayFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', }); function berlinDay(date = new Date()) { const parts = Object.fromEntries(dayFormatter.formatToParts(date).map(part => [part.type, part.value])); return parts.year + '-' + parts.month + '-' + parts.day; } let currentDay = berlinDay(); function groupDocuments(pages) { const entries = new Map(); for (const item of pages) { const key = item.document ?? item.url; if (!entries.has(key)) entries.set(key, []); entries.get(key).push(item); } return [...entries.values()].map(pages => pages.sort((a, b) => a.page - b.page)); } class ImageGallery { constructor(id, message, rotate = false) { Object.assign(this, { message, rotate, items: [], current: [], images: new Map(), index: 0, generation: 0, signature: '', layoutKey: '', failed: false, pending: false, }); const panel = document.getElementById(id); this.stage = panel.querySelector('.image-stage'); this.progress = panel.querySelector('.poster-progress'); new ResizeObserver(() => this.resize()).observe(this.stage); } layout() { // Keine Ganzzahlrundung durch clientHeight: Original-Bildhöhe exakt erhalten. const bounds = this.stage.getBoundingClientRect(); const style = getComputedStyle(this.stage); const width = bounds.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); const height = bounds.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); const gap = parseFloat(style.columnGap); const frame = this.rotate ? parseFloat(style.getPropertyValue('--poster-frame-width')) : 0; if (!this.rotate) return { groups: [this.items], width, height, gap, frame }; const minimum = innerHeight * config.POSTER_MIN_RENDER_HEIGHT_VH / 100 - config.POSTER_MIN_RENDER_HEIGHT_OFFSET_PX; const occupied = pages => minimum * aspectRatio(pages) + groupDocuments(pages).length * (2 * frame + gap); const fits = pages => pages.length <= config.POSTER_MAX_VISIBLE_PAGES && occupied(pages) <= width + gap; // Nur Aushänge aufteilen, die selbst nicht in eine Ansicht passen. const chunks = []; for (const pages of groupDocuments(this.items)) { let chunk = []; for (const item of pages) { if (chunk.length && !fits([...chunk, item])) { chunks.push(chunk); chunk = []; } chunk.push(item); } if (chunk.length) chunks.push(chunk); } // Große Dokumente zuerst, kleinere in die Lücken: Datum ist nachrangig. chunks.sort((a, b) => occupied(b) - occupied(a)); const groups = []; for (const chunk of chunks) { const group = groups.find(group => fits([...group, ...chunk])); if (group) group.push(...chunk); else groups.push([...chunk]); } // Zuerst vollständige Dokumente aus der vorigen Ansicht übernehmen, // verbleibende Plätze nötigenfalls mit einzelnen Seiten auffüllen. const primaryGroups = groups.map(group => [...group]); for (let index = 0; groups.length > 1 && index < groups.length; index++) { const current = groups[index]; const previous = primaryGroups[(index + groups.length - 1) % groups.length]; const available = previous.filter(item => !current.some(entry => entry.url === item.url)); for (const pages of groupDocuments(available).reverse()) { if (fits([...current, ...pages])) current.unshift(...pages); } for (const item of [...available].reverse()) { if (!current.some(entry => entry.url === item.url) && fits([...current, item])) current.unshift(item); } groups[index] = groupDocuments(current).flat(); } return { groups, width, height, gap, frame }; } loadImage(item) { if (!this.images.has(item.url)) { let localUrl; this.images.set(item.url, request(item.url, async response => { localUrl = URL.createObjectURL(await response.blob()); const image = new Image(); image.alt = item.alt; image.dataset.source = item.url; image.src = localUrl; await image.decode(); if (!this.rotate) { const canvas = document.createElement('canvas'); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; const context = canvas.getContext('2d'); context.drawImage(image, 0, 0); const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; let left = canvas.width, top = canvas.height, right = 0, bottom = 0; for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) { if (!pixels[(y * canvas.width + x) * 4 + 3]) continue; left = Math.min(left, x); top = Math.min(top, y); right = Math.max(right, x + 1); bottom = Math.max(bottom, y + 1); } if (right) Object.defineProperty(item, 'crop', { value: { left, top, width: right - left, height: bottom - top }, configurable: true, }); } return image; }).catch(() => { if (localUrl) URL.revokeObjectURL(localUrl); this.images.delete(item.url); return null; })); } return this.images.get(item.url); } pruneImages() { for (const [url, pending] of this.images) { if (this.items.some(item => item.url === url) || this.current.some(item => item.url === url)) continue; this.images.delete(url); pending.then(image => { if (image) URL.revokeObjectURL(image.src); }); } } update(items, message = this.message) { const signature = JSON.stringify([items, message]); if (signature === this.signature && !this.failed) return; this.signature = signature; this.message = message; this.items = items; // Nur neue Bildversionen laden; unveränderte Blob-URLs wiederverwenden. const loading = items.map(item => this.loadImage(item)); if (!items.length) { this.generation++; clearTimeout(this.timer); this.current = []; this.pending = false; empty(this.stage, message); this.pruneImages(); this.schedule(); return; } // Nur abgelaufene Seiten sofort entfernen; sonst bleiben vorhandene // Bilder bis zum vollständig geladenen Ersatz sichtbar. if (this.rotate && this.current.some(item => item.expires < currentDay)) { this.current = []; empty(this.stage, this.message); } if (this.rotate && this.current.length) { this.pending = true; return; } return this.rotate ? this.show(0) : Promise.all(loading).then(() => this.show(0)); } resize() { if (this.items.length && layoutKey(this.layout()) !== this.layoutKey) this.show(this.index); } schedule() { clearTimeout(this.timer); if (!this.rotate) return; const duration = this.items.length ? config.POSTER_MIN_DWELL_TIME_S * 1000 : config.EMPTY_REFRESH_MS; const changing = this.items.length && this.layout().groups.length > 1; this.progressAnimation?.cancel(); this.progress.toggleAttribute('hidden', !changing); if (changing) { this.progressAnimation = this.progress.querySelector('.poster-progress-value').animate( [{ strokeDashoffset: 100 }, { strokeDashoffset: 0 }], { duration, easing: 'linear', fill: 'forwards' }); } this.timer = setTimeout(async () => { if (this.items.length && !this.pending && this.index + 1 < this.layout().groups.length) { return this.show(this.index + 1); } // Texte und Bildbestand nach einem vollständigen Durchlauf prüfen. await this.onCycle?.(); if (this.items.length && (this.pending || this.failed || this.index !== 0)) this.show(0); else this.schedule(); }, duration); } async show(index) { if (!this.items.length) return; this.pending = false; const layout = this.layout(); this.layoutKey = layoutKey(layout); this.index = index % layout.groups.length; const batch = layout.groups[this.index]; const documents = this.rotate ? groupDocuments(batch) : [batch]; const renderHeight = Math.min(layout.height - 2 * layout.frame, (layout.width - (documents.length - 1) * layout.gap - documents.length * 2 * layout.frame) / aspectRatio(batch)); const monthScale = this.rotate ? 0 : Math.min(layout.height / Math.max(...batch.map(item => imageSize(item).height)), (layout.width - (batch.length - 1) * layout.gap) / batch.reduce((sum, item) => sum + imageSize(item).width, 0)); const generation = ++this.generation; clearTimeout(this.timer); const sheet = node('div', 'image-sheet'); let failures = 0; // Gemeinsam gerahmte Seiten laden unabhängig; erst die fertige Ansicht einblenden. await Promise.all(documents.flatMap(pages => { const wrapper = this.rotate ? node('div', 'poster-document') : sheet; if (this.rotate) sheet.append(wrapper); return pages.map(async item => { const cell = node('div', 'image-cell'); const size = imageSize(item); cell.style.height = ((this.rotate ? renderHeight : size.height * monthScale)) + 'px'; cell.style.width = ((this.rotate ? renderHeight * item.width / item.height : size.width * monthScale)) + 'px'; wrapper.append(cell); const copy = (await this.loadImage(item))?.cloneNode(); if (copy) { try { await copy.decode(); if (item.crop) { cell.classList.add('is-cropped'); Object.assign(copy.style, { width: item.width / size.width * 100 + '%', height: item.height / size.height * 100 + '%', left: -size.left / size.width * 100 + '%', top: -size.top / size.height * 100 + '%', }); } cell.append(copy); return; } catch { /* Die übrigen Seiten bleiben unabhängig darstellbar. */ } } failures++; empty(cell, 'Seite zurzeit nicht verfügbar.'); }); })); if (generation !== this.generation) return; if (!batch.every(item => this.items.some(entry => entry.url === item.url))) return this.show(0); this.failed = failures > 0; // Einen vorhandenen Monatsplan bei defektem Ersatz nicht wegblenden. if (!this.rotate && failures && this.stage.querySelector('img')) return; this.current = batch; this.stage.replaceChildren(sheet); this.pruneImages(); if (failures === batch.length) empty(this.stage, 'Bilder zurzeit nicht verfügbar.'); this.schedule(); // Mindeststandzeit erst nach vollständigem Laden der Gruppe. } } // Eine Textseite enthält ausschließlich DOM-Text, niemals HTML aus der Datenbank. function paragraphs(className, entries) { const container = node('div', className); for (const [name, text] of entries) if (text) container.append(node('p', name, text)); return container; } function announcementList(items) { const list = node('ul', 'text-page announcement-list'); for (const item of items) { const entry = node('li', 'announcement-entry'); entry.append(paragraphs('announcement-when', [ ['announcement-date', item.date_label || 'Ohne Datum'], ['announcement-end', item.end_label], ]), paragraphs('announcement-content', [ ['announcement-title', item.title], ['announcement-subtitle', item.subtitle], ['announcement-meta', [item.time ? item.time + ' Uhr' : '', item.place].filter(Boolean).join(', ')], ])); list.append(entry); } return list; } class FixedText { constructor(id, render = entries => paragraphs('text-page', entries)) { this.stage = document.getElementById(id).querySelector('.text-stage'); this.render = render; this.signature = ''; this.loaded = false; new ResizeObserver(() => this.rebuild()).observe(this.stage); } update(documents, message) { this.loaded = true; const signature = JSON.stringify([documents, message]); if (signature === this.signature) return; this.signature = signature; if (documents.length) this.stage.replaceChildren(this.render(documents)); else empty(this.stage, message); this.rebuild(); } unavailable(message) { // Auch ein erfolgreich gelesener Leerzustand bleibt bei Abruffehlern stehen. if (this.loaded) return; empty(this.stage, message); this.rebuild(); } rebuild() { const page = this.stage.querySelector('.text-page'); this.stage.classList.toggle('is-overflowing', Boolean(page && this.stage.clientHeight && page.getBoundingClientRect().height > this.stage.clientHeight + 1)); } } const month = new ImageGallery('month-panel', 'Kein Monatsplan vorhanden.'); const posters = new ImageGallery('poster-panel', 'Keine aktuellen Aushänge.', true); const announcements = new FixedText('announcements-panel', announcementList); const bible = new FixedText('bible-panel'); let imageData = null; function layoutImages() { if (!imageData) return; // Monatsplan dauerhaft separat: keine Vermischung mit der Aushangrotation. const months = imageData.month_images; if (months.length) root.style.setProperty('--month-aspect', months.reduce((sum, item) => sum + item.width / item.height, 0)); return Promise.all([ month.update(months), posters.update(imageData.posters.filter(item => item.expires >= currentDay)), ]); } function contentLoaded(data) { if (data.date !== currentDay) return contentUnavailable(); if (data.errors.includes('announcements')) announcements.unavailable('Ansagen zurzeit nicht verfügbar.'); else announcements.update(data.announcements, 'Es sind keine Ansagen eingetragen.'); const service = data.service; const blocks = service ? [ ['bible-reference', service.reference || 'Bibelstelle nicht eingetragen.'], ['bible-verse', service.text || 'Bibelvers nicht eingetragen.'], ] : []; if (data.errors.includes('service')) bible.unavailable('Bibelwort zurzeit nicht verfügbar.'); else bible.update(blocks, 'Für heute ist kein Bibelwort eingetragen.'); } function contentUnavailable() { announcements.unavailable('Ansagen zurzeit nicht verfügbar.'); bible.unavailable('Bibelwort zurzeit nicht verfügbar.'); } function imagesUnavailable() { if (!imageData) { month.update([], 'Monatsplan zurzeit nicht verfügbar.'); posters.update([], 'Aushänge zurzeit nicht verfügbar.'); } } // Unabhängige Hintergrundabrufe, ohne Ladezustand und ohne doppelte Anfragen. function createRefresh(resource, onData, onError) { let inFlight = null; return () => inFlight ??= request('data.php?resource=' + resource, async response => onData(await response.json()), { cache: 'no-store' }) .catch(onError).finally(() => { inFlight = null; }); } const timeFormat = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', }); const dateFormat = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: 'long', year: 'numeric', }); const clock = document.getElementById('clock'); const date = document.getElementById('date'); function fitClock() { const width = clock.getBoundingClientRect().width; const target = date.getBoundingClientRect().width; if (width && Math.abs(width - target) > 1.5) { clock.style.fontSize = parseFloat(getComputedStyle(clock).fontSize) * target / width + 'px'; } } new ResizeObserver(fitClock).observe(date); const departures = new FixedText('departures-panel', rows => { const list = node('ul', 'text-page departures-list'); for (const item of rows) { const row = node('li', 'departure-row is-' + item.state); row.title = item.detail; row.append(node('span', 'departure-line', item.line), node('span', 'departure-direction', item.direction), node('span', 'departure-remaining', item.remaining), node('span', 'departure-status', item.status)); list.append(row); } return list; }); const departureData = { departures: [], updatedAt: 0 }; let nextDepartureRequest = 0; function renderDepartures(now) { const age = now - departureData.updatedAt; // Der globale Feed-Zeitstempel kann älter sein; entscheidend sind die // Prognosen der einzelnen Fahrten und das Alter unseres letzten Abrufs. const fresh = age < 300000; const rows = (age < 7200000 ? departureData.departures : []) .map(d => ({ ...d, timestamp: Date.parse(d.when || d.plannedWhen) })) .filter(d => Number.isFinite(d.timestamp) && d.timestamp >= now) .sort((a, b) => a.timestamp - b.timestamp).slice(0, 5).map(d => { const live = fresh && Number.isFinite(d.delay) && Boolean(d.when); const state = !fresh ? 'stale' : d.cancelled ? 'cancelled' : !live ? 'scheduled' : d.delay > 0 ? 'delayed' : d.delay < 0 ? 'early' : 'ontime'; const status = { stale: 'veraltet', cancelled: 'entfällt', scheduled: 'pünktlich', ontime: 'pünktlich', delayed: '+' + Math.ceil(d.delay / 60) + ' Min.', early: '−' + Math.ceil(-d.delay / 60) + ' Min.' }[state]; const minutes = Math.ceil((d.timestamp - now) / 60000); const planned = Date.parse(d.plannedWhen); return { state, status, line: d.line?.name || 'Bus', direction: d.direction || 'Richtung unbekannt', remaining: d.cancelled ? '' : minutes ? minutes + ' Min' : 'jetzt', detail: (Number.isFinite(planned) ? 'Geplant ' + timeFormat.format(planned) + ' · ' : '') + (d.direction || '') + ' · ' + status }; }); departures.update(rows, fresh ? 'Keine Busabfahrten in den nächsten 120 Minuten.' : 'Live-Abfahrten derzeit nicht verfügbar.'); } const refreshDepartures = createRefresh('departures_eich', data => { if (!Array.isArray(data.departures) || !Number.isFinite(data.updatedAt)) throw new Error('Ungültige BVG-Antwort'); Object.assign(departureData, data); nextDepartureRequest = Number(data.retryAt) * 1000 || 0; renderDepartures(Date.now()); }, () => { departureData.unavailable = true; nextDepartureRequest = Date.now() + 30000; renderDepartures(Date.now()); }); function tick() { const now = new Date(); const nextDay = berlinDay(now); clock.textContent = timeFormat.format(now); clock.dateTime = now.toISOString(); date.textContent = dateFormat.format(now); date.dateTime = nextDay; fitClock(); renderDepartures(now.getTime()); if (!document.hidden && now.getTime() >= nextDepartureRequest) { nextDepartureRequest = now.getTime() + 1000; refreshDepartures(); } if (nextDay !== currentDay) { currentDay = nextDay; // Texte erst mit einer erfolgreichen Antwort austauschen; abgelaufene // Aushänge weiterhin sofort entfernen. layoutImages(); refreshAll(); } } const refreshContent = createRefresh('content', contentLoaded, contentUnavailable); const refreshImages = createRefresh('images', data => { imageData = data; return layoutImages(); }, imagesUnavailable); const temperatureFormat = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 1 }); const refreshTemperatures = [ ['indoor', data => [data.indoor.value, data.indoor.unit]], ['outdoor', data => [data.outdoor.temperature, '°C']], ].map(([id, read]) => { const output = document.getElementById(id); return createRefresh(id, data => { const [value, unit] = read(data); if (!Number.isFinite(value)) throw new Error('Ungültige Temperatur'); const text = temperatureFormat.format(value) + ' ' + unit; if (output.textContent !== text) output.textContent = text; output.classList.remove('is-unavailable'); }, () => { if (output.classList.contains('is-unavailable')) output.textContent = 'Nicht verfügbar'; }); }); function refreshAll() { // Langsame Temperatursensoren dürfen den Aushangswechsel nicht verzögern. refreshTemperatures.forEach(refresh => refresh()); return Promise.all([refreshContent(), refreshImages()]); } posters.onCycle = refreshAll; refreshAll(); tick(); setInterval(tick, 1000); document.fonts.ready.then(() => { fitClock(); announcements.rebuild(); bible.rebuild(); // Ein Mindestkontrast wird auch bei später geänderten zentralen Tokens geprüft. const style = getComputedStyle(root); const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1; const context = canvas.getContext('2d', { willReadFrequently: true }); function luminance(value) { context.fillStyle = value; context.fillRect(0, 0, 1, 1); const channels = Array.from(context.getImageData(0, 0, 1, 1).data).slice(0, 3); return channels.map(channel => { const c = channel / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }).reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0); } const background = Math.min(...Array.from(document.querySelectorAll( '.dashboard-panel, .dashboard-overview, .panel-heading, .announcement-entry'), panel => luminance(getComputedStyle(panel).backgroundColor))); for (const [property, token] of [['--dashboard-text', '--nak-black'], ['--dashboard-meta', '--gray-700']]) { const contrast = (background + 0.05) / (luminance(style.getPropertyValue(token)) + 0.05); if (contrast < config.CONTENT_MIN_CONTRAST_RATIO) root.style.setProperty(property, 'var(--nak-black)'); } }); document.addEventListener('visibilitychange', () => { if (!document.hidden) { tick(); // Nach Rückkehr bekommt die sichtbare Seite erneut ihre volle Lesezeit. posters.schedule(); } }); })();