543 lines
26 KiB
JavaScript
543 lines
26 KiB
JavaScript
(() => {
|
||
'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;
|
||
this.progressAnimation?.cancel();
|
||
this.progress.toggleAttribute('hidden', !this.items.length);
|
||
if (this.items.length) {
|
||
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();
|
||
}
|
||
});
|
||
})();
|