import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import type { MouseEvent } from 'react'; import './Sparkline.css'; interface SparklineProps { data: number[]; labels?: (string | number)[]; vbWidth?: number; height?: number; stroke?: string; strokeWidth?: number; maxPoints?: number; showGrid?: boolean; gridColor?: string; fillOpacity?: number; showMarker?: boolean; markerRadius?: number; showAxes?: boolean; yTickStep?: number; tickCountX?: number; paddingLeft?: number; paddingRight?: number; paddingTop?: number; paddingBottom?: number; showTooltip?: boolean; valueMin?: number; valueMax?: number | null; yFormatter?: (v: number) => string; tooltipFormatter?: ((v: number) => string) | null; } export default function Sparkline({ data, labels = [], vbWidth = 320, height = 80, stroke = '#008771', strokeWidth = 2, maxPoints = 120, showGrid = true, gridColor = 'rgba(0,0,0,0.08)', fillOpacity = 0.22, showMarker = true, markerRadius = 3, showAxes = false, yTickStep = 25, tickCountX = 4, paddingLeft = 56, paddingRight = 6, paddingTop = 6, paddingBottom = 20, showTooltip = false, valueMin = 0, valueMax = 100, yFormatter = (v: number) => `${Math.round(v)}%`, tooltipFormatter = null, }: SparklineProps) { const svgRef = useRef(null); const [measuredWidth, setMeasuredWidth] = useState(0); const [hoverIdx, setHoverIdx] = useState(-1); const reactId = useId(); const safeId = reactId.replace(/[^a-zA-Z0-9]/g, ''); const gradId = `spkGrad-${safeId}`; const shadowId = `spkShadow-${safeId}`; const glowId = `spkGlow-${safeId}`; useEffect(() => { const el = svgRef.current; if (!el) return; const measure = () => { const w = el.getBoundingClientRect?.().width || 0; if (w > 0) setMeasuredWidth(Math.round(w)); }; measure(); if (typeof ResizeObserver !== 'undefined') { const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); } window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); }, []); const effectiveVbWidth = measuredWidth > 0 ? measuredWidth : vbWidth; const drawWidth = Math.max(1, effectiveVbWidth - paddingLeft - paddingRight); const drawHeight = Math.max(1, height - paddingTop - paddingBottom); const nPoints = Math.min(data.length, maxPoints); const dataSlice = useMemo( () => (nPoints === 0 ? [] : data.slice(data.length - nPoints)), [data, nPoints], ); const labelsSlice = useMemo(() => { if (!labels?.length || nPoints === 0) return [] as (string | number)[]; const start = Math.max(0, labels.length - nPoints); return labels.slice(start); }, [labels, nPoints]); const yDomain = useMemo(() => { const min = valueMin; if (valueMax != null) return { min, max: valueMax }; let max = min; for (const v of dataSlice) { const n = Number(v); if (Number.isFinite(n) && n > max) max = n; } if (max <= min) max = min + 1; return { min, max: max * 1.1 }; }, [dataSlice, valueMin, valueMax]); const project = useCallback( (v: number) => { const { min, max } = yDomain; const span = max - min; if (span <= 0) return paddingTop + drawHeight; const clipped = Math.max(min, Math.min(max, Number(v) || 0)); const ratio = (clipped - min) / span; return Math.round(paddingTop + (drawHeight - ratio * drawHeight)); }, [yDomain, paddingTop, drawHeight], ); const pointsArr = useMemo<[number, number][]>(() => { if (nPoints === 0) return []; const w = drawWidth; const dx = nPoints > 1 ? w / (nPoints - 1) : 0; return dataSlice.map((v, i) => { const x = Math.round(paddingLeft + i * dx); return [x, project(v)]; }); }, [dataSlice, nPoints, drawWidth, paddingLeft, project]); const pointsStr = useMemo(() => pointsArr.map((p) => `${p[0]},${p[1]}`).join(' '), [pointsArr]); const areaPath = useMemo(() => { if (pointsArr.length === 0) return ''; const first = pointsArr[0]; const last = pointsArr[pointsArr.length - 1]; const baseY = paddingTop + drawHeight; const line = pointsStr.replace(/ /g, ' L '); return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`; }, [pointsArr, pointsStr, paddingTop, drawHeight]); const gridLines = useMemo(() => { if (!showGrid) return []; const h = drawHeight; const w = drawWidth; return [0, 0.25, 0.5, 0.75, 1].map((r) => { const y = Math.round(paddingTop + h * r); return { x1: paddingLeft, y1: y, x2: paddingLeft + w, y2: y }; }); }, [showGrid, drawHeight, drawWidth, paddingTop, paddingLeft]); const lastPoint = pointsArr.length === 0 ? null : pointsArr[pointsArr.length - 1]; const yTicks = useMemo(() => { if (!showAxes) return []; const { min, max } = yDomain; const out: { y: number; label: string }[] = []; if (valueMax === 100 && valueMin === 0 && yTickStep > 0) { for (let p = min; p <= max; p += yTickStep) { out.push({ y: project(p), label: yFormatter(p) }); } return out; } const ticks = 5; for (let i = 0; i < ticks; i++) { const v = min + ((max - min) * i) / (ticks - 1); out.push({ y: project(v), label: yFormatter(v) }); } return out; }, [showAxes, yDomain, valueMax, valueMin, yTickStep, project, yFormatter]); const xTicks = useMemo(() => { if (!showAxes) return []; if (nPoints === 0) return []; const m = Math.max(2, tickCountX); const w = drawWidth; const dx = nPoints > 1 ? w / (nPoints - 1) : 0; const out: { x: number; label: string }[] = []; for (let i = 0; i < m; i++) { const idx = Math.round((i * (nPoints - 1)) / (m - 1)); const label = labelsSlice[idx] != null ? String(labelsSlice[idx]) : String(idx); const x = Math.round(paddingLeft + idx * dx); out.push({ x, label }); } return out; }, [showAxes, labelsSlice, nPoints, tickCountX, drawWidth, paddingLeft]); const onMouseMove = useCallback( (evt: MouseEvent) => { if (!showTooltip || pointsArr.length === 0) return; const rect = evt.currentTarget.getBoundingClientRect(); const px = evt.clientX - rect.left; const x = (px / rect.width) * effectiveVbWidth; const dx = nPoints > 1 ? drawWidth / (nPoints - 1) : 0; const idx = Math.max(0, Math.min(nPoints - 1, Math.round((x - paddingLeft) / (dx || 1)))); setHoverIdx(idx); }, [showTooltip, pointsArr.length, effectiveVbWidth, nPoints, drawWidth, paddingLeft], ); const onMouseLeave = useCallback(() => setHoverIdx(-1), []); const hoverText = useMemo(() => { const idx = hoverIdx; if (idx < 0 || idx >= dataSlice.length) return ''; const raw = Number(dataSlice[idx] || 0); const fmt = tooltipFormatter || yFormatter; const val = fmt(Number.isFinite(raw) ? raw : 0); const lab = labelsSlice[idx] != null ? labelsSlice[idx] : ''; return `${val}${lab ? ' • ' + lab : ''}`; }, [hoverIdx, dataSlice, labelsSlice, tooltipFormatter, yFormatter]); const tooltipPillWidth = Math.max(48, hoverText.length * 6.2 + 14); const hoverPoint = hoverIdx >= 0 ? pointsArr[hoverIdx] : null; const tooltipX = hoverPoint ? Math.max( paddingLeft + 2, Math.min(effectiveVbWidth - paddingRight - tooltipPillWidth - 2, hoverPoint[0] - tooltipPillWidth / 2), ) : 0; return ( {showGrid && ( {gridLines.map((g, i) => ( ))} )} {showAxes && ( {yTicks.map((tk, i) => ( {tk.label} ))} {xTicks.map((tk, i) => ( {tk.label} ))} )} {areaPath && } {showMarker && lastPoint && ( <> )} {showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx] && ( {hoverText} )} ); }