"use client"; import { type ReactNode, useCallback, useMemo } from "react"; import { clipRevealTransition } from "./animation"; import { defaultScatterColors, useChartHover, useChartStable, useYScale, } from "./chart-context"; import { useChartLegendHover } from "./chart-legend-hover"; import { getSeriesMarkerVisualExtent, SeriesPointMarker, type SeriesPointMarkerStyle, StaticSeriesPointMarker, } from "./series-point-marker"; export interface SeriesMarkersProps extends SeriesPointMarkerStyle { dataKey: string; /** Marker fill color. Defaults to series stroke or chart palette color. */ fill?: string; /** Whether to animate markers with clip reveal. Default: true */ animate?: boolean; } interface PointAt { index: number; cx: number; cy: number; revealDelay: number; } interface MarkerStyle { fill: string; stroke: string; strokeWidth: number; ringGap: number; outlineWidth: number; outlineColor?: string; radius: number; } export function SeriesMarkers({ dataKey, fill, stroke, strokeWidth = 2, ringGap = 2, outlineWidth = 0, outlineColor, radius = 5, animate = true, fadeOnHover = true, inactiveOpacity = 0.5, inactiveBlur = 2, enterBlur = 2, showActiveHighlight = true, }: SeriesMarkersProps) { // Stable slice only. Hover-driven dim + active-highlight live in the inner // / components, so // mouse motion does not re-render the full point grid. const { data, xScale, innerWidth, enterTransition, animationDuration, revealEpoch, isLoaded, xAccessor, lines, } = useChartStable(); const seriesIndex = useMemo(() => { const index = lines.findIndex((line) => line.dataKey === dataKey); return index >= 0 ? index : 0; }, [lines, dataKey]); const seriesConfig = lines[seriesIndex]; const yScale = useYScale(seriesConfig?.yAxisId); const seriesColor = defaultScatterColors[seriesIndex % defaultScatterColors.length] ?? defaultScatterColors[0]; const resolvedFill = fill ?? seriesConfig?.stroke ?? seriesColor; const resolvedStroke = stroke ?? resolvedFill; const visualExtent = useMemo( () => getSeriesMarkerVisualExtent({ radius, strokeWidth, ringGap, outlineWidth, showActiveHighlight, }), [radius, strokeWidth, ringGap, outlineWidth, showActiveHighlight] ); const revealDurationSec = clipRevealTransition(enterTransition).duration ?? animationDuration / 1000; const enterDuration = 0.5; const isRevealing = animate && !isLoaded; const getY = useCallback( (d: Record) => { const value = d[dataKey]; return typeof value === "number" ? (yScale(value) ?? 0) : null; }, [dataKey, yScale] ); const points = useMemo( () => data.flatMap((d, index) => { const cy = getY(d); if (cy === null) { return []; } const cx = xScale(xAccessor(d)) ?? 0; const leadingEdge = Math.max(0, cx - visualExtent); const revealDelay = innerWidth > 0 && isRevealing ? (leadingEdge / innerWidth) * revealDurationSec : 0; return [{ index, cx, cy, revealDelay }]; }), [ data, getY, xScale, xAccessor, innerWidth, isRevealing, revealDurationSec, visualExtent, ] ); // Memo so the inner sees a stable prop and // can be cheaply re-rendered on hover without re-creating the spread. const markerStyle = useMemo( () => ({ fill: resolvedFill, stroke: resolvedStroke, strokeWidth, ringGap, outlineWidth, outlineColor, radius, }), [ resolvedFill, resolvedStroke, strokeWidth, ringGap, outlineWidth, outlineColor, radius, ] ); if (isRevealing) { return ( {points.map((point) => ( ))} ); } // Stable base layer — its children come from the parent and stay // referentially identical when the dim wrapper re-renders for hover. const baseMarkers = points.map((point) => ( )); const activeScale = showActiveHighlight ? 1.35 : 1; return ( {baseMarkers} ); } SeriesMarkers.displayName = "SeriesMarkers"; interface SeriesMarkersDimWrapperProps { enabled: boolean; inactiveOpacity: number; inactiveBlur: number; seriesIndex: number; children: ReactNode; } /** * Wraps the stable point grid with hover-driven opacity + blur. Subscribes to * hover internally so the grid (passed as `children`) keeps a stable reference * and React skips reconciling it when this wrapper re-renders. */ function SeriesMarkersDimWrapper({ enabled, inactiveOpacity, inactiveBlur, seriesIndex, children, }: SeriesMarkersDimWrapperProps) { const { tooltipData } = useChartHover(); const { hoveredIndex: legendHoveredIndex } = useChartLegendHover(); const isLegendDimmed = legendHoveredIndex !== null && legendHoveredIndex !== seriesIndex; const dimBase = enabled && (tooltipData !== null || isLegendDimmed); return ( 0 ? `blur(${inactiveBlur}px)` : "none", }} > {children} ); } interface SeriesMarkersActiveHighlightProps { enabled: boolean; points: PointAt[]; markerStyle: MarkerStyle; activeScale: number; } /** * Renders the scaled "active" marker on top of the base grid. Subscribes to * hover internally; the parent doesn't re-render on cursor motion. */ function SeriesMarkersActiveHighlight({ enabled, points, markerStyle, activeScale, }: SeriesMarkersActiveHighlightProps) { const { tooltipData } = useChartHover(); if (!enabled || tooltipData === null) { return null; } const activePoint = points.find((point) => point.index === tooltipData.index); if (!activePoint) { return null; } return ( ); } export default SeriesMarkers;