"use client"; import { curveMonotoneX } from "@visx/curve"; // biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type type CurveFactory = any; import { AreaClosed, LinePath } from "@visx/shape"; import { motion } from "motion/react"; import { useCallback, useId, useMemo } from "react"; import { chartCssVars, useChart } from "./chart-context"; export type Momentum = "up" | "down" | "flat"; export interface MomentumColors { up: string; down: string; flat: string; } export function detectMomentum( data: Record[], dataKey: string, lookback = 20 ): Momentum { if (data.length < 5) { return "flat"; } const start = Math.max(0, data.length - lookback); let min = Number.POSITIVE_INFINITY; let max = Number.NEGATIVE_INFINITY; for (let i = start; i < data.length; i++) { const v = data[i]?.[dataKey]; if (typeof v === "number") { if (v < min) { min = v; } if (v > max) { max = v; } } } const range = max - min; if (range === 0) { return "flat"; } const tailStart = Math.max(start, data.length - 5); const first = (data[tailStart]?.[dataKey] as number) ?? 0; const last = (data.at(-1)?.[dataKey] as number) ?? 0; const delta = last - first; const threshold = range * 0.12; if (delta > threshold) { return "up"; } if (delta < -threshold) { return "down"; } return "flat"; } export interface LiveLineProps { /** Key in data to use for y values */ dataKey: string; /** Stroke color. Default: var(--chart-line-primary) */ stroke?: string; /** Stroke width. Default: 2 */ strokeWidth?: number; /** Curve function. Default: curveMonotoneX */ curve?: CurveFactory; /** Show gradient fill under the curve. Default: true */ fill?: boolean; /** Show pulsing live dot at the right edge. Default: true */ pulse?: boolean; /** Radius of the live dot. Default: 4 */ dotSize?: number; /** Show value badge pill at the live tip. Default: true */ badge?: boolean; /** Value label formatter for the badge */ formatValue?: (v: number) => string; /** * When set, the line/fill color changes based on momentum direction. * Overrides `stroke` for the line and fill (dot always uses momentum colors). */ momentumColors?: MomentumColors; } LiveLine.displayName = "LiveLine"; export function LiveLine({ dataKey, stroke = chartCssVars.linePrimary, strokeWidth = 2, curve = curveMonotoneX, fill = true, pulse = true, dotSize = 4, badge = true, formatValue = (v: number) => v.toFixed(2), momentumColors, }: LiveLineProps) { const { data, xScale, yScale, innerWidth, innerHeight, xAccessor, lines, tooltipData, } = useChart(); const isScrubbing = tooltipData !== null; const uid = useId(); const gradientId = `live-line-grad-${uid}`; const areaGradientId = `live-area-grad-${uid}`; const fadeId = `live-fade-${uid}`; const fadeMaskId = `live-fade-mask-${uid}`; const getX = useCallback( (d: Record) => xScale(xAccessor(d)) ?? 0, [xScale, xAccessor] ); const getY = useCallback( (d: Record) => { const v = d[dataKey]; return typeof v === "number" ? (yScale(v) ?? 0) : 0; }, [dataKey, yScale] ); // The second-to-last point is the "now" position (live tip). // The last point is the queued future point for the fade-out zone. const nowPoint = data.length >= 2 ? data.at(-2) : data.at(-1); const liveValue = nowPoint && typeof nowPoint[dataKey] === "number" ? (nowPoint[dataKey] as number) : 0; const liveDotX = nowPoint ? (xScale(xAccessor(nowPoint)) ?? 0) : innerWidth; const liveDotY = yScale(liveValue) ?? 0; const momentum = useMemo( () => detectMomentum(data, dataKey), [data, dataKey] ); const defaultMomentumColors: MomentumColors = { up: "var(--chart-1)", down: "var(--chart-5)", flat: stroke, }; const dotMomentumColors = momentumColors ?? defaultMomentumColors; const dotColor = dotMomentumColors[momentum]; // Find the line config for this dataKey to get the resolved stroke const lineConfig = lines.find((l) => l.dataKey === dataKey); const baseStroke = lineConfig?.stroke ?? stroke; const resolvedStroke = momentumColors ? momentumColors[momentum] : baseStroke; return ( <> {liveDotX < innerWidth - 1 ? ( <> ) : ( )} {/* Area fill */} {fill && data.length > 1 && ( )} {/* Line */} {data.length > 1 && ( )} {/* Dashed horizontal line at current value */} {/* Live indicator (dot + badge) — dims when crosshair is active */} {/* Pulsing dot */} {pulse && ( )} {/* Badge — use popover vars so text is never white-on-white. Flip it to the left of the dot when it would overflow the right edge, so the value never spills outside the card. */} {badge && (() => { const badgeWidth = formatValue(liveValue).length * 7.5 + 16; const flip = liveDotX + 12 + badgeWidth > innerWidth; const badgeX = flip ? liveDotX - 12 - badgeWidth : liveDotX + 12; return ( {formatValue(liveValue)} ); })()} ); } export default LiveLine;