feat(forecast): integrate full forecast charts and complete SDL visualization
Tests / test (push) Failing after 52s

This commit is contained in:
dh
2026-10-06 17:40:45 +00:00
parent 5687596799
commit 372b997876
30 changed files with 4377 additions and 53 deletions
+182
View File
@@ -0,0 +1,182 @@
const SVG_NS = 'http://www.w3.org/2000/svg';
const COLORS = ['#4e8181', '#757b12', '#ac4f68', '#386b91', '#737c7e', '#9c6335'];
let sequence = 0;
export const finiteValue = value => typeof value === 'number' && Number.isFinite(value);
export const formatNumber = (value, digits = 2) => finiteValue(value)
? value.toLocaleString('de-CH', { maximumFractionDigits: digits }) : 'nicht vorhanden';
export function forecastSeries(plan) {
const fullHorizon = Array.isArray(plan.forecastPoints) && plan.forecastPoints.length > 0;
return { fullHorizon, points: fullHorizon ? plan.forecastPoints : Array.isArray(plan.points) ? plan.points : [] };
}
export function formatTime(value) {
const stamp = Date.parse(value);
return Number.isFinite(stamp) ? new Date(stamp).toLocaleString('de-CH', {
timeZone: 'Europe/Zurich', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZoneName: 'short'
}) : 'nicht vorhanden';
}
export function chartDomain(points, series) {
let low = 0, high = 0, start = Infinity, end = -Infinity, valid = false;
for (const point of points) {
const at = Date.parse(point.time), until = Date.parse(point.validUntil || point.time);
if (!Number.isFinite(at) || !Number.isFinite(until) || until < at) continue;
start = Math.min(start, at); end = Math.max(end, until);
for (const item of series) {
const value = item.value(point);
if (!finiteValue(value)) continue;
low = Math.min(low, value); high = Math.max(high, value); valid = true;
}
}
if (!Number.isFinite(start)) return null;
return { low, high: high === low ? low + 1 : high, start, end: Math.max(start + 1, end), valid };
}
// Keep missing values and non-contiguous intervals as visible gaps, never interpolated data.
export function seriesSegments(points, series, x, y) {
const segments = [];
let section = [], previousEnd;
const flush = () => { if (section.length) segments.push(section); section = []; };
for (const point of points) {
const value = series.value(point), at = Date.parse(point.time), end = Date.parse(point.validUntil || point.time);
if (!finiteValue(value) || !Number.isFinite(at) || !Number.isFinite(end) || end < at) {
flush(); previousEnd = undefined; continue;
}
if (previousEnd !== undefined && (at > previousEnd || at < previousEnd)) flush();
if (series.position === 'end') section.push([x(end), y(value)]);
else {
section.push([x(at), y(value)]);
if (end > at) section.push([x(end), y(value)]);
}
previousEnd = end;
}
flush(); return segments;
}
export function pointIndexAt(points, stamp) {
let selected = -1, distance = Infinity;
points.forEach((point, index) => {
const start = Date.parse(point.time), end = Date.parse(point.validUntil || point.time);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return;
const next = stamp < start ? start - stamp : stamp >= end ? stamp - end : 0;
if (next <= distance) { distance = next; selected = index; }
});
return selected;
}
const element = (tag, text, className) => {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
};
const svgElement = (tag, attributes, text) => {
const node = document.createElementNS(SVG_NS, tag);
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
if (text !== undefined) node.textContent = text;
return node;
};
export function renderForecastChart(container, { title, points = [], series = [], unit = '', caption = '' }) {
const id = 'forecast-chart-' + ++sequence;
const root = element('section', undefined, 'forecast-chart');
const heading = element('h3', title); heading.id = id;
root.setAttribute('aria-labelledby', id); root.append(heading); container.append(root);
const records = points.filter(point => Number.isFinite(Date.parse(point.time)) && Number.isFinite(Date.parse(point.validUntil || point.time)) && Date.parse(point.validUntil || point.time) >= Date.parse(point.time));
if (!records.length || !chartDomain(records, series)?.valid) {
root.append(element('p', 'Keine Prognosewerte vorhanden.', 'forecast-chart-empty'));
if (caption) root.append(element('p', caption, 'forecast-chart-caption'));
return { element: root, destroy() { root.remove(); } };
}
const sources = series.map((source, index) => ({ ...source, color: source.color || COLORS[index % COLORS.length], visible: source.visible !== false }));
const legend = element('fieldset', undefined, 'forecast-chart-legend');
legend.append(element('legend', 'Datenreihen', 'forecast-chart-sr'));
const plot = element('div', undefined, 'forecast-chart-plot');
const svg = svgElement('svg', { role: 'img', 'aria-label': `${title}, ${unit}. Prognosewerte pro Zeitintervall.` });
plot.append(svg);
const inspector = element('div', undefined, 'forecast-chart-inspector');
const timestamp = element('p', undefined, 'forecast-chart-time');
const readout = element('dl'); inspector.append(timestamp, readout);
const sliderLabel = element('label', 'Prognosezeitpunkt', 'forecast-chart-slider');
const slider = element('input'); slider.type = 'range'; slider.min = '0'; slider.max = String(records.length - 1); slider.step = '1'; slider.value = '0';
slider.disabled = records.length === 1; sliderLabel.append(slider);
root.append(legend, plot, sliderLabel, inspector);
if (caption) root.append(element('p', caption, 'forecast-chart-caption'));
let index = 0, domain, width = 900, marker;
const left = 68, right = 16, top = 18, bottom = 238, height = 284;
const x = value => left + (width - left - right) * (value - domain.start) / (domain.end - domain.start);
const y = value => bottom - (bottom - top) * (value - domain.low) / (domain.high - domain.low);
function inspect(next) {
index = Math.max(0, Math.min(records.length - 1, next)); slider.value = String(index);
const point = records[index];
timestamp.textContent = `${formatTime(point.time)} bis ${formatTime(point.validUntil || point.time)}`;
readout.replaceChildren();
const descriptions = [];
for (const source of sources.filter(item => item.visible)) {
const row = element('div'), name = element('dt', source.label), value = source.value(point);
name.style.setProperty('--series-color', source.color);
const text = finiteValue(value) ? `${formatNumber(value, source.digits ?? 2)} ${unit}` : 'nicht vorhanden';
row.append(name, element('dd', text)); readout.append(row); descriptions.push(`${source.label}: ${text}`);
}
if (!descriptions.length) readout.append(element('div', 'Keine Datenreihe ausgewaehlt.'));
slider.setAttribute('aria-valuetext', `${timestamp.textContent}. ${descriptions.join('; ')}`);
if (marker) {
const active = sources.filter(item => item.visible);
const endPosition = active.length && active.every(item => item.position === 'end');
const at = x(Date.parse(endPosition ? point.validUntil || point.time : point.time)); marker.setAttribute('x1', at); marker.setAttribute('x2', at);
}
}
function draw() {
width = Math.max(300, Math.round(plot.getBoundingClientRect().width || 900));
const active = sources.filter(source => source.visible);
domain = chartDomain(records, active.length ? active : sources);
svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.replaceChildren();
for (let tick = 0; tick <= 4; tick++) {
const value = domain.low + (domain.high - domain.low) * tick / 4, at = y(value);
svg.append(svgElement('line', { x1: left, x2: width - right, y1: at, y2: at, class: 'forecast-chart-grid' }));
svg.append(svgElement('text', { x: left - 8, y: at + 4, 'text-anchor': 'end', class: 'forecast-chart-axis' }, formatNumber(value, Math.max(1, ...active.map(source => source.digits ?? 1)))));
}
svg.append(svgElement('text', { x: left, y: 12, class: 'forecast-chart-axis' }, unit));
const tickCount = width < 550 ? 2 : 4;
for (let tick = 0; tick <= tickCount; tick++) {
const stamp = domain.start + (domain.end - domain.start) * tick / tickCount;
const anchor = tick === 0 ? 'start' : tick === tickCount ? 'end' : 'middle';
const at = new Date(stamp);
svg.append(svgElement('text', { x: x(stamp), y: bottom + 20, 'text-anchor': anchor, class: 'forecast-chart-axis' }, at.toLocaleTimeString('de-CH', { timeZone: 'Europe/Zurich', hour: '2-digit', minute: '2-digit' })));
svg.append(svgElement('text', { x: x(stamp), y: bottom + 36, 'text-anchor': anchor, class: 'forecast-chart-axis' }, at.toLocaleDateString('de-CH', { timeZone: 'Europe/Zurich', day: '2-digit', month: '2-digit' })));
}
for (const source of active) {
for (const section of seriesSegments(records, source, x, y)) {
const attributes = { fill: 'none', stroke: source.color, 'stroke-width': 2, 'vector-effect': 'non-scaling-stroke', 'stroke-linecap': 'round' };
if (source.dash) attributes['stroke-dasharray'] = source.dash;
if (section.length === 1) svg.append(svgElement('circle', { cx: section[0][0], cy: section[0][1], r: 3, fill: source.color }));
else svg.append(svgElement('polyline', { ...attributes, points: section.map(pair => pair.join(',')).join(' ') }));
}
}
marker = svgElement('line', { y1: top, y2: bottom, class: 'forecast-chart-marker' }); svg.append(marker);
inspect(index);
}
sources.forEach(source => {
const label = element('label'), input = element('input'), swatch = element('span', undefined, 'forecast-chart-swatch');
input.type = 'checkbox'; input.checked = source.visible; swatch.style.background = source.color;
if (source.dash) swatch.style.background = `repeating-linear-gradient(90deg, ${source.color} 0 5px, transparent 5px 8px)`;
label.append(input, swatch, document.createTextNode(source.label)); legend.append(label);
input.addEventListener('change', () => { source.visible = input.checked; draw(); });
});
slider.addEventListener('input', () => inspect(Number(slider.value)));
const pointer = event => {
const bounds = svg.getBoundingClientRect();
if (!bounds.width) return;
const position = Math.max(left, Math.min(width - right, (event.clientX - bounds.left) * width / bounds.width));
const stamp = domain.start + (position - left) / (width - left - right) * (domain.end - domain.start);
inspect(pointIndexAt(records, stamp));
};
svg.addEventListener('pointermove', event => { if (event.pointerType !== 'touch') pointer(event); });
svg.addEventListener('pointerdown', pointer);
draw();
const resize = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(draw) : null;
resize?.observe(plot);
return { element: root, destroy() { resize?.disconnect(); root.remove(); } };
}