Files
intranet/web/intranet_repo/projects/dashboard/dashboard.js
T

544 lines
26 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 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();
}
});
})();