feat(forecast): integrate full forecast charts and complete SDL visualization
Tests / test (push) Failing after 52s
Tests / test (push) Failing after 52s
This commit is contained in:
@@ -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(); } };
|
||||
}
|
||||
Reference in New Issue
Block a user