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(); } }; }