Weather Station / ECOWITT / DNT
1"use client";
2
3import React, { useMemo, useState } from "react";
4
5/**
6 * Represents a single point in a line chart series.
7 */
8export type LinePoint = { x: number; y: number; label?: string };
9
10/**
11 * Represents a series of data for the line chart.
12 */
13export type LineSeries = { id: string; color: string; points: LinePoint[] };
14
15/**
16 * Props for the LineChart component.
17 */
18type Props = {
19 /** The array of data series to plot. */
20 series: LineSeries[];
21 /** The height of the chart canvas. */
22 height?: number;
23 /** The label for the Y-axis. */
24 yLabel?: string;
25 /** A function to format the X-axis tick labels. */
26 xTickFormatter?: (v: number) => string;
27 /** A dedicated formatter for the time displayed in the hover tooltip. */
28 hoverTimeFormatter?: (v: number) => string;
29 /** The label for the X-axis. */
30 xLabel?: string;
31 /** Whether to display the legend. */
32 showLegend?: boolean;
33 /** If true, renders the data as vertical bars instead of a line. */
34 bars?: boolean;
35 /** The width of bars in data units (e.g., minutes). */
36 barWidth?: number;
37 /** A fixed width for bars in pixels, overriding `barWidth`. */
38 barWidthPx?: number;
39 /** If true, shows a crosshair and tooltip on hover. */
40 showHover?: boolean;
41 /** The unit to append to the Y-value in the tooltip. */
42 yUnit?: string;
43 /** A custom function to format the value displayed in the tooltip. */
44 valueFormatter?: (v: number) => string;
45};
46
47/**
48 * A responsive line chart component built with SVG.
49 * It supports multiple series, tooltips, legends, and rendering as bar charts.
50 *
51 * @param props - The component props.
52 * @returns A React component that renders the line chart.
53 */
54export default function LineChart({ series, height = 220, yLabel, xTickFormatter, hoverTimeFormatter, xLabel, showLegend = true, bars = false, barWidth, barWidthPx, showHover = true, yUnit, valueFormatter }: Props) {
55 const padding = { top: 20, right: 12, bottom: 28, left: 36 };
56 const width = 800; // SVG viewBox width; scales responsively via CSS
57
58 const allPoints = series.flatMap((s) => s.points);
59 const xs = allPoints.map((p) => p.x);
60 const ys = allPoints.map((p) => p.y).filter((n) => Number.isFinite(n));
61 const minX = xs.length ? Math.min(...xs) : 0;
62 const maxX = xs.length ? Math.max(...xs) : 1;
63 let minY = ys.length ? Math.min(...ys) : 0;
64 let maxY = ys.length ? Math.max(...ys) : 1;
65 // For bars, include zero baseline in domain
66 if (bars) {
67 minY = Math.min(minY, 0);
68 maxY = Math.max(maxY, 0);
69 }
70
71 // Detect temperature series (ignore dew point and felt temperature)
72 const isFeel = (id: string) => id.toLowerCase().includes("gefühlte temperatur");
73 const isDew = (id: string) => id.toLowerCase().includes("taupunkt");
74 const isTemp = (id: string) => id.toLowerCase().includes("temperatur") && !isFeel(id) && !isDew(id);
75 const tempValues: number[] = [];
76 for (const s of series) {
77 if (!isTemp(s.id)) continue;
78 for (const p of s.points) if (Number.isFinite(p.y)) tempValues.push(p.y as number);
79 }
80 const hasTemperature = tempValues.length > 0;
81 const avgTemp = hasTemperature ? (tempValues.reduce((a, b) => a + b, 0) / tempValues.length) : null;
82
83 function pickForHover(points: LinePoint[], x: number, _barsMode: boolean): LinePoint | null {
84 const valid = points.filter((p) => Number.isFinite(p.y));
85 if (valid.length === 0) return null;
86
87 // Find the closest point to the hover position
88 const sorted = valid.slice().sort((a, b) => a.x - b.x);
89
90 // Find the closest point to hover x
91 let closest: LinePoint | null = null;
92 let minDistance = Infinity;
93
94 for (const p of sorted) {
95 const distance = Math.abs(p.x - x);
96 if (distance < minDistance) {
97 minDistance = distance;
98 closest = p;
99 }
100 }
101
102 return closest;
103 }
104 const spanX = maxX - minX || 1;
105 const spanY = maxY - minY || 1;
106
107 const innerW = width - padding.left - padding.right;
108 const innerH = height - padding.top - padding.bottom;
109
110 function sx(x: number) {
111 return padding.left + ((x - minX) / spanX) * innerW;
112 }
113 function sy(y: number) {
114 return padding.top + innerH - ((y - minY) / spanY) * innerH;
115 }
116
117 function pathFor(points: LinePoint[]) {
118 const valid = points.filter((p) => Number.isFinite(p.y));
119 if (valid.length === 0) return "";
120 return valid
121 .map((p, i) => `${i === 0 ? "M" : "L"}${sx(p.x).toFixed(2)},${sy(p.y).toFixed(2)}`)
122 .join(" ");
123 }
124
125 // Variant that allows per-point pixel offsets in Y (screen space)
126 function pathForWithOffset(points: LinePoint[], offsetPx?: number[]) {
127 const valid: { p: LinePoint; i: number }[] = [];
128 for (let i = 0; i < points.length; i++) {
129 const p = points[i];
130 if (Number.isFinite(p.y)) valid.push({ p, i });
131 }
132 if (!valid.length) return "";
133 return valid
134 .map(({ p, i }, idx) => {
135 const off = offsetPx ? (offsetPx[i] || 0) : 0;
136 const x = sx(p.x).toFixed(2);
137 const y = (sy(p.y) + off).toFixed(2);
138 return `${idx === 0 ? "M" : "L"}${x},${y}`;
139 })
140 .join(" ");
141 }
142
143 // Ticks: show hourly ticks for ~1 day, fewer for longer spans
144 const xTickCount = spanX <= 1440 ? 24 : 10; // spanX in minutes (x is minutes offset)
145 const yTickCount = 5;
146 const xTicks = Array.from({ length: xTickCount + 1 }, (_, i) => minX + (spanX * i) / xTickCount);
147 const yTicks = Array.from({ length: yTickCount + 1 }, (_, i) => minY + (spanY * i) / yTickCount);
148
149 // Hover state (data-space x)
150 const [hoverX, setHoverX] = useState<number | null>(null);
151 const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
152 if (!showHover) return;
153 const rect = e.currentTarget.getBoundingClientRect();
154 const px = e.clientX - rect.left;
155 const xFrac = (px - padding.left) / (width - padding.left - padding.right);
156 const dataX = minX + Math.max(0, Math.min(1, xFrac)) * spanX;
157 setHoverX(dataX);
158 };
159 const onLeave = () => setHoverX(null);
160
161 function nearest(points: LinePoint[], x: number): LinePoint | null {
162 let best: LinePoint | null = null;
163 let bestD = Infinity;
164 for (const p of points) {
165 if (!Number.isFinite(p.y)) continue;
166 const d = Math.abs(p.x - x);
167 if (d < bestD) { bestD = d; best = p; }
168 }
169 return best;
170 }
171
172 // Get hover points for all series
173 const hoverPoints = useMemo(() => {
174 if (hoverX == null || !series.length) return [];
175 return series.map(s => {
176 const p = pickForHover(s.points, hoverX, !!bars);
177 return p ? { color: s.color, id: s.id, p } : null;
178 }).filter((hp): hp is {color: string; id: string; p: LinePoint} => hp !== null);
179 }, [hoverX, series, bars]);
180
181 // Primary hover point (for crosshair)
182 const hoverPrimary = hoverPoints.length > 0 ? hoverPoints[0] : null;
183
184 return (
185 <div className="w-full overflow-hidden">
186 <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" onMouseMove={onMove} onMouseLeave={onLeave}>
187 <rect x={0} y={0} width={width} height={height} fill="transparent" />
188 {/* Y axis */}
189 <line x1={padding.left} y1={padding.top} x2={padding.left} y2={padding.top + innerH} stroke="#999" strokeWidth={1} />
190 {/* X axis */}
191 <line x1={padding.left} y1={padding.top + innerH} x2={padding.left + innerW} y2={padding.top + innerH} stroke="#999" strokeWidth={1} />
192
193 {/* Helper lines: average temperature and 0°C */}
194 {hasTemperature && !bars && (
195 <g>
196 {/* Average temperature line */}
197 {avgTemp != null && avgTemp >= minY && avgTemp <= maxY && (
198 <line
199 x1={padding.left}
200 x2={padding.left + innerW}
201 y1={sy(avgTemp)}
202 y2={sy(avgTemp)}
203 stroke="#f59e0b"
204 strokeWidth={1}
205 strokeDasharray="4,3"
206 opacity={0.9}
207 />
208 )}
209 {/* Average temperature label (left, above line) */}
210 {avgTemp != null && avgTemp >= minY && avgTemp <= maxY && (
211 <text
212 x={padding.left + 6}
213 y={Math.max(padding.top + 10, sy(avgTemp) - 4)}
214 fontSize={11}
215 textAnchor="start"
216 fill="#92400e"
217 fontWeight="600"
218 >
219 {`${avgTemp.toFixed(1)}${yUnit ? " " + yUnit : ""}`}
220 </text>
221 )}
222 {/* Zero Celsius line */}
223 {minY <= 0 && maxY >= 0 && (
224 <line
225 x1={padding.left}
226 x2={padding.left + innerW}
227 y1={sy(0)}
228 y2={sy(0)}
229 stroke="#94a3b8"
230 strokeWidth={1}
231 strokeDasharray="3,3"
232 opacity={0.8}
233 />
234 )}
235 </g>
236 )}
237
238 {/* Y ticks */}
239 {yTicks.map((v, i) => (
240 <g key={`yt-${i}`}>
241 <line x1={padding.left - 4} y1={sy(v)} x2={padding.left} y2={sy(v)} stroke="#999" strokeWidth={1} />
242 <text x={padding.left - 6} y={sy(v) + 3} fontSize={10} textAnchor="end" fill="#666">
243 {v.toFixed(1)}
244 </text>
245 </g>
246 ))}
247
248 {/* X ticks */}
249 {xTicks.map((v, i) => (
250 <g key={`xt-${i}`}>
251 <line x1={sx(v)} y1={padding.top + innerH} x2={sx(v)} y2={padding.top + innerH + 4} stroke="#999" strokeWidth={1} />
252 <text x={sx(v)} y={padding.top + innerH + 14} fontSize={10} textAnchor="middle" fill="#666">
253 {xTickFormatter ? xTickFormatter(v) : String(Math.round(v))}
254 </text>
255 </g>
256 ))}
257
258 {/* Bars (optional) */}
259 {bars && series.map((s) => {
260 // derive bar width from data spacing to avoid overlap
261 let derivedBW: number | null = null;
262 const xsS = s.points.map((pt) => pt.x).filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
263 if (xsS.length >= 2) {
264 let dx = Infinity;
265 for (let i = 1; i < xsS.length; i++) dx = Math.min(dx, xsS[i] - xsS[i - 1]);
266 if (Number.isFinite(dx) && dx > 0) derivedBW = dx * 0.4; // 40% of spacing (thinner)
267 }
268 const bw = barWidth ?? derivedBW ?? (spanX / (xTickCount * 2));
269 const pxW = Math.max(1, barWidthPx ?? ((bw / spanX) * innerW));
270 return (
271 <g key={`bars-${s.id}`}>
272 {s.points.map((pt, idx) => {
273 if (!Number.isFinite(pt.y)) return null;
274 const cx = sx(pt.x);
275 const y0 = sy(0);
276 const y1 = sy(pt.y);
277 const top = Math.min(y0, y1);
278 const h = Math.abs(y1 - y0);
279 return <rect key={idx} x={cx - pxW / 2} y={top} width={pxW} height={h} fill={s.color} opacity={0.8} />;
280 })}
281 </g>
282 );
283 })}
284
285 {/* Lines */}
286 {!bars && (() => {
287 // Compute 1px downward offset for 'Gefühlte Temperatur' when overlapping exactly with a base 'Temperatur' series
288 const isFeel = (id: string) => id.toLowerCase().includes("gefühlte temperatur");
289 const isBaseTemp = (id: string) => id.toLowerCase().includes("temperatur") && !id.toLowerCase().includes("gefühlte");
290 const eps = 1e-9;
291 // Build lookup maps for base temperature series: x -> y
292 const baseMaps = series.map((s) => {
293 if (!isBaseTemp(s.id)) return null as Map<number, number> | null;
294 const m = new Map<number, number>();
295 for (const pt of s.points) if (Number.isFinite(pt.y)) m.set(pt.x, pt.y);
296 return m;
297 });
298 // Offsets per series point
299 const offsets: number[][] = series.map((s) => new Array(s.points.length).fill(0));
300 series.forEach((s, si) => {
301 if (!isFeel(s.id)) return;
302 for (let pi = 0; pi < s.points.length; pi++) {
303 const pt = s.points[pi];
304 if (!Number.isFinite(pt.y)) continue;
305 // If any base map has same x and nearly equal y, offset by +1px
306 let overlap = false;
307 for (const bm of baseMaps) {
308 if (!bm) continue;
309 const y = bm.get(pt.x);
310 if (y != null && Number.isFinite(y) && Math.abs(y - (pt.y as number)) < eps) { overlap = true; break; }
311 }
312 if (overlap) offsets[si][pi] = 2; // shift down by 1px
313 }
314 });
315 return (
316 <g>
317 {series.map((s, i) => (
318 <path key={s.id} d={pathForWithOffset(s.points, offsets[i])} stroke={s.color} strokeWidth={2} fill="none" />
319 ))}
320 </g>
321 );
322 })()}
323
324 {/* Legend (optional) */}
325 {showLegend && (
326 <g transform={`translate(${padding.left + 120}, ${padding.top})`}>
327 {series.map((s, i) => (
328 <g key={`lg-${s.id}`} transform={`translate(${i * 140}, 0)`}>
329 <rect width={12} height={2} y={5} fill={s.color} />
330 <text x={16} y={8} fontSize={11} fill="#333">{s.id}</text>
331 </g>
332 ))}
333 </g>
334 )}
335
336 {yLabel && (
337 <text x={padding.left} y={padding.top - 6} fontSize={12} fill="#333" fontWeight="500">{yLabel}</text>
338 )}
339
340 {xLabel && (
341 <text x={padding.left + innerW / 2} y={padding.top + innerH + 24} fontSize={11} fill="#333" textAnchor="middle">{xLabel}</text>
342 )}
343
344 {/* Hover crosshair and tooltip */}
345 {showHover && hoverX != null && (
346 <g>
347 {/* Crosshair */}
348 <line x1={sx(hoverPrimary ? hoverPrimary.p.x : hoverX)} y1={padding.top} x2={sx(hoverPrimary ? hoverPrimary.p.x : hoverX)} y2={padding.top + innerH} stroke="#94a3b8" strokeDasharray="3,3" />
349 {/* Points markers */}
350 {hoverPrimary && (
351 <circle cx={sx(hoverPrimary.p.x)} cy={sy(hoverPrimary.p.y)} r={3} fill={hoverPrimary.color} stroke="#fff" strokeWidth={1} />
352 )}
353 {/* Enhanced tooltip: date time and values for all series */}
354 <g transform={`translate(${padding.left + 6}, ${padding.top + innerH - (42 + hoverPoints.length * 20)})`}>
355 <rect
356 width={220}
357 height={36 + hoverPoints.length * 20}
358 fill="rgba(255,255,255,0.95)"
359 stroke="#cbd5e1"
360 rx={4}
361 />
362 {/* Time header */}
363 <text x={6} y={16} fontSize={11} fontWeight="bold" fill="#111">
364 {hoverTimeFormatter
365 ? hoverTimeFormatter(hoverPrimary ? hoverPrimary.p.x : hoverX)
366 : (xTickFormatter
367 ? xTickFormatter(hoverPrimary ? hoverPrimary.p.x : hoverX)
368 : String(Math.round(hoverPrimary ? hoverPrimary.p.x : hoverX)))}
369 </text>
370
371 {/* Separator line */}
372 <line x1={6} y1={24} x2={214} y2={24} stroke="#e2e8f0" strokeWidth={1.5} />
373
374 {/* Values for each series */}
375 {hoverPoints.map((hp, idx) => (
376 <g key={`hp-${idx}`} transform={`translate(0, ${36 + idx * 20})`}>
377 {/* Color indicator */}
378 <rect x={6} y={-8} width={10} height={10} fill={hp.color} rx={2} />
379 {/* Series name */}
380 <text x={22} y={0} fontSize={11} fill="#333">{hp.id}</text>
381 {/* Value */}
382 <text x={214} y={0} fontSize={11} textAnchor="end" fill="#111" fontWeight="500">
383 {valueFormatter
384 ? valueFormatter(hp.p.y)
385 : `${hp.p.y.toFixed(2)}${yUnit ? " " + yUnit : ""}`}
386 </text>
387 </g>
388 ))}
389 </g>
390 </g>
391 )}
392 </svg>
393 </div>
394 );
395}