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

436 lines
20 KiB
JavaScript

(() => {
'use strict';
const config = JSON.parse(document.getElementById('dashboard-config').textContent);
const root = document.body;
for (const [property, value] of Object.entries({
'--announcement-detail-font': 'calc(' + config.ANNOUNCEMENT_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))',
'--announcement-meta-font': 'calc(' + config.ANNOUNCEMENT_META_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))',
'--announcement-leading': config.ANNOUNCEMENT_LINE_HEIGHT,
'--bible-font': 'calc(' + config.BIBLE_FONT_SIZE_PX + ' * var(--dashboard-unit))',
'--announcement-measure': config.ANNOUNCEMENT_MAX_LINE_LENGTH_CH + 'ch',
})) root.style.setProperty(property, value);
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 aspectRatio = pages => pages.reduce((sum, item) => sum + item.width / 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,
});
this.panel = document.getElementById(id);
this.stage = this.panel.querySelector('.image-stage');
this.progress = this.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 = this.rotate ? parseFloat(style.columnGap) : 0;
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();
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.
for (const item of items) 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.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 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');
cell.style.height = renderHeight + 'px';
cell.style.width = (renderHeight * item.width / item.height) + 'px';
wrapper.append(cell);
const copy = (await this.loadImage(item))?.cloneNode();
if (copy) {
try {
await copy.decode();
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', aspectRatio(months));
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',
});
function tick() {
const now = new Date();
const nextDay = berlinDay(now);
document.getElementById('clock').textContent = timeFormat.format(now);
document.getElementById('clock').dateTime = now.toISOString();
document.getElementById('date').textContent = dateFormat.format(now);
document.getElementById('date').dateTime = nextDay;
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);
function refreshAll() {
return Promise.all([refreshContent(), refreshImages()]);
}
posters.onCycle = refreshAll;
refreshAll();
tick();
setInterval(tick, 1000);
document.fonts.ready.then(() => {
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();
}
});
})();