Weather Station / ECOWITT / DNT
0

Configure Feed

Select the types of activity you want to include in your feed.

at stats2.0 16 kB View raw
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}