dashboard - whole redesign and rewrite
This commit is contained in:
@@ -3,13 +3,6 @@
|
||||
|
||||
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);
|
||||
@@ -35,7 +28,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
const aspectRatio = pages => pages.reduce((sum, item) => sum + item.width / item.height, 0);
|
||||
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', {
|
||||
@@ -63,9 +58,9 @@
|
||||
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');
|
||||
const panel = document.getElementById(id);
|
||||
this.stage = panel.querySelector('.image-stage');
|
||||
this.progress = panel.querySelector('.poster-progress');
|
||||
new ResizeObserver(() => this.resize()).observe(this.stage);
|
||||
}
|
||||
|
||||
@@ -75,7 +70,7 @@
|
||||
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 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
|
||||
@@ -134,6 +129,23 @@
|
||||
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);
|
||||
@@ -159,7 +171,7 @@
|
||||
this.message = message;
|
||||
this.items = items;
|
||||
// Nur neue Bildversionen laden; unveränderte Blob-URLs wiederverwenden.
|
||||
for (const item of items) this.loadImage(item);
|
||||
const loading = items.map(item => this.loadImage(item));
|
||||
if (!items.length) {
|
||||
this.generation++;
|
||||
clearTimeout(this.timer);
|
||||
@@ -180,7 +192,7 @@
|
||||
this.pending = true;
|
||||
return;
|
||||
}
|
||||
return this.show(0);
|
||||
return this.rotate ? this.show(0) : Promise.all(loading).then(() => this.show(0));
|
||||
}
|
||||
|
||||
resize() {
|
||||
@@ -219,6 +231,8 @@
|
||||
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');
|
||||
@@ -229,13 +243,23 @@
|
||||
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';
|
||||
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. */ }
|
||||
@@ -324,7 +348,8 @@
|
||||
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));
|
||||
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)),
|
||||
@@ -371,13 +396,76 @@
|
||||
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);
|
||||
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;
|
||||
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
|
||||
@@ -392,7 +480,25 @@
|
||||
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;
|
||||
@@ -400,6 +506,7 @@
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user