Bklit UI
componentchart-seriessource: ok · hit
Chart Series Layer
Shared line and area series rendering helpers (paths, markers, highlights, dash tails)
npx shadcn add "https://ui.bklit.com/r/chart-series.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Registry dependencies
@bklit/chart-context@bklit/chart-animation@bklit/chart-tooltip
Files15 · source cached 10/8/2026, 6:50:20 PM
src/charts/path-stroke-utils.ts
// biome-ignore-all lint/correctness/useExhaustiveDependencies: usePathStrokeMetrics intentionally accepts caller-controlled deps
import { type RefObject, useEffect, useState } from "react";
export function findPathLengthAtX(
path: SVGPathElement | null,
pathLength: number,
targetX: number
): number {
if (!path || pathLength === 0) {
return 0;
}
let low = 0;
let high = pathLength;
const tolerance = 0.5;
while (high - low > tolerance) {
const mid = (low + high) / 2;
const point = path.getPointAtLength(mid);
if (point.x < targetX) {
low = mid;
} else {
high = mid;
}
}
return (low + high) / 2;
}
interface PathStrokeMetrics {
pathD: string | null;
pathLength: number;
}
const EMPTY_METRICS: PathStrokeMetrics = { pathD: null, pathLength: 0 };
/**
* Caller passes the references that drive the rendered path (renderData,
* innerWidth, etc.) as `deps`. A stringified summary like
* `${renderData.length}:${innerWidth}` is *not* safe here — same-length
* in-place mutations of `renderData` keep the summary identical, so the
* effect would never re-fire and `pathD`/`pathLength` would stay frozen on
* the previous geometry (the area fill repaints from `renderData` directly
* and would diverge from the stroke).
*/
export function usePathStrokeMetrics(
pathRef: RefObject<SVGPathElement | null>,
deps: readonly unknown[]
): PathStrokeMetrics {
const [metrics, setMetrics] = useState<PathStrokeMetrics>(EMPTY_METRICS);
useEffect(() => {
const path = pathRef.current;
if (!path) {
return;
}
const d = path.getAttribute("d");
const len = d ? path.getTotalLength() : 0;
setMetrics((prev) =>
prev.pathD === d && prev.pathLength === len
? prev
: { pathD: d, pathLength: len }
);
}, deps);
return metrics;
}
export function resolveDashTailBounds(
dashFromIndex: number | undefined,
dataLength: number
): boolean {
return (
dashFromIndex != null &&
dashFromIndex >= 0 &&
dashFromIndex < dataLength - 1
);
}
export function resolveDashStartX(
data: Record<string, unknown>[],
dashFromIndex: number,
xScale: (value: Date | number) => number | undefined,
xAccessor: (datum: Record<string, unknown>) => Date | number
): number {
const dashFromPoint = data[dashFromIndex];
if (!dashFromPoint) {
return 0;
}
return xScale(xAccessor(dashFromPoint)) ?? 0;
}
src/charts/series-path-utils.ts
import { line as d3Line } from "d3-shape";
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;
export interface SeriesPathPoint {
x: number;
y: number;
key: string;
}
export function computeSeriesPathPoints(
data: Record<string, unknown>[],
xAccessor: (datum: Record<string, unknown>) => Date,
xScale: (value: Date) => number | undefined,
yScale: (value: number) => number | undefined,
dataKey: string
): SeriesPathPoint[] {
return data.map((datum, index) => {
const xValue = xAccessor(datum);
const yValue = datum[dataKey];
return {
x: xScale(xValue) ?? 0,
y: typeof yValue === "number" ? (yScale(yValue) ?? 0) : 0,
key: String(xValue.getTime?.() ?? index),
};
});
}
export function interpolateSeriesPathPoints(
from: SeriesPathPoint[],
to: SeriesPathPoint[],
progress: number
): SeriesPathPoint[] {
if (progress >= 1) {
return to;
}
if (progress <= 0) {
return from.length > 0 ? from : to;
}
const fromByKey = new Map(from.map((point) => [point.key, point]));
return to.map((target, index) => {
const source = fromByKey.get(target.key);
if (source) {
return {
key: target.key,
x: source.x + (target.x - source.x) * progress,
y: source.y + (target.y - source.y) * progress,
};
}
const previousTarget = index > 0 ? to[index - 1] : undefined;
const previousSource = previousTarget
? fromByKey.get(previousTarget.key)
: undefined;
const nextTarget = index < to.length - 1 ? to[index + 1] : undefined;
const nextSource = nextTarget ? fromByKey.get(nextTarget.key) : undefined;
const anchor = previousSource ?? nextSource ?? from[0] ?? target;
return {
key: target.key,
x: anchor.x + (target.x - anchor.x) * progress,
y: anchor.y + (target.y - anchor.y) * progress,
};
});
}
export function seriesPathFromPoints(
points: SeriesPathPoint[],
curve: CurveFactory
): string {
if (points.length === 0) {
return "";
}
const generator = d3Line<SeriesPathPoint>()
.x((point) => point.x)
.y((point) => point.y)
.curve(curve);
return generator(points) ?? "";
}
export function seriesPathTransitionSignature({
renderData,
xAccessor,
dataKey,
innerWidth,
xDomainMin,
xDomainMax,
}: {
renderData: Record<string, unknown>[];
xAccessor: (datum: Record<string, unknown>) => Date;
dataKey: string;
innerWidth: number;
xDomainMin: number;
xDomainMax: number;
}): string {
const values = renderData.map((datum) => {
const xValue = xAccessor(datum);
const yValue = datum[dataKey];
return `${xValue.getTime()}:${typeof yValue === "number" ? yValue : ""}`;
});
return `${innerWidth}|${xDomainMin}|${xDomainMax}|${values.join(",")}`;
}
src/charts/use-animated-series-path.ts
"use client";
import { animate, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { LINE_LOADING_PULSE_EASE } from "./line-loading-timing";
import {
computeSeriesPathPoints,
interpolateSeriesPathPoints,
type SeriesPathPoint,
seriesPathFromPoints,
seriesPathTransitionSignature,
} from "./series-path-utils";
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;
export interface UseAnimatedSeriesPathOptions {
renderData: Record<string, unknown>[];
xAccessor: (datum: Record<string, unknown>) => Date;
xScale: (value: Date) => number | undefined;
yScale: (value: number) => number | undefined;
dataKey: string;
curve: CurveFactory;
chartPhase: string;
durationMs: number;
innerWidth: number;
enabled: boolean;
}
export function useAnimatedSeriesPath({
renderData,
xAccessor,
xScale,
yScale,
dataKey,
curve,
chartPhase,
durationMs,
innerWidth,
enabled,
}: UseAnimatedSeriesPathOptions) {
const reducedMotion = useReducedMotion();
const [animatedPoints, setAnimatedPoints] = useState<
SeriesPathPoint[] | null
>(null);
const displayedPointsRef = useRef<SeriesPathPoint[] | null>(null);
const animatingRef = useRef(false);
const xScaleDomain = useMemo(() => {
const scaleWithDomain = xScale as { domain?: () => [Date, Date] };
return scaleWithDomain.domain?.() ?? [new Date(0), new Date(0)];
}, [xScale]);
const transitionSignature = useMemo(
() =>
seriesPathTransitionSignature({
renderData,
xAccessor,
dataKey,
innerWidth,
xDomainMin: xScaleDomain[0]?.getTime?.() ?? 0,
xDomainMax: xScaleDomain[1]?.getTime?.() ?? 0,
}),
[renderData, xAccessor, dataKey, innerWidth, xScaleDomain]
);
const targetPoints = useMemo(
() =>
computeSeriesPathPoints(renderData, xAccessor, xScale, yScale, dataKey),
[renderData, xAccessor, xScale, yScale, dataKey]
);
const prevTransitionSignatureRef = useRef(transitionSignature);
useEffect(() => {
if (!animatingRef.current) {
displayedPointsRef.current = targetPoints;
}
}, [targetPoints]);
useEffect(() => {
const shouldAnimate =
enabled &&
!reducedMotion &&
chartPhase === "ready" &&
durationMs > 0 &&
renderData.length > 0;
if (!shouldAnimate) {
animatingRef.current = false;
setAnimatedPoints(null);
displayedPointsRef.current = targetPoints;
prevTransitionSignatureRef.current = transitionSignature;
return;
}
if (prevTransitionSignatureRef.current === transitionSignature) {
return;
}
prevTransitionSignatureRef.current = transitionSignature;
const fromPoints = displayedPointsRef.current ?? targetPoints;
if (fromPoints.length === 0) {
displayedPointsRef.current = targetPoints;
return;
}
animatingRef.current = true;
const fromSnapshot = fromPoints;
const control = animate(0, 1, {
duration: durationMs / 1000,
ease: [...LINE_LOADING_PULSE_EASE],
onUpdate: (progress) => {
const currentTarget = computeSeriesPathPoints(
renderData,
xAccessor,
xScale,
yScale,
dataKey
);
const next = interpolateSeriesPathPoints(
fromSnapshot,
currentTarget,
progress
);
displayedPointsRef.current = next;
setAnimatedPoints(next);
},
onComplete: () => {
animatingRef.current = false;
displayedPointsRef.current = targetPoints;
setAnimatedPoints(null);
},
});
return () => {
control.stop();
animatingRef.current = false;
};
}, [
transitionSignature,
chartPhase,
durationMs,
enabled,
reducedMotion,
renderData,
xAccessor,
xScale,
yScale,
dataKey,
targetPoints,
]);
const activePoints = animatedPoints ?? targetPoints;
const pathD = useMemo(
() => seriesPathFromPoints(activePoints, curve),
[activePoints, curve]
);
return {
pathD,
isPathAnimating: animatedPoints != null,
};
}
src/charts/highlight-segment-bounds.ts
import type { TooltipData } from "./chart-context";
import type { ChartSelection } from "./use-chart-interaction";
// Pure geometry for the hover-highlight band, split out from the hook so it can
// be unit-tested without React/motion (see __tests__).
//
// The band is the pixel x-range one data point either side of the hovered point:
// [ xScale(t(idx-1)), xScale(t(idx+1)) ]
// `<HighlightSegment>` then re-strokes the base path clipped to that band, so the
// highlight always traces the line itself. Selecting the band by data index
// assumes x is monotone along the path, which holds for a time series. On a curve
// that overshoots in x (curveNatural, curveBasis) a band edge can land a few
// pixels short, slightly narrowing the bright slice but never detaching it.
export interface SegmentBounds {
/** Left edge of the highlight band, in pixels. */
x: number;
/** Width of the highlight band, in pixels. */
width: number;
isActive: boolean;
}
export const INACTIVE_SEGMENT: SegmentBounds = {
x: 0,
width: 0,
isActive: false,
};
/**
* The highlight band `{x, width}` in pixel space, from the data + `xScale` plus
* the current hover/selection. Hover spans one data point either side of the dot
* (clamped to the ends); an active drag-selection uses the dragged pixel range
* directly and takes priority over hover.
*/
export function computeSegmentBounds(
data: Record<string, unknown>[],
xScale: (value: Date) => number | undefined,
xAccessor: (d: Record<string, unknown>) => Date,
tooltipData: Pick<TooltipData, "index"> | null | undefined,
selection:
| Pick<ChartSelection, "active" | "startX" | "endX">
| null
| undefined
): SegmentBounds {
if (data.length === 0) {
return INACTIVE_SEGMENT;
}
if (selection?.active) {
const x = Math.min(selection.startX, selection.endX);
const width = Math.abs(selection.endX - selection.startX);
return { x, width, isActive: true };
}
if (!tooltipData) {
return INACTIVE_SEGMENT;
}
const idx = tooltipData.index;
const startIdx = Math.max(0, idx - 1);
const endIdx = Math.min(data.length - 1, idx + 1);
const startPoint = data[startIdx];
const endPoint = data[endIdx];
if (!(startPoint && endPoint)) {
return INACTIVE_SEGMENT;
}
const startX = xScale(xAccessor(startPoint)) ?? 0;
const endX = xScale(xAccessor(endPoint)) ?? 0;
return { x: startX, width: Math.max(0, endX - startX), isActive: true };
}
src/charts/highlight-segment.tsx
"use client";
import { type MotionValue, motion } from "motion/react";
import { type RefObject, useId } from "react";
// Hover-highlight overlay: re-strokes the base path `d`, clipped to a vertical
// band whose x/width spring to track the hovered point, so only the segment
// around the dot shows brighter. The band comes from `useHighlightSegment`;
// because the bright stroke reuses the base `d`, it follows whatever curve is
// drawn (see `highlight-segment-bounds.ts` for the band-extent caveat).
export interface HighlightSegmentProps {
/** Ref to the rendered base stroke `<path>` — its `d` is re-used verbatim. */
pathRef: RefObject<SVGPathElement | null>;
/** Whether to render (caller gates on showHighlight + active + loaded). */
visible: boolean;
stroke: string;
strokeWidth: number;
/** Plot height — the clip band spans it fully. */
height: number;
/** Spring-eased left edge of the clip band (px). */
x: MotionValue<number>;
/** Spring-eased width of the clip band (px). */
width: MotionValue<number>;
}
export function HighlightSegment({
pathRef,
visible,
stroke,
strokeWidth,
height,
x,
width,
}: HighlightSegmentProps) {
const clipId = useId();
if (!(visible && pathRef.current)) {
return null;
}
return (
<>
<defs>
<clipPath id={clipId}>
<motion.rect height={height} width={width} x={x} y={0} />
</clipPath>
</defs>
<motion.path
animate={{ opacity: 1 }}
clipPath={`url(#${clipId})`}
d={pathRef.current.getAttribute("d") || ""}
exit={{ opacity: 0 }}
fill="none"
initial={{ opacity: 0 }}
stroke={stroke}
strokeLinecap="round"
strokeWidth={strokeWidth}
transition={{ duration: 0.4, ease: "easeInOut" }}
/>
</>
);
}
HighlightSegment.displayName = "HighlightSegment";
export default HighlightSegment;
src/charts/dash-tail-stroke.tsx
"use client";
import { useId } from "react";
export interface DashTailStrokeProps {
/** SVG path `d` for the full series (single curved path). */
pathD: string | null;
/** Total length of `pathD` in user units. */
pathLength: number;
/** Path length at which the dashed tail begins. */
dashStartLength: number;
/** X coordinate (chart inner space) where the tail clip begins. */
dashStartX: number;
innerWidth: number;
innerHeight: number;
/** Stroke paint — solid color or gradient url. */
stroke: string;
strokeWidth: number;
dashArray: string;
}
export function DashTailStroke({
pathD,
pathLength,
dashStartLength,
dashStartX,
innerWidth,
innerHeight,
stroke,
strokeWidth,
dashArray,
}: DashTailStrokeProps) {
const clipPathId = useId().replace(/:/g, "");
if (!pathD || pathLength <= 0 || dashStartLength >= pathLength) {
return null;
}
const pad = strokeWidth * 2;
const tailWidth = Math.max(0, innerWidth - dashStartX + pad);
return (
<>
<defs>
<clipPath id={clipPathId}>
<rect
height={innerHeight + pad}
width={tailWidth}
x={dashStartX - strokeWidth}
y={-strokeWidth}
/>
</clipPath>
</defs>
{/* Solid head — same curved path, gradient/fade preserved */}
<path
d={pathD}
fill="none"
stroke={stroke}
strokeDasharray={`${dashStartLength} ${Math.max(1, pathLength - dashStartLength)}`}
strokeLinecap="round"
strokeWidth={strokeWidth}
/>
{/* Dashed tail — clipped to x ≥ dashStartX so dashes follow the curve */}
<path
clipPath={`url(#${clipPathId})`}
d={pathD}
fill="none"
stroke={stroke}
strokeDasharray={dashArray}
strokeLinecap="round"
strokeWidth={strokeWidth}
/>
</>
);
}
src/charts/series-dash-tail-overlay.tsx
"use client";
import { memo, useMemo } from "react";
import { DashTailStroke } from "./dash-tail-stroke";
import { resolveDashStartX, resolveDashTailBounds } from "./path-stroke-utils";
interface SeriesDashTailOverlayProps {
dashFromIndex?: number;
dashArray: string;
data: Record<string, unknown>[];
pathD: string | null;
pathLength: number;
innerWidth: number;
innerHeight: number;
stroke: string;
strokeWidth: number;
xScale: (value: Date | number) => number | undefined;
xAccessor: (datum: Record<string, unknown>) => Date | number;
}
function SeriesDashTailOverlayImpl({
dashFromIndex,
dashArray,
data,
pathD,
pathLength,
innerWidth,
innerHeight,
stroke,
strokeWidth,
xScale,
xAccessor,
}: SeriesDashTailOverlayProps) {
const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length);
const dashStartX = useMemo(() => {
if (!hasDashTail || dashFromIndex == null) {
return 0;
}
return resolveDashStartX(data, dashFromIndex, xScale, xAccessor);
}, [hasDashTail, dashFromIndex, data, xScale, xAccessor]);
// Linear (index-based) approximation of the path length at `dashFromIndex`.
// The accurate version (`findPathLengthAtX` binary search via
// `getPointAtLength`) is exact but cost ~40 ms per series on a 365-point
// bezier — for charts with ~10 series that synchronously blocks the main
// thread for ~400 ms on the post-measurement re-render, swallowing the first
// second of the entrance animation.
//
// For evenly-spaced time-series data — the standard case — this is exact at
// flat regions of the curve and only differs by a pixel or two where the
// curve has steep y-variation, which is imperceptible at the dash boundary.
const dashStartLength = useMemo(() => {
if (!hasDashTail || dashFromIndex == null || pathLength <= 0) {
return 0;
}
return (dashFromIndex / Math.max(1, data.length - 1)) * pathLength;
}, [hasDashTail, dashFromIndex, data.length, pathLength]);
if (!hasDashTail || dashFromIndex == null || pathLength <= 0) {
return null;
}
return (
<DashTailStroke
dashArray={dashArray}
dashStartLength={dashStartLength}
dashStartX={dashStartX}
innerHeight={innerHeight}
innerWidth={innerWidth}
pathD={pathD}
pathLength={pathLength}
stroke={stroke}
strokeWidth={strokeWidth}
/>
);
}
// All props originate from the chart's stable context slice (data, xScale,
// xAccessor, …) or are mount-stable strings (gradient `url(#…)` ids). Shallow
// compare lets us skip the path-length binary search on every cursor move.
export const SeriesDashTailOverlay = memo(SeriesDashTailOverlayImpl);
src/charts/series-highlight-layer.tsx
"use client";
import type { RefObject } from "react";
import { useChartStable } from "./chart-context";
import { HighlightSegment } from "./highlight-segment";
import { useHighlightSegment } from "./use-highlight-segment";
interface SeriesHighlightLayerProps {
/** Caller already gated `showHighlight && showLine`; this just routes through. */
enabled: boolean;
height: number;
pathRef: RefObject<SVGPathElement | null>;
stroke: string;
strokeWidth: number;
}
/**
* Self-contained hover-highlight band over a series stroke.
*
* Owns the `useHighlightSegment` subscription (which reads both stable + hover
* context) so the parent <Area> / <Line> can stay on the stable slice. This
* component still re-renders on hover — that's the price of driving the
* highlight band — but it's a tiny leaf so the cost is bounded to itself.
*/
export function SeriesHighlightLayer({
enabled,
height,
pathRef,
stroke,
strokeWidth,
}: SeriesHighlightLayerProps) {
const { isLoaded } = useChartStable();
const { xSpring, widthSpring, isActive } = useHighlightSegment({ enabled });
return (
<HighlightSegment
height={height}
pathRef={pathRef}
stroke={stroke}
strokeWidth={strokeWidth}
visible={enabled && isActive && isLoaded}
width={widthSpring}
x={xSpring}
/>
);
}
SeriesHighlightLayer.displayName = "SeriesHighlightLayer";
export default SeriesHighlightLayer;
src/charts/chart-legend-hover.tsx
"use client";
import { createContext, type ReactNode, useContext, useMemo } from "react";
interface ChartLegendHoverContextValue {
hoveredIndex: number | null;
setHoveredIndex: (index: number | null) => void;
}
const ChartLegendHoverContext =
createContext<ChartLegendHoverContextValue | null>(null);
export function ChartLegendHoverProvider({
hoveredIndex,
onHoverChange,
children,
}: {
hoveredIndex: number | null;
onHoverChange: (index: number | null) => void;
children: ReactNode;
}) {
const value = useMemo(
() => ({ hoveredIndex, setHoveredIndex: onHoverChange }),
[hoveredIndex, onHoverChange]
);
return (
<ChartLegendHoverContext.Provider value={value}>
{children}
</ChartLegendHoverContext.Provider>
);
}
export function useChartLegendHover(): ChartLegendHoverContextValue {
const context = useContext(ChartLegendHoverContext);
return (
context ?? {
hoveredIndex: null,
setHoveredIndex: () => {
/* noop outside ChartLegendHoverProvider */
},
}
);
}
src/charts/series-hover-dim.tsx
"use client";
import { motion } from "motion/react";
import type { ReactNode } from "react";
import { useChartHover } from "./chart-context";
import { useChartLegendHover } from "./chart-legend-hover";
interface SeriesHoverDimProps {
/** Skip the dim entirely. */
enabled?: boolean;
/** Opacity to fade to while the chart is being hovered. */
dimOpacity?: number;
/** Tween duration in seconds. */
durationSec?: number;
/** Series index for multi-series legend hover dimming. */
seriesIndex?: number;
/** Stable chart visuals — area fill, stroke line, dashed tail, etc. */
children: ReactNode;
}
/**
* Wraps stable series visuals with a hover-driven opacity animation.
*
* The wrapper subscribes to chart hover state internally so the parent (Area /
* Line) can stay on the stable context slice. Children come in as a React prop:
* because the parent is not re-rendering on hover, the children element
* reference stays identical and React skips re-rendering them when this
* wrapper re-renders. That keeps expensive subtrees (`SeriesDashTailOverlay`
* and its `getPointAtLength` binary search) quiescent on cursor motion.
*/
export function SeriesHoverDim({
enabled = true,
dimOpacity = 0.5,
durationSec = 0.4,
seriesIndex,
children,
}: SeriesHoverDimProps) {
const { tooltipData, selection } = useChartHover();
const { hoveredIndex: legendHoveredIndex } = useChartLegendHover();
const isChartHovering = tooltipData !== null || selection?.active === true;
const isLegendDimmed =
legendHoveredIndex !== null &&
seriesIndex !== undefined &&
legendHoveredIndex !== seriesIndex;
const opacity =
enabled && (isChartHovering || isLegendDimmed) ? dimOpacity : 1;
return (
<motion.g
animate={{ opacity }}
initial={{ opacity: 1 }}
transition={{ duration: durationSec, ease: "easeInOut" }}
>
{children}
</motion.g>
);
}
SeriesHoverDim.displayName = "SeriesHoverDim";
export default SeriesHoverDim;
src/charts/series-point-marker.tsx
"use client";
import type { Variants } from "motion/react";
import { motion } from "motion/react";
import { memo } from "react";
import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation";
export interface SeriesPointMarkerStyle {
/** Fill color for the inner circle */
fill?: string;
/** Outer ring stroke color. Default: same as `fill` */
stroke?: string;
/** Outer ring stroke width in px. Default: 2. Set to 0 to disable. */
strokeWidth?: number;
/** Gap between the inner fill and outer ring in px. Default: 2 */
ringGap?: number;
/** Optional outer outline beyond the ring. Default: 0 */
outlineWidth?: number;
/** Outer outline color. Default: same as `stroke` */
outlineColor?: string;
/** Point radius in px. Default: 5 */
radius?: number;
/** Dim non-active points while hovering. Default: true */
fadeOnHover?: boolean;
/** Opacity for non-hovered points when `fadeOnHover` is true. Default: 0.5 */
inactiveOpacity?: number;
/**
* Blur in px for non-hovered points when `fadeOnHover` is true.
* Applied once on the dimmed layer (not per dot) for performance. Default: 2
*/
inactiveBlur?: number;
/** Initial blur in px during enter animation. Default: 2 */
enterBlur?: number;
/** Enlarge the active point while hovering. Default: true */
showActiveHighlight?: boolean;
}
interface MarkerCirclesProps {
fill?: string;
stroke?: string;
strokeWidth: number;
ringGap: number;
outlineWidth: number;
outlineColor?: string;
radius: number;
}
function MarkerCircles({
fill,
stroke,
strokeWidth,
ringGap,
outlineWidth,
outlineColor,
radius,
}: MarkerCirclesProps) {
const resolvedStroke = stroke ?? fill ?? "currentColor";
const resolvedOutlineColor = outlineColor ?? resolvedStroke;
const ringOuter = strokeWidth > 0 ? radius + ringGap + strokeWidth : radius;
const outlineRadius = outlineWidth > 0 ? ringOuter + outlineWidth / 2 : 0;
return (
<>
{outlineWidth > 0 ? (
<circle
cx={0}
cy={0}
fill="none"
r={outlineRadius}
stroke={resolvedOutlineColor}
strokeWidth={outlineWidth}
/>
) : null}
<circle cx={0} cy={0} fill={fill} r={radius} />
{strokeWidth > 0 ? (
<circle
cx={0}
cy={0}
fill="none"
r={radius + ringGap + strokeWidth / 2}
stroke={resolvedStroke}
strokeWidth={strokeWidth}
/>
) : null}
</>
);
}
export interface StaticSeriesPointMarkerProps extends SeriesPointMarkerStyle {
cx: number;
cy: number;
scale?: number;
}
export const StaticSeriesPointMarker = memo(function StaticSeriesPointMarker({
cx,
cy,
scale = 1,
fill,
stroke,
strokeWidth = 2,
ringGap = 2,
outlineWidth = 0,
outlineColor,
radius = 5,
}: StaticSeriesPointMarkerProps) {
return (
<g transform={`translate(${cx}, ${cy}) scale(${scale})`}>
<MarkerCircles
fill={fill}
outlineColor={outlineColor}
outlineWidth={outlineWidth}
radius={radius}
ringGap={ringGap}
stroke={stroke}
strokeWidth={strokeWidth}
/>
</g>
);
});
export interface SeriesPointMarkerProps extends SeriesPointMarkerStyle {
dataKey: string;
index: number;
cx: number;
cy: number;
revealDelay: number;
revealEpoch: number;
enterDuration: number;
}
/** Motion enter marker — used only while the chart reveal is running. */
export function SeriesPointMarker({
dataKey,
index,
cx,
cy,
enterBlur = 2,
revealDelay,
revealEpoch,
enterDuration,
fill,
stroke,
strokeWidth = 2,
ringGap = 2,
outlineWidth = 0,
outlineColor,
radius = 5,
}: SeriesPointMarkerProps) {
const variants: Variants = {
hidden: {
opacity: 0,
filter: `blur(${enterBlur}px)`,
scale: 1,
},
visible: {
opacity: 1,
filter: "blur(0px)",
scale: 1,
transition: {
delay: revealDelay,
duration: enterDuration,
ease: DEFAULT_CHART_ENTER_TRANSITION.ease,
},
},
};
return (
<g transform={`translate(${cx}, ${cy})`}>
<motion.g
animate="visible"
initial="hidden"
key={`${dataKey}-${index}-${revealEpoch}`}
variants={variants}
>
<MarkerCircles
fill={fill}
outlineColor={outlineColor}
outlineWidth={outlineWidth}
radius={radius}
ringGap={ringGap}
stroke={stroke}
strokeWidth={strokeWidth}
/>
</motion.g>
</g>
);
}
export function getSeriesMarkerVisualExtent(
style: Pick<
SeriesPointMarkerStyle,
| "radius"
| "strokeWidth"
| "ringGap"
| "outlineWidth"
| "showActiveHighlight"
>
): number {
const radius = style.radius ?? 5;
const strokeWidth = style.strokeWidth ?? 2;
const ringGap = style.ringGap ?? 2;
const outlineWidth = style.outlineWidth ?? 0;
const showActiveHighlight = style.showActiveHighlight ?? true;
const ring = strokeWidth > 0 ? ringGap + strokeWidth : 0;
const outline = outlineWidth > 0 ? outlineWidth : 0;
const highlightPad = showActiveHighlight ? radius * 0.35 : 0;
return radius + ring + outline + highlightPad + 2;
}
src/charts/series-markers.tsx
"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
// <SeriesMarkersDimWrapper> / <SeriesMarkersActiveHighlight> 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<string, unknown>) => {
const value = d[dataKey];
return typeof value === "number" ? (yScale(value) ?? 0) : null;
},
[dataKey, yScale]
);
const points = useMemo<PointAt[]>(
() =>
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 <SeriesMarkersActiveHighlight> sees a stable prop and
// can be cheaply re-rendered on hover without re-creating the spread.
const markerStyle = useMemo<MarkerStyle>(
() => ({
fill: resolvedFill,
stroke: resolvedStroke,
strokeWidth,
ringGap,
outlineWidth,
outlineColor,
radius,
}),
[
resolvedFill,
resolvedStroke,
strokeWidth,
ringGap,
outlineWidth,
outlineColor,
radius,
]
);
if (isRevealing) {
return (
<g>
{points.map((point) => (
<SeriesPointMarker
cx={point.cx}
cy={point.cy}
dataKey={dataKey}
enterBlur={enterBlur}
enterDuration={enterDuration}
index={point.index}
key={`${dataKey}-${point.index}`}
revealDelay={point.revealDelay}
revealEpoch={revealEpoch ?? 0}
{...markerStyle}
/>
))}
</g>
);
}
// 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) => (
<StaticSeriesPointMarker
cx={point.cx}
cy={point.cy}
key={`${dataKey}-${point.index}`}
{...markerStyle}
/>
));
const activeScale = showActiveHighlight ? 1.35 : 1;
return (
<g>
<SeriesMarkersDimWrapper
enabled={fadeOnHover}
inactiveBlur={inactiveBlur}
inactiveOpacity={inactiveOpacity}
seriesIndex={seriesIndex}
>
{baseMarkers}
</SeriesMarkersDimWrapper>
<SeriesMarkersActiveHighlight
activeScale={activeScale}
enabled={fadeOnHover}
markerStyle={markerStyle}
points={points}
/>
</g>
);
}
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 (
<g
opacity={dimBase ? inactiveOpacity : 1}
style={{
transition: "opacity 0.15s ease-in-out, filter 0.15s ease-in-out",
filter:
dimBase && inactiveBlur > 0 ? `blur(${inactiveBlur}px)` : "none",
}}
>
{children}
</g>
);
}
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 (
<StaticSeriesPointMarker
cx={activePoint.cx}
cy={activePoint.cy}
scale={activeScale}
{...markerStyle}
/>
);
}
export default SeriesMarkers;
src/charts/use-highlight-segment.ts
"use client";
import { useSpring } from "motion/react";
import { useMemo, useRef } from "react";
import { useChartConfig } from "./chart-config-context";
import { useChartHover, useChartStable } from "./chart-context";
import {
computeSegmentBounds,
INACTIVE_SEGMENT,
} from "./highlight-segment-bounds";
// Hover-highlight band for `line.tsx` and `area.tsx`. Computes the segment
// bounds and springs its x/width; `<HighlightSegment>` renders the clipped
// re-stroke. Spring tuning comes from `ChartConfigProvider.highlightSpring`.
// Stable + hover slices are read separately so callers can see the exact
// subscription surface (anything calling this hook will re-render on hover).
export interface HighlightSegmentResult {
xSpring: ReturnType<typeof useSpring>;
widthSpring: ReturnType<typeof useSpring>;
isActive: boolean;
}
/**
* @param enabled set false when there is no stroke to highlight (e.g. an area
* with `showLine={false}`); defaults true.
*/
export function useHighlightSegment({
enabled = true,
}: {
enabled?: boolean;
} = {}): HighlightSegmentResult {
const { data, xScale, xAccessor } = useChartStable();
const { tooltipData, selection } = useChartHover();
const { highlightSpring } = useChartConfig();
const bounds = useMemo(
() =>
enabled
? computeSegmentBounds(data, xScale, xAccessor, tooltipData, selection)
: INACTIVE_SEGMENT,
[enabled, data, xScale, xAccessor, tooltipData, selection]
);
const xSpring = useSpring(0, highlightSpring);
const widthSpring = useSpring(0, highlightSpring);
// Jump on inactive→active so the band appears at the hovered point instead
// of sliding in from x=0; ease on subsequent moves.
const wasActive = useRef(false);
if (bounds.isActive && !wasActive.current) {
xSpring.jump(bounds.x);
widthSpring.jump(bounds.width);
} else {
xSpring.set(bounds.x);
widthSpring.set(bounds.width);
}
wasActive.current = bounds.isActive;
return { xSpring, widthSpring, isActive: bounds.isActive };
}
src/charts/area-gradient-defs.tsx
import {
type FadeEdges,
fadeGradientStops,
resolveFadeSides,
viewportFadeGradientAttrs,
} from "./fade-edges";
interface AreaGradientDefsProps {
gradientId: string;
strokeGradientId: string;
edgeMaskId: string;
edgeGradientId: string;
fill: string;
fillOpacity: number;
gradientToOpacity: number;
/** 0–1: where the bottom stop sits (1 = full-height gradient). */
gradientSpan?: number;
resolvedStroke: string;
isPatternFill: boolean;
fadeEdges: FadeEdges;
innerWidth: number;
innerHeight: number;
}
export function AreaGradientDefs({
gradientId,
strokeGradientId,
edgeMaskId,
edgeGradientId,
fill,
fillOpacity,
gradientToOpacity,
gradientSpan = 1,
resolvedStroke,
isPatternFill,
fadeEdges,
innerWidth,
innerHeight,
}: AreaGradientDefsProps) {
const sides = resolveFadeSides(fadeEdges);
// Stroke gradient mirrors the area's edge fade so the line doesn't pop in
// past the faded fill. Skip emitting it when neither edge fades — the line
// can then paint a solid stroke instead of an unnecessary url(#...) ref.
const strokeStops = sides.any ? fadeGradientStops(sides) : null;
const showEdgeMask = sides.any && !isPatternFill;
const edgeStops = showEdgeMask ? fadeGradientStops(sides) : null;
const span = Math.min(1, Math.max(0.01, gradientSpan));
const midOffset = `${span * 100}%`;
return (
<defs>
{isPatternFill ? null : (
<linearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop
offset="0%"
style={{ stopColor: fill, stopOpacity: fillOpacity }}
/>
<stop
offset={midOffset}
style={{ stopColor: fill, stopOpacity: gradientToOpacity }}
/>
{span < 1 ? (
<stop
offset="100%"
style={{ stopColor: fill, stopOpacity: gradientToOpacity }}
/>
) : null}
</linearGradient>
)}
{strokeStops ? (
<linearGradient
id={strokeGradientId}
{...viewportFadeGradientAttrs(innerWidth)}
>
{strokeStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: resolvedStroke, stopOpacity: stop.opacity }}
/>
))}
</linearGradient>
) : null}
{edgeStops ? (
<>
<linearGradient
id={edgeGradientId}
{...viewportFadeGradientAttrs(innerWidth)}
>
{edgeStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: "white", stopOpacity: stop.opacity }}
/>
))}
</linearGradient>
<mask id={edgeMaskId}>
<rect
fill={`url(#${edgeGradientId})`}
height={innerHeight}
width={innerWidth}
x="0"
y="0"
/>
</mask>
</>
) : null}
</defs>
);
}
src/charts/fade-edges.ts
/**
* Which side(s) of a series should fade to transparent at the chart edges.
* - `true` → fade both edges (default for `<Line>`)
* - `false` → no fade (default for `<Area>`)
* - `"left"` / `"right"` → fade only that side
*/
export type FadeEdges = boolean | "left" | "right";
export interface FadeSides {
/** Whether the left edge should fade out. */
left: boolean;
/** Whether the right edge should fade out. */
right: boolean;
/** True if either side fades — use to gate gradient/mask defs. */
any: boolean;
}
export function resolveFadeSides(fade: FadeEdges): FadeSides {
if (fade === false) {
return { left: false, right: false, any: false };
}
if (fade === "left") {
return { left: true, right: false, any: true };
}
if (fade === "right") {
return { left: false, right: true, any: true };
}
return { left: true, right: true, any: true };
}
export interface FadeGradientStop {
offset: string;
opacity: number;
}
/**
* Stops for a horizontal fade gradient with opacity 0 at the faded side(s)
* and opacity 1 in the middle. Matches the historic 0/15/85/100 pattern.
*/
export function fadeGradientStops(sides: FadeSides): FadeGradientStop[] {
return [
{ offset: "0%", opacity: sides.left ? 0 : 1 },
{ offset: "15%", opacity: 1 },
{ offset: "85%", opacity: 1 },
{ offset: "100%", opacity: sides.right ? 0 : 1 },
];
}
/** Horizontal fade gradient pinned to the chart viewport (not the series path bounds). */
export function viewportFadeGradientAttrs(innerWidth: number) {
return {
gradientUnits: "userSpaceOnUse" as const,
x1: 0,
x2: innerWidth,
y1: 0,
y2: 0,
};
}