diff --git a/apps/start/components.json b/apps/start/components.json index 58bb3a273..b4c1b2e41 100644 --- a/apps/start/components.json +++ b/apps/start/components.json @@ -10,6 +10,7 @@ "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -17,5 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" + "registries": { + "@bklit": "https://ui.bklit.com/r/{name}.json" + } } diff --git a/apps/start/package.json b/apps/start/package.json index 8c4c699a0..1714f66bc 100644 --- a/apps/start/package.json +++ b/apps/start/package.json @@ -87,12 +87,21 @@ "@trpc/server": "^11.17.0", "@trpc/tanstack-react-query": "^11.6.0", "@types/d3": "^7.4.3", + "@visx/curve": "4.0.1-alpha.0", + "@visx/event": "4.0.1-alpha.0", + "@visx/gradient": "4.0.1-alpha.0", + "@visx/grid": "4.0.1-alpha.0", + "@visx/pattern": "4.0.1-alpha.0", + "@visx/responsive": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", "bind-event-listener": "^3.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^0.2.1", "codemirror": "^6.0.1", "d3": "^7.8.5", + "d3-array": "^3.2.4", "date-fns": "^3.3.1", "debounce": "^2.2.0", "embla-carousel-autoplay": "^8.6.0", @@ -109,6 +118,7 @@ "lottie-react": "^2.4.0", "lucide-react": "^0.476.0", "mitt": "^3.0.1", + "motion": "^12.40.0", "nuqs": "^2.5.2", "prisma-error-enum": "^0.1.3", "pushmodal": "^1.0.3", @@ -145,7 +155,7 @@ "sonner": "^1.4.0", "sqlstring": "^2.3.3", "superjson": "^2.2.2", - "tailwind-merge": "^3.0.2", + "tailwind-merge": "^3.3.1", "tailwindcss": "^4.0.6", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.3.6", @@ -162,6 +172,7 @@ "@tanstack/devtools-event-client": "^0.3.3", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", + "@types/d3-array": "^3.2.2", "@types/lodash.debounce": "^4.0.9", "@types/lodash.isequal": "^4.5.8", "@types/lodash.throttle": "^4.1.9", diff --git a/apps/start/src/components/charts/animation.ts b/apps/start/src/components/charts/animation.ts new file mode 100644 index 000000000..f33492c19 --- /dev/null +++ b/apps/start/src/components/charts/animation.ts @@ -0,0 +1,33 @@ +import type { Transition } from "motion/react"; + +/** Default clip-reveal easing for cartesian charts. */ +export const DEFAULT_ANIMATION_EASING = "cubic-bezier(0.85, 0, 0.15, 1)"; + +export const DEFAULT_ANIMATION_DURATION_MS = 1100; + +/** Default enter transition — matches the original line chart reveal. */ +export const DEFAULT_CHART_ENTER_TRANSITION: Transition = { + type: "tween", + duration: DEFAULT_ANIMATION_DURATION_MS / 1000, + ease: [0.85, 0, 0.15, 1], +}; + +/** + * Clip-path width reveal must use tween — spring does not reliably animate SVG width. + */ +export function clipRevealTransition(enterTransition?: Transition): Transition { + if (enterTransition?.type === "tween") { + return enterTransition; + } + + const duration = + typeof enterTransition?.duration === "number" + ? enterTransition.duration + : DEFAULT_ANIMATION_DURATION_MS / 1000; + + return { + type: "tween", + duration, + ease: DEFAULT_CHART_ENTER_TRANSITION.ease, + }; +} diff --git a/apps/start/src/components/charts/area-chart.tsx b/apps/start/src/components/charts/area-chart.tsx new file mode 100644 index 000000000..b919ab430 --- /dev/null +++ b/apps/start/src/components/charts/area-chart.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { ParentSize } from "@visx/responsive"; +import type { Transition } from "motion/react"; +import { + Children, + isValidElement, + type ReactNode, + useMemo, + useRef, +} from "react"; +import { cn } from "@/lib/utils"; +import { Area, type AreaProps } from "./area"; +import type { LineConfig, Margin } from "./chart-context"; +import { PatternArea } from "./pattern-area"; +import { TimeSeriesChartInner } from "./time-series-chart-shell"; + +export interface AreaChartProps { + /** Data array - each item should have a date field and numeric values */ + data: Record[]; + /** Key in data for the x-axis (date). Default: "date" */ + xDataKey?: string; + /** Chart margins */ + margin?: Partial; + /** Animation duration in milliseconds. Default: 1100 */ + animationDuration?: number; + /** CSS easing for clip-reveal. Default: cubic-bezier(0.85, 0, 0.15, 1) */ + animationEasing?: string; + /** Motion enter transition (spring or cubic-bezier tween). */ + enterTransition?: Transition; + /** Signature of motion URL state — triggers reveal replay when it changes. */ + revealSignature?: string; + /** Aspect ratio as "width / height". Default: "2 / 1" */ + aspectRatio?: string; + /** Additional class name for the container */ + className?: string; + /** Child components (Area, Grid, ChartTooltip, etc.) */ + children: ReactNode; +} + +const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 }; + +function extractAreaConfigs(children: ReactNode): LineConfig[] { + const configs: LineConfig[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + + const childType = child.type as { + displayName?: string; + name?: string; + }; + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; + + const props = child.props as AreaProps | undefined; + const isPatternArea = + componentName === "PatternArea" || child.type === PatternArea; + const isAreaComponent = + componentName === "Area" || + child.type === Area || + (props && + typeof props.dataKey === "string" && + props.dataKey.length > 0 && + !isPatternArea); + + if (isAreaComponent && props?.dataKey) { + configs.push({ + dataKey: props.dataKey, + stroke: props.stroke || props.fill || "var(--chart-line-primary)", + strokeWidth: props.strokeWidth || 2, + }); + } + }); + + return configs; +} + +interface ChartInnerProps { + width: number; + height: number; + data: Record[]; + xDataKey: string; + margin: Margin; + animationDuration: number; + animationEasing?: string; + enterTransition?: Transition; + revealSignature?: string; + children: ReactNode; + containerRef: React.RefObject; +} + +function ChartInner({ + width, + height, + data, + xDataKey, + margin, + animationDuration, + animationEasing, + enterTransition, + revealSignature, + children, + containerRef, +}: ChartInnerProps) { + const lines = useMemo(() => extractAreaConfigs(children), [children]); + + return ( + + {children} + + ); +} + +export function AreaChart({ + data, + xDataKey = "date", + margin: marginProp, + animationDuration = 1100, + animationEasing, + enterTransition, + revealSignature, + aspectRatio = "2 / 1", + className = "", + children, +}: AreaChartProps) { + const containerRef = useRef(null); + const margin = { ...DEFAULT_MARGIN, ...marginProp }; + + return ( +
+ + {({ width, height }) => ( + + {children} + + )} + +
+ ); +} + +export { Area, type AreaProps } from "./area"; + +export default AreaChart; diff --git a/apps/start/src/components/charts/area-gradient-defs.tsx b/apps/start/src/components/charts/area-gradient-defs.tsx new file mode 100644 index 000000000..8615ff3f3 --- /dev/null +++ b/apps/start/src/components/charts/area-gradient-defs.tsx @@ -0,0 +1,95 @@ +import { + type FadeEdges, + fadeGradientStops, + resolveFadeSides, +} from "./fade-edges"; + +interface AreaGradientDefsProps { + gradientId: string; + strokeGradientId: string; + edgeMaskId: string; + edgeGradientId: string; + fill: string; + fillOpacity: number; + gradientToOpacity: number; + resolvedStroke: string; + isPatternFill: boolean; + fadeEdges: FadeEdges; + innerWidth: number; + innerHeight: number; +} + +export function AreaGradientDefs({ + gradientId, + strokeGradientId, + edgeMaskId, + edgeGradientId, + fill, + fillOpacity, + gradientToOpacity, + 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; + + return ( + + {isPatternFill ? null : ( + + + + + )} + + {strokeStops ? ( + + {strokeStops.map((stop) => ( + + ))} + + ) : null} + + {edgeStops ? ( + <> + + {edgeStops.map((stop) => ( + + ))} + + + + + + ) : null} + + ); +} diff --git a/apps/start/src/components/charts/area.tsx b/apps/start/src/components/charts/area.tsx new file mode 100644 index 000000000..f1f9e77d2 --- /dev/null +++ b/apps/start/src/components/charts/area.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { curveMonotoneX } from "@visx/curve"; +import { AreaClosed, LinePath } from "@visx/shape"; + +// CurveFactory type - simplified version compatible with visx +// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type +type CurveFactory = any; + +import { useCallback, useId, useRef } from "react"; +import { AreaGradientDefs } from "./area-gradient-defs"; +import { chartCssVars, useChartStable } from "./chart-context"; +import { type FadeEdges, resolveFadeSides } from "./fade-edges"; +import { + resolveDashTailBounds, + usePathStrokeMetrics, +} from "./path-stroke-utils"; +import { SeriesDashTailOverlay } from "./series-dash-tail-overlay"; +import { SeriesHighlightLayer } from "./series-highlight-layer"; +import { SeriesHoverDim } from "./series-hover-dim"; +import { SeriesMarkers } from "./series-markers"; +import type { SeriesPointMarkerStyle } from "./series-point-marker"; + +export interface AreaProps { + /** Key in data to use for y values */ + dataKey: string; + /** Fill color for the area gradient start. Default: var(--chart-line-primary) */ + fill?: string; + /** Fill opacity at the top of the area. Default: 0.4 */ + fillOpacity?: number; + /** Stroke color for the line. Default: same as fill */ + stroke?: string; + /** Stroke width. Default: 2 */ + strokeWidth?: number; + /** Curve function. Default: curveMonotoneX */ + curve?: CurveFactory; + /** Whether to animate the area. Default: true */ + animate?: boolean; + /** Whether to show the stroke line. Default: true */ + showLine?: boolean; + /** Whether to show highlight segment on hover. Default: true */ + showHighlight?: boolean; + /** Gradient opacity at bottom (0 = fully transparent). Default: 0 */ + gradientToOpacity?: number; + /** + * Fade the area fill (and stroke) toward transparent at the chart edges. + * - `true` fades both edges, `false` disables the fade entirely. + * - `"left"` / `"right"` fades only that side — useful when the opposite + * edge butts up against another element you don't want to fade into. + * Default: false + */ + fadeEdges?: FadeEdges; + /** Render scatter-style circle markers at each data point. Default: false */ + showMarkers?: boolean; + /** Marker styling (same options as Scatter). */ + markers?: SeriesPointMarkerStyle; + /** + * Data index from which the line stroke becomes dashed (inclusive). + * Useful for projecting incomplete periods, e.g. dashed from yesterday through today. + */ + dashFromIndex?: number; + /** Dash pattern for the tail segment when `dashFromIndex` is set. Default: "6,4" */ + dashArray?: string; +} + +export function Area({ + dataKey, + fill = chartCssVars.linePrimary, + fillOpacity = 0.4, + stroke, + strokeWidth = 2, + curve = curveMonotoneX, + animate = true, + showLine = true, + showHighlight = true, + gradientToOpacity = 0, + fadeEdges = false, + showMarkers = false, + markers, + dashFromIndex, + dashArray = "6,4", +}: AreaProps) { + // Stable slice only: hover state lives inside `` and + // `` so this component (and its expensive + // child) does not re-render on cursor motion. + // The reveal-clip is now a single shared clipPath at the chart-shell + // level (`time-series-chart-shell.tsx`); we no longer render a per-area + // `` or read `revealEpoch` here. + const { + data, + renderData, + xScale, + yScale, + innerHeight, + innerWidth, + xAccessor, + } = useChartStable(); + + const pathRef = useRef(null); + const pathMetricsKey = `${renderData.length}:${innerWidth}:${dashFromIndex}:${showLine}`; + const { pathLength, pathD } = usePathStrokeMetrics(pathRef, pathMetricsKey); + + // Unique IDs for this area + const uniqueId = useId(); + const gradientId = `area-gradient-${dataKey}-${uniqueId}`; + const strokeGradientId = `area-stroke-gradient-${dataKey}-${uniqueId}`; + const edgeMaskId = `area-edge-mask-${dataKey}-${uniqueId}`; + const edgeGradientId = `${edgeMaskId}-gradient`; + + const isPatternFill = fill.startsWith("url("); + const showAreaFill = isPatternFill || fillOpacity > 0; + const areaFill = isPatternFill ? fill : `url(#${gradientId})`; + + // Resolved stroke color (defaults to fill; pattern URLs need a real color) + const resolvedStroke = + stroke || (isPatternFill ? chartCssVars.linePrimary : fill); + + const getY = useCallback( + (d: Record) => { + const value = d[dataKey]; + return typeof value === "number" ? (yScale(value) ?? 0) : 0; + }, + [dataKey, yScale] + ); + + const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length); + // The stroke gradient is only emitted when at least one edge fades, so fall + // back to the resolved solid color otherwise — avoids an invalid url(#...). + const fadeSides = resolveFadeSides(fadeEdges); + const strokePaint = fadeSides.any + ? `url(#${strokeGradientId})` + : resolvedStroke; + const highlightEnabled = showHighlight && showLine; + + return ( + <> + + + + {/* Area fill */} + {showAreaFill ? ( + + xScale(xAccessor(d)) ?? 0} + y={getY} + yScale={yScale} + /> + + ) : null} + + {/* Stroke line on top of area */} + {showLine ? ( + <> + xScale(xAccessor(d)) ?? 0} + y={getY} + /> + + + ) : null} + + + {/* Highlight segment on hover — isolated hover subscriber. */} + + + {showMarkers ? ( + + ) : null} + + ); +} + +Area.displayName = "Area"; + +export default Area; diff --git a/apps/start/src/components/charts/bar-chart.tsx b/apps/start/src/components/charts/bar-chart.tsx new file mode 100644 index 000000000..3a2ad5164 --- /dev/null +++ b/apps/start/src/components/charts/bar-chart.tsx @@ -0,0 +1,611 @@ +"use client"; + +import { localPoint } from "@visx/event"; +import { ParentSize } from "@visx/responsive"; +import { scaleBand, scaleLinear } from "@visx/scale"; +import type { Transition } from "motion/react"; +import { + Children, + isValidElement, + memo, + type ReactElement, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { cn } from "@/lib/utils"; +import { DEFAULT_ANIMATION_EASING } from "./animation"; +import type { BarProps } from "./bar"; +import { + ChartProvider, + type LineConfig, + type Margin, + type TooltipData, +} from "./chart-context"; +import { isGradientDefComponent, isPatternDefComponent } from "./chart-defs"; +import { shortDateFmt } from "./chart-formatters"; +import { useScheduledTooltip } from "./use-scheduled-tooltip"; + +export type BarOrientation = "vertical" | "horizontal"; + +export interface BarChartProps { + /** Data array - each item should have an x-axis key and numeric values */ + data: Record[]; + /** Key in data for the categorical axis. Default: "name" */ + xDataKey?: string; + /** Chart margins */ + margin?: Partial; + /** Animation duration in milliseconds. Default: 1100 */ + animationDuration?: number; + /** CSS easing for bar grow transitions. */ + animationEasing?: string; + /** Motion enter transition (spring or cubic-bezier tween). */ + enterTransition?: Transition; + /** Signature of motion URL state — triggers enter replay when it changes. */ + revealSignature?: string; + /** Aspect ratio as "width / height". Default: "2 / 1" */ + aspectRatio?: string; + /** Additional class name for the container */ + className?: string; + /** Gap between bar groups as a fraction of band width (0-1). Default: 0.2 */ + barGap?: number; + /** Fixed bar width in pixels. If not set, bars auto-size to fill the band. */ + barWidth?: number; + /** Bar chart orientation. Default: "vertical" */ + orientation?: BarOrientation; + /** Whether to stack bars instead of grouping them. Default: false */ + stacked?: boolean; + /** Gap between stacked bar segments in pixels. Default: 0 */ + stackGap?: number; + /** Child components (Bar, Grid, ChartTooltip, etc.) */ + children: ReactNode; +} + +const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 }; + +// Extract bar configs from children synchronously +function extractBarConfigs(children: ReactNode): LineConfig[] { + const configs: LineConfig[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + + const childType = child.type as { + displayName?: string; + name?: string; + }; + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; + + const props = child.props as BarProps | undefined; + const isBarComponent = + componentName === "Bar" || + (props && typeof props.dataKey === "string" && props.dataKey.length > 0); + + if (isBarComponent && props?.dataKey) { + // Use stroke for tooltip dot color if provided, otherwise fall back to fill + // This allows gradient/pattern fills to have a solid dot color + const dotColor = + props.stroke || props.fill || "var(--chart-line-primary)"; + configs.push({ + dataKey: props.dataKey, + stroke: dotColor, + strokeWidth: 0, + }); + } + }); + + return configs; +} + +// Check if a component should render after the mouse overlay +function isPostOverlayComponent(child: ReactElement): boolean { + const childType = child.type as { + displayName?: string; + name?: string; + __isChartMarkers?: boolean; + }; + + if (childType.__isChartMarkers) { + return true; + } + + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; + + return componentName === "ChartMarkers" || componentName === "MarkerGroup"; +} + +interface ChartInnerProps { + width: number; + height: number; + data: Record[]; + xDataKey: string; + margin: Margin; + animationDuration: number; + animationEasing: string; + enterTransition?: Transition; + revealSignature?: string; + barGap: number; + barWidthProp?: number; + orientation: BarOrientation; + stacked: boolean; + stackGap: number; + children: ReactNode; + containerRef: React.RefObject; +} + +function ChartInner(props: ChartInnerProps) { + const { width, height } = props; + if (width < 10 || height < 10) { + return null; + } + return ; +} + +const ChartCore = memo(function ChartCore({ + width, + height, + data, + xDataKey, + margin, + animationDuration, + animationEasing, + enterTransition, + revealSignature = "", + barGap, + barWidthProp, + orientation, + stacked, + stackGap, + children, + containerRef, +}: ChartInnerProps) { + const { tooltipData, setTooltipData, scheduleTooltip, clearTooltip } = + useScheduledTooltip(); + const [isLoaded, setIsLoaded] = useState(false); + const [revealEpoch, setRevealEpoch] = useState(0); + const hoveredBarIndex = tooltipData?.index ?? null; + + const isHorizontal = orientation === "horizontal"; + + // Extract bar configs synchronously from children + const lines = useMemo(() => extractBarConfigs(children), [children]); + + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + // Category accessor function - returns string for categorical scale + const categoryAccessor = useCallback( + (d: Record): string => { + const value = d[xDataKey]; + if (value instanceof Date) { + return shortDateFmt.format(value); + } + return String(value ?? ""); + }, + [xDataKey] + ); + + // For compatibility with ChartContext, provide a Date-based xAccessor + const xAccessorDate = useCallback( + (d: Record): Date => { + const value = d[xDataKey]; + if (value instanceof Date) { + return value; + } + return new Date(); + }, + [xDataKey] + ); + + // Category scale (band) - for the categorical axis + const categoryScale = useMemo(() => { + const domain = data.map((d) => categoryAccessor(d)); + const range: [number, number] = isHorizontal + ? [0, innerHeight] + : [0, innerWidth]; + return scaleBand({ + range, + domain, + padding: barGap, + }); + }, [innerWidth, innerHeight, data, categoryAccessor, barGap, isHorizontal]); + + // Band width for bars - use prop if provided, otherwise use scale's bandwidth + const bandWidth = barWidthProp ?? categoryScale.bandwidth(); + + // Compute max value considering stacking + const maxValue = useMemo(() => { + if (stacked) { + // For stacked bars, sum all values at each data point + let max = 0; + for (const d of data) { + let sum = 0; + for (const line of lines) { + const value = d[line.dataKey]; + if (typeof value === "number") { + sum += value; + } + } + if (sum > max) { + max = sum; + } + } + return max || 100; + } + // For grouped bars, find max single value + let max = 0; + for (const line of lines) { + for (const d of data) { + const value = d[line.dataKey]; + if (typeof value === "number" && value > max) { + max = value; + } + } + } + return max || 100; + }, [data, lines, stacked]); + + // Value scale (linear) - for the value axis + const valueScale = useMemo(() => { + const range = isHorizontal ? [0, innerWidth] : [innerHeight, 0]; + return scaleLinear({ + range, + domain: [0, maxValue * 1.1], + nice: true, + }); + }, [innerWidth, innerHeight, maxValue, isHorizontal]); + + // Compute stack offsets for stacked bars + const stackOffsets = useMemo(() => { + if (!stacked) { + return undefined; + } + const offsets = new Map>(); + for (let i = 0; i < data.length; i++) { + const d = data[i]; + if (!d) { + continue; + } + const pointOffsets = new Map(); + let cumulative = 0; + for (const line of lines) { + pointOffsets.set(line.dataKey, cumulative); + const value = d[line.dataKey]; + if (typeof value === "number") { + cumulative += value; + } + } + offsets.set(i, pointOffsets); + } + return offsets; + }, [data, lines, stacked]); + + // Column width for tooltip indicator + const columnWidth = useMemo(() => { + if (data.length < 1) { + return 0; + } + return isHorizontal ? innerHeight / data.length : innerWidth / data.length; + }, [innerWidth, innerHeight, data.length, isHorizontal]); + + // Pre-compute labels for ticker animation + const dateLabels = useMemo( + () => data.map((d) => categoryAccessor(d)), + [data, categoryAccessor] + ); + + // Create a fake time scale for compatibility with ChartContext + const fakeTimeScale = useMemo(() => { + const now = Date.now(); + const start = now - data.length * 24 * 60 * 60 * 1000; + const scale = { + ...categoryScale, + domain: () => [new Date(start), new Date(now)], + range: () => [0, innerWidth] as [number, number], + invert: (x: number) => new Date(start + (x / innerWidth) * (now - start)), + copy: () => scale, + }; + return scale; + }, [categoryScale, innerWidth, data.length]); + + // Animation timing — replay when motion settings change + // biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature + useEffect(() => { + setRevealEpoch((n) => n + 1); + setIsLoaded(false); + const timer = setTimeout(() => { + setIsLoaded(true); + }, animationDuration); + return () => clearTimeout(timer); + }, [animationDuration, revealSignature]); + + // Mouse move handler + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const point = localPoint(event); + if (!point) { + return; + } + + const pos = isHorizontal ? point.y - margin.top : point.x - margin.left; + + // Find which band the mouse is over + const bandIndex = Math.floor(pos / columnWidth); + const clampedIndex = Math.max(0, Math.min(data.length - 1, bandIndex)); + const d = data[clampedIndex]; + + if (!d) { + return; + } + + // Calculate positions for each bar + const yPositions: Record = {}; + const xPositions: Record = {}; + const barPos = categoryScale(categoryAccessor(d)) ?? 0; + + if (isHorizontal) { + // Horizontal bars: dots at end of bar (x = value), centered vertically in band + const seriesCount = lines.length; + const groupGap = seriesCount > 1 ? 4 : 0; + const individualBarHeight = + seriesCount > 0 + ? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount + : bandWidth; + + if (stacked) { + // Stacked horizontal: all bars same y, x at cumulative end + let cumulative = 0; + for (const line of lines) { + const value = d[line.dataKey]; + if (typeof value === "number") { + cumulative += value; + xPositions[line.dataKey] = valueScale(cumulative) ?? 0; + yPositions[line.dataKey] = barPos + bandWidth / 2; + } + } + } else { + // Grouped horizontal: each bar at its own y position + lines.forEach((line, idx) => { + const value = d[line.dataKey]; + if (typeof value === "number") { + xPositions[line.dataKey] = valueScale(value) ?? 0; + yPositions[line.dataKey] = + barPos + + idx * (individualBarHeight + groupGap) + + individualBarHeight / 2; + } + }); + } + } else if (stacked) { + // Vertical stacked bars + let cumulative = 0; + let seriesIdx = 0; + for (const line of lines) { + const value = d[line.dataKey]; + if (typeof value === "number") { + cumulative += value; + const gapOffset = seriesIdx * stackGap; + yPositions[line.dataKey] = + (valueScale(cumulative) ?? 0) - gapOffset; + seriesIdx++; + } + } + } else { + // Vertical grouped bars + const seriesCount = lines.length; + const groupGap = seriesCount > 1 ? 4 : 0; + const individualBarWidth = + seriesCount > 0 + ? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount + : bandWidth; + + lines.forEach((line, idx) => { + const value = d[line.dataKey]; + if (typeof value === "number") { + yPositions[line.dataKey] = valueScale(value) ?? 0; + xPositions[line.dataKey] = + barPos + + idx * (individualBarWidth + groupGap) + + individualBarWidth / 2; + } + }); + } + + // Tooltip position: for horizontal, position at max bar end; for vertical, center of band + let tooltipX: number; + if (isHorizontal) { + // Position tooltip at the end of the longest bar + const maxX = Math.max(...Object.values(xPositions), 0); + tooltipX = maxX; + } else { + tooltipX = barPos + bandWidth / 2; + } + + scheduleTooltip({ + point: d, + index: clampedIndex, + x: tooltipX, + yPositions, + xPositions: Object.keys(xPositions).length > 0 ? xPositions : undefined, + }); + }, + [ + categoryScale, + valueScale, + data, + lines, + margin.left, + margin.top, + categoryAccessor, + columnWidth, + bandWidth, + isHorizontal, + stacked, + stackGap, + scheduleTooltip, + ] + ); + + const handleMouseLeave = useCallback(() => { + clearTooltip(); + }, [clearTooltip]); + + const canInteract = isLoaded; + + // Separate children into defs, pre-overlay, and post-overlay + const defsChildren: ReactElement[] = []; + const preOverlayChildren: ReactElement[] = []; + const postOverlayChildren: ReactElement[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + + if (isGradientDefComponent(child)) { + defsChildren.push(child); + } else if (isPatternDefComponent(child)) { + preOverlayChildren.push(child); + } else if (isPostOverlayComponent(child)) { + postOverlayChildren.push(child); + } else { + preOverlayChildren.push(child); + } + }); + + const contextValue = { + data, + renderData: data, + xScale: fakeTimeScale as unknown as ReturnType< + typeof import("@visx/scale").scaleTime + >, + yScale: valueScale, + width, + height, + innerWidth, + innerHeight, + margin, + columnWidth, + tooltipData, + setTooltipData, + containerRef, + lines, + isLoaded, + animationDuration, + animationEasing, + enterTransition, + revealEpoch, + xAccessor: xAccessorDate, + dateLabels, + // Bar-specific properties + barScale: categoryScale, + bandWidth, + hoveredBarIndex, + barXAccessor: categoryAccessor, + orientation, + stacked, + stackOffsets, + }; + + return ( + + + + ); +}); + +export function BarChart({ + data, + xDataKey = "name", + margin: marginProp, + animationDuration = 1100, + animationEasing = DEFAULT_ANIMATION_EASING, + enterTransition, + revealSignature, + aspectRatio = "2 / 1", + className = "", + barGap = 0.2, + barWidth, + orientation = "vertical", + stacked = false, + stackGap = 0, + children, +}: BarChartProps) { + const containerRef = useRef(null); + const margin = { ...DEFAULT_MARGIN, ...marginProp }; + + return ( +
+ + {({ width, height }) => ( + + {children} + + )} + +
+ ); +} + +BarChart.displayName = "BarChart"; + +export default BarChart; diff --git a/apps/start/src/components/charts/bar-x-axis.tsx b/apps/start/src/components/charts/bar-x-axis.tsx new file mode 100644 index 000000000..2fbfa5f91 --- /dev/null +++ b/apps/start/src/components/charts/bar-x-axis.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { motion } from "motion/react"; +import { memo, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "@/lib/utils"; +import { useChart, useChartStable } from "./chart-context"; + +export interface BarXAxisProps { + /** Width of the date ticker box for fade calculation. Default: 50 */ + tickerHalfWidth?: number; + /** Whether to show all labels or skip some for dense data. Default: false */ + showAllLabels?: boolean; + /** Maximum number of labels to show. Default: 12 */ + maxLabels?: number; +} + +interface BarXAxisLabelProps { + label: string; + x: number; + crosshairX: number | null; + isHovering: boolean; + tickerHalfWidth: number; +} + +function BarXAxisLabel({ + label, + x, + crosshairX, + isHovering, + tickerHalfWidth, +}: BarXAxisLabelProps) { + const fadeBuffer = 20; + const fadeRadius = tickerHalfWidth + fadeBuffer; + + let opacity = 1; + if (isHovering && crosshairX !== null) { + const distance = Math.abs(x - crosshairX); + if (distance < tickerHalfWidth) { + opacity = 0; + } else if (distance < fadeRadius) { + opacity = (distance - tickerHalfWidth) / fadeBuffer; + } + } + + // Zero-width container approach for perfect centering + return ( +
+ + {label} + +
+ ); +} + +export function BarXAxis(props: BarXAxisProps) { + const { containerRef, barScale } = useChartStable(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + if (!barScale) { + return null; + } + + return ; +} + +const BarXAxisInner = memo(function BarXAxisInner({ + tickerHalfWidth = 50, + showAllLabels = false, + maxLabels = 12, + container, +}: BarXAxisProps & { container: HTMLDivElement }) { + const { margin, tooltipData, barScale, bandWidth, barXAccessor, data } = + useChart(); + + // Generate labels for each bar + const labelsToShow = useMemo(() => { + if (!(barScale && bandWidth && barXAccessor)) { + return []; + } + + const allLabels = data.map((d) => { + const label = barXAccessor(d); + const bandX = barScale(label) ?? 0; + // Center the label under the bar group + const x = bandX + bandWidth / 2 + margin.left; + return { label, x }; + }); + + // If showAllLabels is true or we have fewer than maxLabels, show all + if (showAllLabels || allLabels.length <= maxLabels) { + return allLabels; + } + + // Otherwise, skip some labels to avoid crowding + const step = Math.ceil(allLabels.length / maxLabels); + return allLabels.filter((_, i) => i % step === 0); + }, [ + barScale, + bandWidth, + barXAccessor, + data, + margin.left, + showAllLabels, + maxLabels, + ]); + + const isHovering = tooltipData !== null; + const crosshairX = tooltipData ? tooltipData.x + margin.left : null; + + return createPortal( +
+ {labelsToShow.map((item) => ( + + ))} +
, + container + ); +}); + +BarXAxis.displayName = "BarXAxis"; + +export default BarXAxis; diff --git a/apps/start/src/components/charts/bar-y-axis.tsx b/apps/start/src/components/charts/bar-y-axis.tsx new file mode 100644 index 000000000..95400e5c7 --- /dev/null +++ b/apps/start/src/components/charts/bar-y-axis.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { motion } from "motion/react"; +import { memo, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "@/lib/utils"; +import { useChart, useChartStable } from "./chart-context"; + +export interface BarYAxisProps { + /** Whether to show all labels or skip some for dense data. Default: true */ + showAllLabels?: boolean; + /** Maximum number of labels to show. Default: 20 */ + maxLabels?: number; +} + +interface BarYAxisLabelProps { + label: string; + y: number; + bandHeight: number; + isHovered: boolean; +} + +function BarYAxisLabel({ + label, + y, + bandHeight, + isHovered, +}: BarYAxisLabelProps) { + return ( +
+ + {label} + +
+ ); +} + +export function BarYAxis(props: BarYAxisProps) { + const { containerRef, barScale } = useChartStable(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + if (!barScale) { + return null; + } + + return ; +} + +const BarYAxisInner = memo(function BarYAxisInner({ + showAllLabels = true, + maxLabels = 20, + container, +}: BarYAxisProps & { container: HTMLDivElement }) { + const { margin, barScale, bandWidth, barXAccessor, data, hoveredBarIndex } = + useChart(); + + // Generate labels for each bar + const labelsToShow = useMemo(() => { + if (!(barScale && bandWidth && barXAccessor)) { + return []; + } + + const allLabels = data.map((d, i) => { + const label = barXAccessor(d); + const bandY = barScale(label) ?? 0; + // Center the label vertically within the band + const y = bandY + margin.top; + return { label, y, bandHeight: bandWidth, index: i }; + }); + + // If showAllLabels is true or we have fewer than maxLabels, show all + if (showAllLabels || allLabels.length <= maxLabels) { + return allLabels; + } + + // Otherwise, skip some labels to avoid crowding + const step = Math.ceil(allLabels.length / maxLabels); + return allLabels.filter((_, i) => i % step === 0); + }, [ + barScale, + bandWidth, + barXAccessor, + data, + margin.top, + showAllLabels, + maxLabels, + ]); + + return createPortal( +
+ {labelsToShow.map((item) => ( + + ))} +
, + container + ); +}); + +BarYAxis.displayName = "BarYAxis"; + +export default BarYAxis; diff --git a/apps/start/src/components/charts/bar.tsx b/apps/start/src/components/charts/bar.tsx new file mode 100644 index 000000000..784f2416f --- /dev/null +++ b/apps/start/src/components/charts/bar.tsx @@ -0,0 +1,354 @@ +"use client"; + +import type { scaleBand } from "@visx/scale"; +import type { Transition } from "motion/react"; +import { motion } from "motion/react"; +import { memo, useId, useMemo } from "react"; +import { chartCssVars, useChart, useChartStable } from "./chart-context"; +import { transitionWithDelay } from "./motion-utils"; + +type ScaleBand = ReturnType< + typeof scaleBand +>; + +export type BarLineCap = "round" | "butt" | number; +export type BarAnimationType = "grow" | "fade"; + +export interface BarProps { + /** Key in data to use for y values */ + dataKey: string; + /** Fill color for the bar. Can be a color, gradient url, or pattern url. Default: var(--chart-line-primary) */ + fill?: string; + /** Color for tooltip dot. Use when fill is a gradient/pattern. Default: uses fill value */ + stroke?: string; + /** Line cap style for bar ends: "round", "butt", or a number for custom radius. Default: "round" */ + lineCap?: BarLineCap; + /** Whether to animate the bars. Default: true */ + animate?: boolean; + /** Animation type: "grow" (height) or "fade" (opacity + blur). Default: "grow" */ + animationType?: BarAnimationType; + /** Opacity when not hovered (when another bar is hovered). Default: 0.3 */ + fadedOpacity?: number; + /** Stagger delay between bars in seconds. Auto-calculated if not provided. */ + staggerDelay?: number; + /** Gap between stacked bars in pixels. Default: 0 */ + stackGap?: number; + /** Gap between grouped bars in pixels. Default: 4 */ + groupGap?: number; +} + +interface BarInnerProps extends BarProps { + barScale: ScaleBand; + bandWidth: number; + barXAccessor: (d: Record) => string; +} + +interface AnimatedBarProps { + x: number; + y: number; + width: number; + height: number; + fill: string; + rx: number; + ry: number; + index: number; + isFaded: boolean; + animationType: BarAnimationType; + innerHeight: number; + fadedOpacity: number; + staggerDelay: number; + enterTransition?: Transition; + revealEpoch: number; + isHorizontal: boolean; +} + +function AnimatedBar({ + x, + y, + width, + height, + fill, + rx, + ry, + index, + isFaded, + animationType, + innerHeight, + fadedOpacity, + staggerDelay, + enterTransition, + revealEpoch, + isHorizontal, +}: AnimatedBarProps) { + const enterAnim = transitionWithDelay(enterTransition, index * staggerDelay); + + if (animationType === "fade") { + return ( + + ); + } + + const initial = isHorizontal + ? { width: 0, height, x: 0, y } + : { width, height: 0, x, y: innerHeight }; + const target = isHorizontal + ? { width, height, x: 0, y } + : { width, height, x, y }; + + return ( + + + + ); +} + +const BarInner = memo(function BarInner({ + dataKey, + fill = chartCssVars.linePrimary, + lineCap = "round", + animate = true, + animationType = "grow", + fadedOpacity = 0.3, + staggerDelay, + stackGap = 0, + groupGap = 4, + barScale, + bandWidth, + barXAccessor, +}: BarInnerProps) { + const { + data, + yScale, + innerHeight, + isLoaded, + hoveredBarIndex, + lines, + orientation, + stacked, + stackOffsets, + animationDuration, + enterTransition, + revealEpoch = 0, + } = useChart(); + + // Calculate stagger delay automatically if not provided + // Total animation duration is ~1200ms, with 40% for stagger spread and 60% for bar animation + const totalAnimDuration = animationDuration || 1100; + const staggerSpread = totalAnimDuration * 0.4; // 40% of time for stagger spread + const calculatedStaggerDelay = + staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0); + const uniqueId = useId(); + + const isHorizontal = orientation === "horizontal"; + + // Find the index of this bar series among all bar series + const seriesIndex = useMemo(() => { + const idx = lines.findIndex((l) => l.dataKey === dataKey); + return idx >= 0 ? idx : 0; + }, [lines, dataKey]); + + const seriesCount = lines.length; + const isLastSeries = seriesIndex === seriesCount - 1; + + // Calculate the width for each bar within a group (for non-stacked) + const barWidth = useMemo(() => { + if (!bandWidth || seriesCount === 0) { + return 0; + } + if (stacked) { + // Stacked bars use full band width + return bandWidth; + } + // Leave a gap between grouped bars (controlled by groupGap prop) + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0; + return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount; + }, [bandWidth, seriesCount, stacked, groupGap]); + + // Calculate corner radius based on lineCap + const cornerRadius = useMemo(() => { + if (typeof lineCap === "number") { + return lineCap; + } + if (lineCap === "round" && barWidth) { + return Math.min(barWidth / 2, 8); + } + return 0; + }, [lineCap, barWidth]); + + return ( + + {data.map((d, i) => { + const value = d[dataKey]; + if (typeof value !== "number") { + return null; + } + + const categoryValue = barXAccessor(d); + const bandPos = barScale(categoryValue) ?? 0; + + let x: number; + let y: number; + let barHeight: number; + let barW: number; + + if (isHorizontal) { + // Horizontal bars: category on y-axis, value on x-axis + const valuePos = yScale(value) ?? 0; + barW = valuePos; // Width is the value position (grows from left) + barHeight = barWidth; + + if (stacked && stackOffsets) { + const offset = stackOffsets.get(i)?.get(dataKey) ?? 0; + x = yScale(offset) ?? 0; + barW = valuePos - x; + // Apply stack gap for horizontal: shift right and reduce width + const gapOffset = seriesIndex * stackGap; + x += gapOffset; + if (!isLastSeries && stackGap > 0) { + barW = Math.max(0, barW - stackGap); + } + } else { + x = 0; + // For grouped bars, offset y position + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0; + y = bandPos + seriesIndex * (barWidth + effectiveGroupGap); + } + y = stacked + ? bandPos + : bandPos + + seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0)); + } else { + // Vertical bars: category on x-axis, value on y-axis + const valuePos = yScale(value) ?? 0; + barHeight = innerHeight - valuePos; + barW = barWidth; + + if (stacked && stackOffsets) { + const offset = stackOffsets.get(i)?.get(dataKey) ?? 0; + const offsetY = yScale(offset) ?? innerHeight; + // Apply stack gap: shift up and reduce height + const gapOffset = seriesIndex * stackGap; + y = offsetY - barHeight - gapOffset; + // Reduce height slightly for non-last bars to create visual gap + if (!isLastSeries && stackGap > 0) { + barHeight = Math.max(0, barHeight - stackGap); + } + } else { + y = valuePos; + // For grouped bars, offset x position + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0; + x = bandPos + seriesIndex * (barWidth + effectiveGroupGap); + } + x = stacked + ? bandPos + : bandPos + + seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0)); + } + + const isFaded = hoveredBarIndex !== null && hoveredBarIndex !== i; + + // Use categoryValue as key since it's the unique identifier from data + const barKey = `bar-${dataKey}-${categoryValue}`; + + // Apply rounded corners: + // - For non-stacked: always apply + // - For stacked with gap: apply to all bars + // - For stacked without gap: only apply to the last series + const applyRounding = !stacked || stackGap > 0 || isLastSeries; + const effectiveRx = applyRounding ? cornerRadius : 0; + const effectiveRy = applyRounding ? cornerRadius : 0; + + if (animate && !isLoaded) { + return ( + + ); + } + + // Static bar after animation completes + return ( + + ); + })} + + ); +}); + +export function Bar(props: BarProps) { + const { barScale, bandWidth, barXAccessor } = useChartStable(); + + if (!(barScale && bandWidth && barXAccessor)) { + console.warn("Bar component must be used within a BarChart"); + return null; + } + + return ( + + ); +} + +Bar.displayName = "Bar"; + +export default Bar; diff --git a/apps/start/src/components/charts/chart-config-context.tsx b/apps/start/src/components/charts/chart-config-context.tsx new file mode 100644 index 000000000..779b1d3c8 --- /dev/null +++ b/apps/start/src/components/charts/chart-config-context.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { createContext, type ReactNode, useContext, useMemo } from "react"; + +export interface SpringConfig { + stiffness: number; + damping: number; +} + +export interface ChartConfigValue { + /** Crosshair indicator, tooltip dot, date pill. */ + tooltipSpring: SpringConfig; + /** Floating tooltip panel. */ + tooltipBoxSpring: SpringConfig; + /** Line/area hover-highlight band (x + width). */ + highlightSpring: SpringConfig; +} + +export const DEFAULT_CHART_CONFIG: ChartConfigValue = { + tooltipSpring: { stiffness: 300, damping: 30 }, + tooltipBoxSpring: { stiffness: 100, damping: 20 }, + highlightSpring: { stiffness: 180, damping: 28 }, +}; + +const ChartConfigContext = createContext(null); + +export interface ChartConfigProviderProps { + value?: Partial; + children: ReactNode; +} + +export function ChartConfigProvider({ + value, + children, +}: ChartConfigProviderProps) { + const merged = useMemo( + () => ({ + ...DEFAULT_CHART_CONFIG, + ...value, + }), + [value] + ); + + return ( + + {children} + + ); +} + +export function useChartConfig(): ChartConfigValue { + return useContext(ChartConfigContext) ?? DEFAULT_CHART_CONFIG; +} diff --git a/apps/start/src/components/charts/chart-context.tsx b/apps/start/src/components/charts/chart-context.tsx new file mode 100644 index 000000000..703c57099 --- /dev/null +++ b/apps/start/src/components/charts/chart-context.tsx @@ -0,0 +1,361 @@ +"use client"; + +import type { scaleBand, scaleLinear, scaleTime } from "@visx/scale"; + +type ScaleLinear = ReturnType< + typeof scaleLinear +>; +type ScaleTime = ReturnType< + typeof scaleTime +>; +type ScaleBand = ReturnType< + typeof scaleBand +>; + +import type { Transition } from "motion/react"; +import { + createContext, + type Dispatch, + type ReactNode, + type RefObject, + type SetStateAction, + useContext, + useMemo, +} from "react"; +import type { ChartSelection } from "./use-chart-interaction"; + +// CSS variable references for theming +export const chartCssVars = { + background: "var(--chart-background)", + foreground: "var(--chart-foreground)", + foregroundMuted: "var(--chart-foreground-muted)", + label: "var(--chart-label)", + linePrimary: "var(--chart-line-primary)", + lineSecondary: "var(--chart-line-secondary)", + crosshair: "var(--chart-crosshair)", + grid: "var(--chart-grid)", + indicatorColor: "var(--chart-indicator-color)", + indicatorSecondaryColor: "var(--chart-indicator-secondary-color)", + markerBackground: "var(--chart-marker-background)", + markerBorder: "var(--chart-marker-border)", + markerForeground: "var(--chart-marker-foreground)", + badgeBackground: "var(--chart-marker-badge-background)", + badgeForeground: "var(--chart-marker-badge-foreground)", + segmentBackground: "var(--chart-segment-background)", + segmentLine: "var(--chart-segment-line)", +}; + +/** Default scatter series colors from the chart palette (`--chart-1` … `--chart-5`). */ +export const defaultScatterColors = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +] as const; + +export interface Margin { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface TooltipData { + /** The data point being hovered */ + point: Record; + /** Index in the data array */ + index: number; + /** X position in pixels (relative to chart area) */ + x: number; + /** Y positions for each line, keyed by dataKey */ + yPositions: Record; + /** X positions for each series (for grouped bars), keyed by dataKey */ + xPositions?: Record; +} + +export interface LineConfig { + dataKey: string; + stroke: string; + strokeWidth: number; +} + +/** + * Hover/selection state — every field here changes on mouse movement. + * Lives in its own context so cold consumers (Grid, YAxis, PatternArea, …) + * can subscribe to the stable slice and skip re-rendering on every hover. + */ +export interface ChartHoverContextValue { + // Tooltip state + tooltipData: TooltipData | null; + setTooltipData: Dispatch>; + + // Selection state (optional - only present when useChartInteraction is used) + /** Current drag/pinch selection range */ + selection?: ChartSelection | null; + /** Clear the current selection */ + clearSelection?: () => void; + + // Bar chart hover (optional - only present in BarChart) + /** Index of currently hovered bar */ + hoveredBarIndex?: number | null; + /** Setter for hovered bar index */ + setHoveredBarIndex?: (index: number | null) => void; + + // Candlestick hover (optional - only present in CandlestickChart) + /** Index of currently hovered candle */ + hoveredCandleIndex?: number | null; + /** Setter for hovered candle index */ + setHoveredCandleIndex?: (index: number | null) => void; +} + +export interface ChartContextValue extends ChartHoverContextValue { + // Data + data: Record[]; + /** Decimated subset for SVG path rendering; equals `data` when no decimation is needed. */ + renderData: Record[]; + + // Scales + xScale: ScaleTime; + yScale: ScaleLinear; + + // Dimensions + width: number; + height: number; + innerWidth: number; + innerHeight: number; + margin: Margin; + + // Column width for spacing calculations + columnWidth: number; + + // Container ref for portals + containerRef: RefObject; + + // Line configurations (extracted from children) + lines: LineConfig[]; + + // Animation state + isLoaded: boolean; + animationDuration: number; + /** CSS easing for clip-reveal / line draw (cartesian charts). */ + animationEasing?: string; + /** Motion enter transition (spring or tween) — drives clip reveal when spring. */ + enterTransition?: Transition; + /** Increments when enter animation should replay. */ + revealEpoch?: number; + + // X accessor - how to get the x value from data points + xAccessor: (d: Record) => Date; + + // Pre-computed date labels for ticker animation + dateLabels: string[]; + + // Bar chart specific (optional - only present in BarChart) + /** Band scale for categorical x-axis (bar charts) */ + barScale?: ScaleBand; + /** Width of each bar band */ + bandWidth?: number; + /** X accessor for bar charts (returns string instead of Date) */ + barXAccessor?: (d: Record) => string; + /** Bar chart orientation */ + orientation?: "vertical" | "horizontal"; + /** Whether bars are stacked */ + stacked?: boolean; + /** Stack offsets: Map of data index -> Map of dataKey -> cumulative offset */ + stackOffsets?: Map>; + + // ComposedChart + SeriesBar (optional) + /** `SeriesBar` dataKeys in tree order, for grouped columns at each x */ + composedBarDataKeys?: string[]; + /** Target bar width in px (Recharts `barSize` style). */ + composedBarSize?: number; + /** Max bar width in px (Recharts `maxBarSize`). */ + composedMaxBarSize?: number; + /** Gap between grouped `SeriesBar` columns in px. */ + composedBarGap?: number; + /** When true, `SeriesBar` segments stack in child order at each x. */ + composedStacked?: boolean; + /** Per-row cumulative offsets for stacked `SeriesBar` (data index → dataKey → offset). */ + composedStackOffsets?: Map>; + /** Vertical gap in px between stacked `SeriesBar` segments. Default: 0 */ + composedStackGap?: number; +} + +/** + * Stable slice of the chart context — everything that doesn't change on hover + * (data, scales, dimensions, animation state, layout config). Consumers that + * subscribe via `useChartStable()` skip re-renders on every mouse move. + */ +export type ChartStableContextValue = Omit< + ChartContextValue, + keyof ChartHoverContextValue +>; + +const ChartStableContext = createContext(null); +const ChartHoverContext = createContext(null); + +/** + * Splits the merged `value` into a stable slice and a volatile hover slice, + * publishing each to its own context. Each slice is memoized on its own + * field identities, so changing `tooltipData` does not bust the stable + * slice — consumers of `useChartStable()` skip re-renders on hover. + */ +export function ChartProvider({ + children, + value, +}: { + children: ReactNode; + value: ChartContextValue; +}) { + const stable = useMemo( + () => ({ + data: value.data, + renderData: value.renderData, + xScale: value.xScale, + yScale: value.yScale, + width: value.width, + height: value.height, + innerWidth: value.innerWidth, + innerHeight: value.innerHeight, + margin: value.margin, + columnWidth: value.columnWidth, + containerRef: value.containerRef, + lines: value.lines, + isLoaded: value.isLoaded, + animationDuration: value.animationDuration, + animationEasing: value.animationEasing, + enterTransition: value.enterTransition, + revealEpoch: value.revealEpoch, + xAccessor: value.xAccessor, + dateLabels: value.dateLabels, + barScale: value.barScale, + bandWidth: value.bandWidth, + barXAccessor: value.barXAccessor, + orientation: value.orientation, + stacked: value.stacked, + stackOffsets: value.stackOffsets, + composedBarDataKeys: value.composedBarDataKeys, + composedBarSize: value.composedBarSize, + composedMaxBarSize: value.composedMaxBarSize, + composedBarGap: value.composedBarGap, + composedStacked: value.composedStacked, + composedStackOffsets: value.composedStackOffsets, + composedStackGap: value.composedStackGap, + }), + [ + value.data, + value.renderData, + value.xScale, + value.yScale, + value.width, + value.height, + value.innerWidth, + value.innerHeight, + value.margin, + value.columnWidth, + value.containerRef, + value.lines, + value.isLoaded, + value.animationDuration, + value.animationEasing, + value.enterTransition, + value.revealEpoch, + value.xAccessor, + value.dateLabels, + value.barScale, + value.bandWidth, + value.barXAccessor, + value.orientation, + value.stacked, + value.stackOffsets, + value.composedBarDataKeys, + value.composedBarSize, + value.composedMaxBarSize, + value.composedBarGap, + value.composedStacked, + value.composedStackOffsets, + value.composedStackGap, + ] + ); + + const hover = useMemo( + () => ({ + tooltipData: value.tooltipData, + setTooltipData: value.setTooltipData, + selection: value.selection, + clearSelection: value.clearSelection, + hoveredBarIndex: value.hoveredBarIndex, + setHoveredBarIndex: value.setHoveredBarIndex, + hoveredCandleIndex: value.hoveredCandleIndex, + setHoveredCandleIndex: value.setHoveredCandleIndex, + }), + [ + value.tooltipData, + value.setTooltipData, + value.selection, + value.clearSelection, + value.hoveredBarIndex, + value.setHoveredBarIndex, + value.hoveredCandleIndex, + value.setHoveredCandleIndex, + ] + ); + + return ( + + + {children} + + + ); +} + +/** + * Stable slice — data, scales, dimensions, animation state, layout config. + * Subscribers skip re-renders on hover (the hover slice lives in a separate + * context). Prefer this in cold consumers like axes, grid, pattern fills. + */ +export function useChartStable(): ChartStableContextValue { + const context = useContext(ChartStableContext); + if (!context) { + throw new Error( + "useChartStable must be used within a ChartProvider. " + + "Make sure your component is wrapped in , , , or ." + ); + } + return context; +} + +/** + * Hover slice — tooltipData, selection, hovered bar / candle indices. + * Subscribers re-render on every mouse move. Use only when the component + * actually reads hover state. + */ +export function useChartHover(): ChartHoverContextValue { + const context = useContext(ChartHoverContext); + if (!context) { + throw new Error( + "useChartHover must be used within a ChartProvider. " + + "Make sure your component is wrapped in , , , or ." + ); + } + return context; +} + +/** + * Merged stable + hover context. Convenient for components that need both, + * but re-renders on every hover (because hover changes). Prefer + * `useChartStable()` or `useChartHover()` for hot consumers that only need + * one slice. + */ +export function useChart(): ChartContextValue { + const stable = useChartStable(); + const hover = useChartHover(); + // Identity changes on every hover (hover is the volatile slice) — that's + // fine for consumers using this merged hook; they explicitly opted in to + // re-rendering on hover. + return { ...stable, ...hover }; +} + +export default ChartStableContext; diff --git a/apps/start/src/components/charts/chart-defs.ts b/apps/start/src/components/charts/chart-defs.ts new file mode 100644 index 000000000..6dd941a60 --- /dev/null +++ b/apps/start/src/components/charts/chart-defs.ts @@ -0,0 +1,72 @@ +import { + Children, + isValidElement, + type ReactElement, + type ReactNode, +} from "react"; + +export function getChartChildComponentName(child: ReactElement): string { + const childType = child.type as { displayName?: string; name?: string }; + return typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; +} + +const VISX_PATTERN_COMPONENT_NAMES = new Set([ + "Lines", + "Circles", + "Waves", + "Hexagons", + "Path", + "Pattern", +]); + +/** @visx/pattern default exports use short names (e.g. `Lines`); also match *Pattern* displayNames. */ +export function isPatternDefComponent(child: ReactElement): boolean { + const name = getChartChildComponentName(child); + return name.includes("Pattern") || VISX_PATTERN_COMPONENT_NAMES.has(name); +} + +export function isGradientDefComponent(child: ReactElement): boolean { + const name = getChartChildComponentName(child); + return ( + name.includes("Gradient") || + name === "LinearGradient" || + name === "RadialGradient" + ); +} + +export function isChartDefsComponent(child: ReactElement): boolean { + return isPatternDefComponent(child) || isGradientDefComponent(child); +} + +/** Split hoisted defs: @visx/pattern nodes already wrap `` and render at the svg root. */ +export function partitionChartDefNodes(defNodes: ReactElement[]): { + patternDefNodes: ReactElement[]; + gradientDefNodes: ReactElement[]; +} { + const patternDefNodes: ReactElement[] = []; + const gradientDefNodes: ReactElement[] = []; + + for (const node of defNodes) { + if (isPatternDefComponent(node)) { + patternDefNodes.push(node); + } else { + gradientDefNodes.push(node); + } + } + + return { patternDefNodes, gradientDefNodes }; +} + +export function collectChartDefsChildren(children: ReactNode): ReactElement[] { + const defNodes: ReactElement[] = []; + + Children.forEach(children, (child) => { + if (isValidElement(child) && isChartDefsComponent(child)) { + defNodes.push(child); + } + }); + + return defNodes; +} diff --git a/apps/start/src/components/charts/chart-formatters.ts b/apps/start/src/components/charts/chart-formatters.ts new file mode 100644 index 000000000..d8b007047 --- /dev/null +++ b/apps/start/src/components/charts/chart-formatters.ts @@ -0,0 +1,20 @@ +export const shortDateFmt = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", +}); + +export const weekdayDateFmt = new Intl.DateTimeFormat("en-US", { + weekday: "short", + month: "short", + day: "numeric", +}); + +export const hmsTimeFmt = new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, +}); + +// `Intl.NumberFormat.prototype.format` is a bound getter — safe to extract. +export const intFmt = new Intl.NumberFormat("en-US").format; diff --git a/apps/start/src/components/charts/chart-reveal-clip.tsx b/apps/start/src/components/charts/chart-reveal-clip.tsx new file mode 100644 index 000000000..3a8591a0c --- /dev/null +++ b/apps/start/src/components/charts/chart-reveal-clip.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { Transition } from "motion/react"; +import { motion } from "motion/react"; +import { clipRevealTransition } from "./animation"; + +export interface ChartRevealClipProps { + clipPathId: string; + height: number; + targetWidth: number; + enterTransition?: Transition; + /** Bumps when motion settings change to replay the reveal. */ + revealEpoch: number; + /** Extra inset around the clip rect so edge glyphs are not cut off. */ + padding?: number; +} + +/** + * Left-to-right clip reveal for cartesian series. + * Grows clip rect width from 0 → full (true LTR; scaleX is avoided — it reveals from center). + */ +export function ChartRevealClip({ + clipPathId, + height, + targetWidth, + enterTransition, + revealEpoch, + padding = 0, +}: ChartRevealClipProps) { + const transition = clipRevealTransition(enterTransition); + const paddedWidth = Math.max(0, targetWidth + padding * 2); + const paddedHeight = height + padding * 2; + + return ( + + + + ); +} diff --git a/apps/start/src/components/charts/chart-stat-flow.tsx b/apps/start/src/components/charts/chart-stat-flow.tsx new file mode 100644 index 000000000..93b5ff9a3 --- /dev/null +++ b/apps/start/src/components/charts/chart-stat-flow.tsx @@ -0,0 +1,76 @@ +"use client"; + +import NumberFlow from "@number-flow/react"; +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +/** Subset of `Intl.NumberFormatOptions` supported by NumberFlow */ +export interface ChartStatFlowFormat { + notation?: "standard" | "compact"; + compactDisplay?: "short" | "long"; + minimumFractionDigits?: number; + maximumFractionDigits?: number; + minimumIntegerDigits?: number; + minimumSignificantDigits?: number; + maximumSignificantDigits?: number; + style?: "decimal" | "percent" | "currency"; + currency?: string; + currencyDisplay?: "symbol" | "narrowSymbol" | "code" | "name"; + unit?: string; + unitDisplay?: "short" | "long" | "narrow"; +} + +export const defaultChartStatFlowFormat: ChartStatFlowFormat = { + notation: "standard", + maximumFractionDigits: 0, +}; + +export interface ChartStatFlowProps { + value: number; + label: string; + formatOptions?: ChartStatFlowFormat; + prefix?: string; + suffix?: string; + valueClassName?: string; + labelClassName?: string; + icon?: ReactNode; +} + +/** + * Shared value + label stack using NumberFlow (same layout as pie / ring centers). + * Parent should provide flex alignment and sizing when needed. + */ +export function ChartStatFlow({ + value, + label, + formatOptions = defaultChartStatFlowFormat, + prefix, + suffix, + valueClassName = "text-2xl font-bold", + labelClassName = "text-xs", + icon, +}: ChartStatFlowProps) { + return ( + <> + {icon ? ( +
+ {icon} +
+ ) : null} + + + + + {label} + + + ); +} + +ChartStatFlow.displayName = "ChartStatFlow"; diff --git a/apps/start/src/components/charts/composed-chart.tsx b/apps/start/src/components/charts/composed-chart.tsx new file mode 100644 index 000000000..77fe6b537 --- /dev/null +++ b/apps/start/src/components/charts/composed-chart.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { ParentSize } from "@visx/responsive"; +import type { Transition } from "motion/react"; +import { + Children, + isValidElement, + type ReactElement, + type ReactNode, + useMemo, + useRef, +} from "react"; +import { cn } from "@/lib/utils"; +import { Area, type AreaProps } from "./area"; +import type { LineConfig, Margin } from "./chart-context"; +import { Line, type LineProps } from "./line"; +import { SeriesBar, type SeriesBarProps } from "./series-bar"; +import { TimeSeriesChartInner } from "./time-series-chart-shell"; + +export interface ComposedChartProps { + /** Data array — each row typically has a date and multiple numeric series */ + data: Record[]; + /** Key for the x-axis (time). Default: "date" */ + xDataKey?: string; + margin?: Partial; + animationDuration?: number; + animationEasing?: string; + enterTransition?: Transition; + /** Signature of motion URL state — triggers reveal replay when it changes. */ + revealSignature?: string; + aspectRatio?: string; + className?: string; + children: ReactNode; + /** Target bar width in px (Recharts-style `barSize`). */ + barSize?: number; + /** Maximum bar width in px (`maxBarSize`). */ + maxBarSize?: number; + /** Gap between grouped `SeriesBar` series in px. Default: 4 */ + barGap?: number; + /** Stack `SeriesBar` segments in child order at each x (line/area are not stacked). */ + stacked?: boolean; + /** Gap in px between stacked segments. Default: 0 */ + stackGap?: number; +} + +const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 }; + +function getChildComponentName(child: ReactElement): string { + const childType = child.type as { displayName?: string; name?: string }; + return typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; +} + +function upsertLineConfig(lines: LineConfig[], config: LineConfig): void { + const index = lines.findIndex((line) => line.dataKey === config.dataKey); + if (index === -1) { + lines.push(config); + return; + } + // Area+Line pairs share a dataKey — keep the later config (Line over Area). + lines[index] = config; +} + +function tryAppendSeriesBar( + child: ReactElement, + lines: LineConfig[], + barDataKeys: string[] +): boolean { + const name = getChildComponentName(child); + if (!(child.type === SeriesBar || name === "SeriesBar")) { + return false; + } + const props = child.props as SeriesBarProps; + if (!props.dataKey) { + return true; + } + barDataKeys.push(props.dataKey); + upsertLineConfig(lines, { + dataKey: props.dataKey, + stroke: props.stroke || props.fill || "var(--chart-line-primary)", + strokeWidth: 0, + }); + return true; +} + +function tryAppendLine(child: ReactElement, lines: LineConfig[]): boolean { + const name = getChildComponentName(child); + if (!(child.type === Line || name === "Line")) { + return false; + } + const props = child.props as LineProps; + if (props.dataKey) { + upsertLineConfig(lines, { + dataKey: props.dataKey, + stroke: props.stroke || "var(--chart-line-primary)", + strokeWidth: props.strokeWidth ?? 2.5, + }); + } + return true; +} + +function tryAppendArea(child: ReactElement, lines: LineConfig[]): boolean { + const name = getChildComponentName(child); + if (!(child.type === Area || name === "Area")) { + return false; + } + const props = child.props as AreaProps; + if (props.dataKey) { + upsertLineConfig(lines, { + dataKey: props.dataKey, + stroke: props.stroke || props.fill || "var(--chart-line-primary)", + strokeWidth: props.strokeWidth ?? 2, + }); + } + return true; +} + +function extractComposedSeries(children: ReactNode): { + lines: LineConfig[]; + barDataKeys: string[]; +} { + const lines: LineConfig[] = []; + const barDataKeys: string[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + if (tryAppendSeriesBar(child, lines, barDataKeys)) { + return; + } + if (tryAppendLine(child, lines)) { + return; + } + tryAppendArea(child, lines); + }); + + return { lines, barDataKeys }; +} + +function computeComposedYScaleDomainMax( + data: Record[], + lines: LineConfig[], + barDataKeys: string[] +): number | undefined { + const barSet = new Set(barDataKeys); + let max = 0; + for (const d of data) { + let barSum = 0; + for (const k of barDataKeys) { + const v = d[k]; + if (typeof v === "number") { + barSum += v; + } + } + let rowMaxOther = 0; + for (const line of lines) { + if (barSet.has(line.dataKey)) { + continue; + } + const v = d[line.dataKey]; + if (typeof v === "number") { + rowMaxOther = Math.max(rowMaxOther, v); + } + } + max = Math.max(max, barSum, rowMaxOther); + } + return max > 0 ? max : undefined; +} + +interface ChartInnerProps { + width: number; + height: number; + data: Record[]; + xDataKey: string; + margin: Margin; + animationDuration: number; + animationEasing?: string; + enterTransition?: Transition; + revealSignature?: string; + children: ReactNode; + containerRef: React.RefObject; + barSize?: number; + maxBarSize?: number; + barGap?: number; + stacked?: boolean; + stackGap?: number; +} + +function ChartInner({ + width, + height, + data, + xDataKey, + margin, + animationDuration, + animationEasing, + enterTransition, + revealSignature, + children, + containerRef, + barSize, + maxBarSize, + barGap, + stacked = false, + stackGap = 0, +}: ChartInnerProps) { + const { lines, barDataKeys } = useMemo( + () => extractComposedSeries(children), + [children] + ); + + const composedStackOffsets = useMemo(() => { + if (!(stacked && barDataKeys.length > 0)) { + return undefined; + } + const offsets = new Map>(); + for (let i = 0; i < data.length; i++) { + const d = data[i]; + if (!d) { + continue; + } + const pointOffsets = new Map(); + let cumulative = 0; + for (const key of barDataKeys) { + pointOffsets.set(key, cumulative); + const v = d[key]; + if (typeof v === "number") { + cumulative += v; + } + } + offsets.set(i, pointOffsets); + } + return offsets; + }, [data, barDataKeys, stacked]); + + const yScaleDomainMax = useMemo( + () => + stacked && barDataKeys.length > 0 + ? computeComposedYScaleDomainMax(data, lines, barDataKeys) + : undefined, + [data, lines, barDataKeys, stacked] + ); + + return ( + 0 ? barDataKeys : undefined} + composedBarGap={barGap} + composedBarSize={barSize} + composedMaxBarSize={maxBarSize} + composedStacked={stacked} + composedStackGap={stackGap} + composedStackOffsets={composedStackOffsets} + containerRef={containerRef} + data={data} + enterTransition={enterTransition} + height={height} + lines={lines} + margin={margin} + revealSignature={revealSignature} + width={width} + xDataKey={xDataKey} + yScaleDomainMax={yScaleDomainMax} + > + {children} + + ); +} + +export function ComposedChart({ + data, + xDataKey = "date", + margin: marginProp, + animationDuration = 1100, + animationEasing, + enterTransition, + revealSignature, + aspectRatio = "2 / 1", + className = "", + children, + barSize, + maxBarSize, + barGap = 4, + stacked = false, + stackGap = 0, +}: ComposedChartProps) { + const containerRef = useRef(null); + const margin = { ...DEFAULT_MARGIN, ...marginProp }; + + return ( +
+ + {({ width, height }) => ( + + {children} + + )} + +
+ ); +} + +ComposedChart.displayName = "ComposedChart"; + +export default ComposedChart; diff --git a/apps/start/src/components/charts/dash-tail-stroke.tsx b/apps/start/src/components/charts/dash-tail-stroke.tsx new file mode 100644 index 000000000..becbca5fa --- /dev/null +++ b/apps/start/src/components/charts/dash-tail-stroke.tsx @@ -0,0 +1,75 @@ +"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 ( + <> + + + + + + {/* Solid head — same curved path, gradient/fade preserved */} + + {/* Dashed tail — clipped to x ≥ dashStartX so dashes follow the curve */} + + + ); +} diff --git a/apps/start/src/components/charts/decimate-time-series.ts b/apps/start/src/components/charts/decimate-time-series.ts new file mode 100644 index 000000000..7749a0c2a --- /dev/null +++ b/apps/start/src/components/charts/decimate-time-series.ts @@ -0,0 +1,136 @@ +export function decimateTimeSeries>( + data: T[], + maxPoints: number, + valueKeys: string[] = [] +): T[] { + const len = data.length; + if (maxPoints >= len || maxPoints < 3) { + return data; + } + + const getY = (point: T, index: number): number => { + if (valueKeys.length === 0) { + for (const val of Object.values(point)) { + if (typeof val === "number") { + return val; + } + } + return index; + } + + let sum = 0; + let count = 0; + for (const key of valueKeys) { + const val = point[key]; + if (typeof val === "number") { + sum += val; + count++; + } + } + return count > 0 ? sum / count : index; + }; + + const sampled: T[] = [data[0] as T]; + const bucketSize = (len - 2) / (maxPoints - 2); + let previousIndex = 0; + + for (let i = 0; i < maxPoints - 2; i++) { + const rangeStart = Math.floor((i + 1) * bucketSize) + 1; + const rangeEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, len - 1); + + const nextRangeStart = Math.floor((i + 2) * bucketSize) + 1; + const nextRangeEnd = Math.min(Math.floor((i + 3) * bucketSize) + 1, len); + const nextCount = Math.max(0, nextRangeEnd - nextRangeStart); + + let avgX = len - 1; + let avgY = getY(data[len - 1] as T, len - 1); + if (nextCount > 0) { + avgX = 0; + avgY = 0; + for (let j = nextRangeStart; j < nextRangeEnd; j++) { + avgX += j; + avgY += getY(data[j] as T, j); + } + avgX /= nextCount; + avgY /= nextCount; + } + + const pointA = data[previousIndex] as T; + const ax = previousIndex; + const ay = getY(pointA, previousIndex); + + let maxArea = -1; + let maxIndex = rangeStart; + + for (let j = rangeStart; j < rangeEnd; j++) { + const area = + Math.abs( + (ax - avgX) * (getY(data[j] as T, j) - ay) - (ax - j) * (avgY - ay) + ) * 0.5; + if (area > maxArea) { + maxArea = area; + maxIndex = j; + } + } + + sampled.push(data[maxIndex] as T); + previousIndex = maxIndex; + } + + sampled.push(data[len - 1] as T); + return sampled; +} + +/** ~1.5 points per pixel — enough for crisp curves without over-drawing. */ +export function maxRenderPointsForWidth(innerWidth: number): number { + return Math.max(64, Math.ceil(innerWidth * 1.5)); +} + +/** Bucket OHLC rows into fewer candles while preserving high/low extremes. */ +export function decimateOhlcData>( + data: T[], + maxPoints: number +): T[] { + const len = data.length; + if (maxPoints >= len || maxPoints < 2) { + return data; + } + + const bucketSize = len / maxPoints; + const sampled: T[] = []; + + for (let i = 0; i < maxPoints; i++) { + const start = Math.floor(i * bucketSize); + const end = Math.min(len, Math.floor((i + 1) * bucketSize)); + if (start >= end) { + continue; + } + + const bucket = data.slice(start, end); + const first = bucket[0] as T; + const last = bucket.at(-1) as T; + + let high = Number.NEGATIVE_INFINITY; + let low = Number.POSITIVE_INFINITY; + for (const row of bucket) { + const rowHigh = row.high; + const rowLow = row.low; + if (typeof rowHigh === "number" && rowHigh > high) { + high = rowHigh; + } + if (typeof rowLow === "number" && rowLow < low) { + low = rowLow; + } + } + + sampled.push({ + ...last, + open: first.open, + high: Number.isFinite(high) ? high : last.high, + low: Number.isFinite(low) ? low : last.low, + close: last.close, + } as T); + } + + return sampled; +} diff --git a/apps/start/src/components/charts/fade-edges.ts b/apps/start/src/components/charts/fade-edges.ts new file mode 100644 index 000000000..99cbad07b --- /dev/null +++ b/apps/start/src/components/charts/fade-edges.ts @@ -0,0 +1,41 @@ +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 }, + ]; +} diff --git a/apps/start/src/components/charts/grid.tsx b/apps/start/src/components/charts/grid.tsx new file mode 100644 index 000000000..08bf38377 --- /dev/null +++ b/apps/start/src/components/charts/grid.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { GridColumns, GridRows } from "@visx/grid"; +import { useId } from "react"; +import { chartCssVars, useChartStable } from "./chart-context"; + +export interface GridProps { + /** Show horizontal grid lines. Default: true */ + horizontal?: boolean; + /** Show vertical grid lines. Default: false */ + vertical?: boolean; + /** Number of horizontal grid lines. Default: 5 */ + numTicksRows?: number; + /** Number of vertical grid lines. Default: 10 */ + numTicksColumns?: number; + /** Explicit tick values for horizontal grid lines. Overrides numTicksRows. */ + rowTickValues?: number[]; + /** Grid line stroke color. Default: var(--chart-grid) */ + stroke?: string; + /** Grid line stroke opacity. Default: 1 */ + strokeOpacity?: number; + /** Grid line stroke width. Default: 1 */ + strokeWidth?: number; + /** Grid line dash array. Default: "4,4" for dashed lines */ + strokeDasharray?: string; + /** Horizontal row values rendered with alternate styling (e.g. zero baseline). */ + highlightRowValues?: number[]; + /** Stroke for highlighted rows. Default: var(--chart-foreground-muted) */ + highlightRowStroke?: string; + /** Stroke opacity for highlighted rows. Default: 1 */ + highlightRowStrokeOpacity?: number; + /** Stroke width for highlighted rows. Default: 1 */ + highlightRowStrokeWidth?: number; + /** Dash array for highlighted rows. Default: solid line */ + highlightRowStrokeDasharray?: string; + /** Enable horizontal fade effect on grid rows (fades at left/right). Default: true */ + fadeHorizontal?: boolean; + /** Enable vertical fade effect on grid columns (fades at top/bottom). Default: false */ + fadeVertical?: boolean; +} + +export function Grid({ + horizontal = true, + vertical = false, + numTicksRows = 5, + numTicksColumns = 10, + rowTickValues, + stroke = chartCssVars.grid, + strokeOpacity = 1, + strokeWidth = 1, + strokeDasharray = "4,4", + highlightRowValues, + highlightRowStroke = chartCssVars.foregroundMuted, + highlightRowStrokeOpacity = 1, + highlightRowStrokeWidth = 1, + highlightRowStrokeDasharray = "0", + fadeHorizontal = true, + fadeVertical = false, +}: GridProps) { + const { xScale, yScale, innerWidth, innerHeight, orientation, barScale } = + useChartStable(); + + // For bar charts, determine which scale to use for grid lines + // Horizontal bar charts: vertical grid should use yScale (value scale) + // Vertical bar charts: horizontal grid uses yScale (value scale) + const isHorizontalBarChart = orientation === "horizontal" && barScale; + + // For vertical grid lines in horizontal bar charts, use yScale (the value scale) + // For time-based charts, use xScale + const columnScale = isHorizontalBarChart ? yScale : xScale; + const uniqueId = useId(); + + // Horizontal fade mask (for grid rows - fades left/right) + const hMaskId = `grid-rows-fade-${uniqueId}`; + const hGradientId = `${hMaskId}-gradient`; + + // Vertical fade mask (for grid columns - fades top/bottom) + const vMaskId = `grid-cols-fade-${uniqueId}`; + const vGradientId = `${vMaskId}-gradient`; + + return ( + + {/* Gradient mask for horizontal grid lines - fades at left/right */} + {horizontal && fadeHorizontal && ( + + + + + + + + + + + + )} + + {/* Gradient mask for vertical grid lines - fades at top/bottom */} + {vertical && fadeVertical && ( + + + + + + + + + + + + )} + + {horizontal && ( + + + + )} + {horizontal && highlightRowValues && highlightRowValues.length > 0 ? ( + + {highlightRowValues.map((value) => { + const y = yScale(value); + if (y == null || !Number.isFinite(y)) { + return null; + } + + return ( + + ); + })} + + ) : null} + {vertical && columnScale && typeof columnScale === "function" && ( + + + + )} + + ); +} + +Grid.displayName = "Grid"; + +export default Grid; diff --git a/apps/start/src/components/charts/highlight-segment-bounds.ts b/apps/start/src/components/charts/highlight-segment-bounds.ts new file mode 100644 index 000000000..19e66a912 --- /dev/null +++ b/apps/start/src/components/charts/highlight-segment-bounds.ts @@ -0,0 +1,71 @@ +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)) ] +// `` 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[], + xScale: (value: Date) => number | undefined, + xAccessor: (d: Record) => Date, + tooltipData: Pick | null | undefined, + selection: + | Pick + | 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 }; +} diff --git a/apps/start/src/components/charts/highlight-segment.tsx b/apps/start/src/components/charts/highlight-segment.tsx new file mode 100644 index 000000000..aa6295ab1 --- /dev/null +++ b/apps/start/src/components/charts/highlight-segment.tsx @@ -0,0 +1,65 @@ +"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 `` — its `d` is re-used verbatim. */ + pathRef: RefObject; + /** 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; + /** Spring-eased width of the clip band (px). */ + width: MotionValue; +} + +export function HighlightSegment({ + pathRef, + visible, + stroke, + strokeWidth, + height, + x, + width, +}: HighlightSegmentProps) { + const clipId = useId(); + if (!(visible && pathRef.current)) { + return null; + } + return ( + <> + + + + + + + + ); +} + +HighlightSegment.displayName = "HighlightSegment"; + +export default HighlightSegment; diff --git a/apps/start/src/components/charts/line-chart.tsx b/apps/start/src/components/charts/line-chart.tsx new file mode 100644 index 000000000..baea6d28a --- /dev/null +++ b/apps/start/src/components/charts/line-chart.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { ParentSize } from "@visx/responsive"; +import type { Transition } from "motion/react"; +import { + Children, + isValidElement, + type ReactElement, + type ReactNode, + useMemo, + useRef, +} from "react"; +import { cn } from "@/lib/utils"; +import type { LineConfig, Margin } from "./chart-context"; +import { Line, type LineProps } from "./line"; +import { TimeSeriesChartInner } from "./time-series-chart-shell"; + +export interface LineChartProps { + /** Data array - each item should have a date field and numeric values */ + data: Record[]; + /** Key in data for the x-axis (date). Default: "date" */ + xDataKey?: string; + /** Chart margins */ + margin?: Partial; + /** Animation duration in milliseconds. Default: 1100 */ + animationDuration?: number; + /** CSS easing for clip-reveal. Default: cubic-bezier(0.85, 0, 0.15, 1) */ + animationEasing?: string; + enterTransition?: Transition; + revealSignature?: string; + /** Aspect ratio as "width / height". Default: "2 / 1" */ + aspectRatio?: string; + /** Additional class name for the container */ + className?: string; + /** Child components (Line, Grid, ChartTooltip, etc.) */ + children: ReactNode; +} + +const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 }; + +/** Series renderers that carry a dataKey but must not drive the shared y-domain. */ +const LINE_DOMAIN_EXCLUDED_NAMES = new Set([ + "ProfitLossLine", + "Area", + "SeriesBar", + "Scatter", + "Candlestick", + "Bar", + "PatternArea", +]); + +function getChildComponentName(child: ReactElement) { + const childType = child.type as { displayName?: string; name?: string }; + return typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; +} + +function registersLineDomain( + child: ReactElement, + props: LineProps | undefined +) { + if (!props?.dataKey) { + return false; + } + + const componentName = getChildComponentName(child); + if (componentName === "Line" || child.type === Line) { + return true; + } + if (LINE_DOMAIN_EXCLUDED_NAMES.has(componentName)) { + return false; + } + // MDX / duplicate bundle instances may not share the same `Line` reference. + return typeof props.dataKey === "string" && props.dataKey.length > 0; +} + +function extractLineConfigs(children: ReactNode): LineConfig[] { + const configs: LineConfig[] = []; + + const visit = (node: ReactNode) => { + Children.forEach(node, (child) => { + if (!isValidElement(child)) { + return; + } + + const props = child.props as LineProps | undefined; + + if (registersLineDomain(child, props) && props?.dataKey) { + configs.push({ + dataKey: props.dataKey, + stroke: props.stroke || "var(--chart-line-primary)", + strokeWidth: props.strokeWidth || 2.5, + }); + return; + } + + const childProps = child.props as { children?: ReactNode } | undefined; + if (childProps?.children) { + visit(childProps.children); + } + }); + }; + + visit(children); + return configs; +} + +interface ChartInnerProps { + width: number; + height: number; + data: Record[]; + xDataKey: string; + margin: Margin; + animationDuration: number; + animationEasing?: string; + enterTransition?: Transition; + revealSignature?: string; + children: ReactNode; + containerRef: React.RefObject; +} + +function ChartInner({ + width, + height, + data, + xDataKey, + margin, + animationDuration, + animationEasing, + enterTransition, + revealSignature, + children, + containerRef, +}: ChartInnerProps) { + const lines = useMemo(() => extractLineConfigs(children), [children]); + + return ( + + {children} + + ); +} + +export function LineChart({ + data, + xDataKey = "date", + margin: marginProp, + animationDuration = 1100, + animationEasing, + enterTransition, + revealSignature, + aspectRatio = "2 / 1", + className = "", + children, +}: LineChartProps) { + const containerRef = useRef(null); + const margin = { ...DEFAULT_MARGIN, ...marginProp }; + + return ( +
+ + {({ width, height }) => ( + + {children} + + )} + +
+ ); +} + +export { Line, type LineProps } from "./line"; + +export default LineChart; diff --git a/apps/start/src/components/charts/line.tsx b/apps/start/src/components/charts/line.tsx new file mode 100644 index 000000000..673f84dff --- /dev/null +++ b/apps/start/src/components/charts/line.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { curveNatural } from "@visx/curve"; +import { LinePath } from "@visx/shape"; + +// CurveFactory type - simplified version compatible with visx +// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type +type CurveFactory = any; + +import { useCallback, useId, useRef } from "react"; +import { chartCssVars, useChartStable } from "./chart-context"; +import { + type FadeEdges, + fadeGradientStops, + resolveFadeSides, +} from "./fade-edges"; +import { + resolveDashTailBounds, + usePathStrokeMetrics, +} from "./path-stroke-utils"; +import { SeriesDashTailOverlay } from "./series-dash-tail-overlay"; +import { SeriesHighlightLayer } from "./series-highlight-layer"; +import { SeriesHoverDim } from "./series-hover-dim"; +import { SeriesMarkers } from "./series-markers"; +import type { SeriesPointMarkerStyle } from "./series-point-marker"; + +export interface LineProps { + /** Key in data to use for y values */ + dataKey: string; + /** Stroke color. Default: var(--chart-line-primary) */ + stroke?: string; + /** Stroke width. Default: 2.5 */ + strokeWidth?: number; + /** Curve function. Default: curveNatural */ + curve?: CurveFactory; + /** Whether to animate the line. Default: true */ + animate?: boolean; + /** + * Fade the line stroke toward transparent at the chart edges. + * - `true` fades both edges, `false` disables the fade entirely. + * - `"left"` / `"right"` fades only that side. + * Default: true + */ + fadeEdges?: FadeEdges; + /** Whether to show highlight segment on hover. Default: true */ + showHighlight?: boolean; + /** Render scatter-style circle markers at each data point. Default: false */ + showMarkers?: boolean; + /** Marker styling (same options as Scatter). */ + markers?: SeriesPointMarkerStyle; + /** + * Data index from which the line stroke becomes dashed (inclusive). + * Useful for projecting incomplete periods, e.g. dashed from yesterday through today. + */ + dashFromIndex?: number; + /** Dash pattern for the tail segment when `dashFromIndex` is set. Default: "6,4" */ + dashArray?: string; +} + +export function Line({ + dataKey, + stroke = chartCssVars.linePrimary, + strokeWidth = 2.5, + curve = curveNatural, + animate = true, + fadeEdges = true, + showHighlight = true, + showMarkers = false, + markers, + dashFromIndex, + dashArray = "6,4", +}: LineProps) { + // Stable slice only: hover state lives inside `` and + // `` so this component (and its expensive + // child) does not re-render on cursor motion. + // The reveal-clip is now a single shared clipPath at the chart-shell + // level (`time-series-chart-shell.tsx`); we no longer render a per-line + // `` or read `revealEpoch` here. + const { + data, + renderData, + xScale, + yScale, + innerHeight, + innerWidth, + xAccessor, + } = useChartStable(); + + const pathRef = useRef(null); + const pathMetricsKey = `${renderData.length}:${innerWidth}:${dashFromIndex}:${animate}`; + const { pathLength, pathD } = usePathStrokeMetrics(pathRef, pathMetricsKey); + + const reactId = useId(); + const gradientId = `line-gradient-${dataKey}-${reactId}`; + + const getY = useCallback( + (d: Record) => { + const value = d[dataKey]; + return typeof value === "number" ? (yScale(value) ?? 0) : 0; + }, + [dataKey, yScale] + ); + + const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length); + const fadeSides = resolveFadeSides(fadeEdges); + const lineStroke = fadeSides.any ? `url(#${gradientId})` : stroke; + const fadeStops = fadeSides.any ? fadeGradientStops(fadeSides) : null; + + return ( + <> + {fadeStops ? ( + + + {fadeStops.map((stop) => ( + + ))} + + + ) : null} + + + xScale(xAccessor(d)) ?? 0} + y={getY} + /> + + + + + {showMarkers ? ( + + ) : null} + + + + ); +} + +Line.displayName = "Line"; + +export default Line; diff --git a/apps/start/src/components/charts/markers/chart-markers.tsx b/apps/start/src/components/charts/markers/chart-markers.tsx new file mode 100644 index 000000000..0c31906ee --- /dev/null +++ b/apps/start/src/components/charts/markers/chart-markers.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { chartCssVars, useChart } from "../chart-context"; +import { type ChartMarker, MarkerGroup } from "./marker-group"; + +export interface ChartMarkersProps { + /** Array of markers to display */ + items: ChartMarker[]; + /** Size of each marker circle. Default: 28 */ + size?: number; + /** Whether to show vertical guide lines. Default: true */ + showLines?: boolean; + /** Whether to animate markers on entrance. Default: true */ + animate?: boolean; +} + +// Tooltip content for markers +export interface MarkerTooltipContentProps { + markers: ChartMarker[]; +} + +const MAX_TOOLTIP_MARKERS = 2; + +export function MarkerTooltipContent({ markers }: MarkerTooltipContentProps) { + if (markers.length === 0) { + return null; + } + + const visibleMarkers = markers.slice(0, MAX_TOOLTIP_MARKERS); + const hiddenCount = markers.length - MAX_TOOLTIP_MARKERS; + + return ( +
+ {visibleMarkers.map((marker) => { + const isClickable = !!(marker.onClick || marker.href); + return ( +
+
+ + {marker.icon} + +
+
+ {marker.content ? ( + marker.content + ) : ( + <> +
+ {marker.title} + {isClickable && ( + + ↗ + + )} +
+ {marker.description && ( +
+ {marker.description} +
+ )} + + )} +
+
+ ); + })} + {hiddenCount > 0 && ( +
+ +{hiddenCount} more... +
+ )} +
+ ); +} + +export function ChartMarkers({ + items, + size = 28, + showLines = true, + animate = true, +}: ChartMarkersProps) { + const { + xScale, + innerHeight, + margin, + containerRef, + tooltipData, + setTooltipData, + animationDuration, + } = useChart(); + + // Hide the crosshair when hovering markers (matching original behavior) + const handleMarkerHover = useCallback( + (markers: ChartMarker[] | null) => { + if (markers) { + // Hide crosshair when hovering a marker + setTooltipData(null); + } + }, + [setTooltipData] + ); + + // Group markers by date + const markersByDate = useMemo(() => { + const grouped = new Map(); + for (const marker of items) { + const dateKey = marker.date.toDateString(); + const existing = grouped.get(dateKey) || []; + grouped.set(dateKey, [...existing, marker]); + } + return grouped; + }, [items]); + + // Get markers for currently hovered date + const _activeMarkers = useMemo(() => { + if (!tooltipData) { + return []; + } + const point = tooltipData.point; + const date = + point.date instanceof Date + ? point.date + : new Date(point.date as string | number); + const dateKey = date.toDateString(); + return markersByDate.get(dateKey) || []; + }, [tooltipData, markersByDate]); + + // Y position for markers (above chart area) + const markerY = -8; + + return ( + <> + {/* SVG markers rendered in chart space */} + {Array.from(markersByDate.entries()).map( + ([dateKey, dateMarkers], groupIndex) => { + const markerDate = dateMarkers[0]?.date; + if (!markerDate) { + return null; + } + + const markerX = xScale(markerDate) ?? 0; + const isActive = tooltipData + ? (() => { + const point = tooltipData.point; + const date = + point.date instanceof Date + ? point.date + : new Date(point.date as string | number); + return date.toDateString() === dateKey; + })() + : undefined; + + const markerDelay = animate + ? animationDuration / 1000 + groupIndex * 0.1 + : 0; + + return ( + + ); + } + )} + + ); +} + +// Hook to get active markers for tooltip +export function useActiveMarkers(items: ChartMarker[]) { + const { tooltipData } = useChart(); + + return useMemo(() => { + if (!tooltipData) { + return []; + } + const point = tooltipData.point; + const date = + point.date instanceof Date + ? point.date + : new Date(point.date as string | number); + const dateKey = date.toDateString(); + return items.filter((m) => m.date.toDateString() === dateKey); + }, [tooltipData, items]); +} + +ChartMarkers.displayName = "ChartMarkers"; +// Marker for SVG component detection (renders after mouse overlay for interaction) +(ChartMarkers as { __isChartMarkers?: boolean }).__isChartMarkers = true; +MarkerTooltipContent.displayName = "MarkerTooltipContent"; + +export default ChartMarkers; diff --git a/apps/start/src/components/charts/markers/index.ts b/apps/start/src/components/charts/markers/index.ts new file mode 100644 index 000000000..32b9f1f7f --- /dev/null +++ b/apps/start/src/components/charts/markers/index.ts @@ -0,0 +1,12 @@ +export { + ChartMarkers, + type ChartMarkersProps, + MarkerTooltipContent, + type MarkerTooltipContentProps, + useActiveMarkers, +} from "./chart-markers"; +export { + type ChartMarker, + MarkerGroup, + type MarkerGroupProps, +} from "./marker-group"; diff --git a/apps/start/src/components/charts/markers/marker-group.tsx b/apps/start/src/components/charts/markers/marker-group.tsx new file mode 100644 index 000000000..73c6c6138 --- /dev/null +++ b/apps/start/src/components/charts/markers/marker-group.tsx @@ -0,0 +1,528 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import type * as React from "react"; +import { useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "@/lib/utils"; +import { chartCssVars } from "../chart-context"; + +// Fan configuration +const FAN_RADIUS = 50; +const FAN_ANGLE = 160; + +export interface ChartMarker { + /** Date for this marker (will be matched to nearest data point) */ + date: Date; + /** Icon to display in the marker circle */ + icon: React.ReactNode; + /** Title shown in tooltip */ + title: string; + /** Optional description */ + description?: string; + /** Optional custom content for tooltip (overrides title/description) */ + content?: React.ReactNode; + /** Optional color override for the marker circle */ + color?: string; + /** Click handler */ + onClick?: () => void; + /** URL to navigate to when clicked */ + href?: string; + /** Open href in new tab. Default: false */ + target?: "_blank" | "_self"; +} + +export interface MarkerGroupProps { + /** X position in pixels */ + x: number; + /** Y position (top of chart area) */ + y: number; + /** Markers at this position */ + markers: ChartMarker[]; + /** Whether this marker group is currently hovered (via chart hover) */ + isActive?: boolean; + /** Size of each marker circle */ + size?: number; + /** Callback when marker group is hovered */ + onHover?: (markers: ChartMarker[] | null) => void; + /** Reference to chart container for portal positioning */ + containerRef?: React.RefObject; + /** Margin left offset from chart container */ + marginLeft?: number; + /** Margin top offset from chart container */ + marginTop?: number; + /** Delay before entrance animation starts */ + animationDelay?: number; + /** Whether the marker should animate in */ + animate?: boolean; + /** Height of the vertical guide line below the marker */ + lineHeight?: number; + /** Whether to show the vertical guide line. Default: true */ + showLine?: boolean; + /** + * Force the marker fan to open even when the user isn't hovering this + * group directly. Used by OPMarkerLayer to fan out a cluster when the + * chart's main crosshair lands on one of the cluster's buckets. + */ + forceOpen?: boolean; + /** + * Make the icon `foreignObject` fill the entire circle (no 4px inset) + * so favicon-style icons sit edge-to-edge with the marker border. + */ + iconFill?: boolean; + /** + * Override the marker circle's stroke color. Falls back to + * `chartCssVars.markerBorder` when not set. + */ + borderColor?: string; + /** + * Marker circle stroke width in px. Default 1.5. + */ + borderWidth?: number; + /** + * Cap the number of markers rendered in the fan-out arc. The count + * badge still reflects the full cluster size. Without this, large + * clusters (e.g. 20+ spikes) collapse the fan angular spacing to + * essentially zero and the markers stack on top of each other. + */ + maxFanned?: number; + /** + * Fade this marker group when another cluster has focus. Used by + * OPMarkerLayer to spotlight the active cluster. + */ + isMuted?: boolean; + /** Count-badge circle radius in px. Default 9. */ + badgeRadius?: number; + /** Count-badge text size in px. Default 11. */ + badgeFontSize?: number; + /** Offset of badge from marker corner along x/-y. Default 2. */ + badgeOffset?: number; +} + +// Entrance + fanned + muted variants. `fanned` shrinks and dims the +// collapsed marker while its siblings are flying out in the portal, so +// users only see the fan and not a duplicate icon sitting underneath. +// `muted` fades non-active clusters when one cluster is being focused — +// see OPMarkerLayer. +const markerEntranceVariants = { + hidden: { + scale: 0.85, + opacity: 0, + filter: "blur(2px)", + }, + visible: { + scale: 1, + opacity: 1, + filter: "blur(0px)", + }, + fanned: { + scale: 0.6, + opacity: 0, + filter: "blur(2px)", + }, + muted: { + scale: 1, + opacity: 0.4, + filter: "blur(0px)", + }, +}; + +export function MarkerGroup({ + x, + y, + markers, + isActive = false, + size = 28, + onHover, + containerRef, + marginLeft = 0, + marginTop = 0, + animationDelay = 0, + animate = true, + lineHeight = 0, + showLine = true, + forceOpen = false, + iconFill = false, + borderColor, + borderWidth = 1.5, + maxFanned, + isMuted = false, + badgeRadius = 9, + badgeFontSize = 11, + badgeOffset = 2, +}: MarkerGroupProps) { + const [isHovered, setIsHovered] = useState(false); + const shouldFan = (isHovered || forceOpen) && markers.length > 1; + const hasMultiple = markers.length > 1; + const fannedMarkers = + maxFanned !== undefined ? markers.slice(0, maxFanned) : markers; + const currentVariant = shouldFan + ? "fanned" + : isMuted + ? "muted" + : "visible"; + + const getCirclePosition = (index: number, total: number) => { + const startAngle = -90 - FAN_ANGLE / 2; + const angleStep = total > 1 ? FAN_ANGLE / (total - 1) : 0; + const angle = startAngle + index * angleStep; + const radians = (angle * Math.PI) / 180; + + return { + x: Math.cos(radians) * FAN_RADIUS, + y: Math.sin(radians) * FAN_RADIUS, + }; + }; + + const handleMouseEnter = (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent chart from handling this event + setIsHovered(true); + onHover?.(markers); + }; + + const handleMouseLeave = (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent chart from handling this event + setIsHovered(false); + onHover?.(null); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent chart crosshair from moving while hovering markers + }; + + const portalX = x + marginLeft; + const portalY = y + marginTop; + + return ( + <> + {/* Position group - no interaction */} + + {/* Vertical guide line - non-interactive, rendered first (behind marker) */} + {showLine && lineHeight > 0 && ( + { + if (isHovered) { + return 1; + } + if (isActive) { + return 0; + } + return 0.6; + })(), + }} + initial={{ strokeOpacity: 0.6 }} + stroke={chartCssVars.markerBorder} + strokeDasharray="4,4" + strokeLinecap="round" + strokeWidth={1} + style={{ pointerEvents: "none" }} + transition={{ duration: 0.2, ease: "easeOut" }} + x1={0} + x2={0} + y1={size / 2 + 4} + y2={lineHeight + Math.abs(y)} + /> + )} + + {/* Interactive marker group */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: Chart marker interaction */} + + + {/* Hit area - covers marker circle with padding for count badge above */} + + + {/* Main marker */} + + + {/* Count badge */} + + {hasMultiple && !shouldFan && ( + + + + {markers.length} + + + )} + + + + + + {/* Portal for fanned circles */} + {containerRef?.current && + createPortal( + // biome-ignore lint/a11y/noStaticElementInteractions: Marker hover portal + // biome-ignore lint/a11y/noNoninteractiveElementInteractions: Marker hover portal +
+ {/* Center point offset - all fanned markers are positioned relative to this */} +
+ + {shouldFan && + fannedMarkers.map((marker, index) => { + const position = getCirclePosition( + index, + fannedMarkers.length + ); + return ( + + + + ); + })} + + + + {shouldFan && ( + +
+ + )} + +
+
, + containerRef.current + )} + + ); +} + +interface MarkerCircleProps { + icon: React.ReactNode; + size: number; + color?: string; + onClick?: () => void; + href?: string; + target?: "_blank" | "_self"; + isClickable?: boolean; + /** Edge-to-edge icon (no 4px inset). */ + iconFill?: boolean; + /** Override circle stroke color. */ + borderColor?: string; + /** Circle stroke width. */ + borderWidth?: number; +} + +function MarkerCircle({ + icon, + size, + color, + iconFill = false, + borderColor, + borderWidth = 1.5, +}: MarkerCircleProps) { + const inset = iconFill ? 0 : 4; + return ( + + + + +
+ {icon} +
+
+
+ ); +} + +function MarkerCircleHTML({ + icon, + size, + color, + onClick, + href, + target = "_self", + isClickable = false, + iconFill = false, + borderColor, + borderWidth = 1.5, +}: MarkerCircleProps) { + const hasAction = isClickable || onClick || href; + const inset = iconFill ? 0 : 4; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onClick) { + onClick(); + } else if (href) { + if (target === "_blank") { + window.open(href, "_blank", "noopener,noreferrer"); + } else { + window.location.href = href; + } + } + }; + + // Note: color and CSS vars must remain inline styles as they're dynamic + return ( + + {icon} + + ); +} + +MarkerGroup.displayName = "MarkerGroup"; + +export default MarkerGroup; diff --git a/apps/start/src/components/charts/motion-utils.ts b/apps/start/src/components/charts/motion-utils.ts new file mode 100644 index 000000000..77a79fe6c --- /dev/null +++ b/apps/start/src/components/charts/motion-utils.ts @@ -0,0 +1,58 @@ +import type { Transition } from "motion/react"; +import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation"; + +export function transitionWithDelay( + transition: Transition | undefined, + delaySeconds: number, + fallback: Transition = DEFAULT_CHART_ENTER_TRANSITION +): Transition { + const base = transition ?? fallback; + return { ...base, delay: delaySeconds }; +} + +export interface SpringOptions { + stiffness: number; + damping: number; + mass?: number; +} + +export function springOptionsFromTransition( + transition?: Transition, + fallback: SpringOptions = { stiffness: 60, damping: 20 } +): SpringOptions { + if (!transition) { + return fallback; + } + if (transition.type === "spring") { + const bounce = + typeof transition.bounce === "number" ? transition.bounce : undefined; + const baseStiffness = + typeof transition.stiffness === "number" + ? transition.stiffness + : fallback.stiffness; + const baseDamping = + typeof transition.damping === "number" + ? transition.damping + : fallback.damping; + return { + stiffness: + bounce == null + ? baseStiffness + : Math.min(400, Math.max(80, baseStiffness * (1 + bounce * 0.35))), + damping: + bounce == null + ? baseDamping + : Math.max(8, baseDamping * (1 - bounce * 0.25)), + mass: + typeof transition.mass === "number" ? transition.mass : fallback.mass, + }; + } + const duration = + "duration" in transition && typeof transition.duration === "number" + ? transition.duration + : 0.8; + return { + stiffness: Math.min(500, Math.max(40, 280 / duration)), + damping: Math.min(40, Math.max(12, 18 + duration * 4)), + }; +} diff --git a/apps/start/src/components/charts/op-dashed-tail.ts b/apps/start/src/components/charts/op-dashed-tail.ts new file mode 100644 index 000000000..f04629974 --- /dev/null +++ b/apps/start/src/components/charts/op-dashed-tail.ts @@ -0,0 +1,81 @@ +import type { IInterval } from '@openpanel/validation'; + +interface UseDashedTailOptions { + data: T[]; + range: string | null | undefined; + interval: IInterval; +} + +/** + * Returns the `dashFromIndex` to pass to a bklit `` / `` / + * `` so the in-progress current period (and any future-extending + * buckets that haven't happened yet) render as a dashed tail. + * + * Two cases: + * - Data ends in the past or at the current bucket (e.g. `30d` ending today). + * We dash the very last segment so today's incomplete bucket reads as + * "not done yet" → `dashFromIndex = data.length - 2`. + * - Data extends into the future (e.g. `today` with hourly buckets — there's + * a bucket per hour through midnight). We dash everything from the + * current-hour bucket onward → `dashFromIndex = currentBucketIndex`. + * + * Returns `undefined` when there's nothing to dash (too little data or + * "now" hasn't reached the first bucket yet). + */ +export function useDashedTail({ + data, + range, + interval: _interval, +}: UseDashedTailOptions): number | undefined { + if (data.length < 2) return undefined; + + const now = Date.now(); + let currentIdx = -1; + for (let i = 0; i < data.length; i++) { + const item = data[i]; + if (!item) continue; + const t = + item.date instanceof Date ? item.date.getTime() : new Date(item.date).getTime(); + if (t <= now) { + currentIdx = i; + } else { + break; + } + } + + if (currentIdx < 0) { + // All buckets are in the future — nothing to dash. + return undefined; + } + + if (currentIdx === data.length - 1) { + // Data ends at "now" (or in the past). Dash only the last segment so the + // current-period bucket reads as incomplete. Skip dashing entirely if the + // last bucket sits well in the past (i.e. no in-progress period at all). + return range === 'today' || isLastBucketInProgress(data, now) + ? data.length - 2 + : undefined; + } + + // Data extends into the future — dash from one bucket *before* the segment + // leading into the in-progress bucket so the full descent into zero reads + // as "incomplete + future" rather than ending mid-cliff with a solid line. + return Math.max(0, currentIdx - 2); +} + +function isLastBucketInProgress( + data: T[], + now: number, +): boolean { + const last = data[data.length - 1]; + const prev = data[data.length - 2]; + if (!(last && prev)) return false; + const lastT = + last.date instanceof Date ? last.date.getTime() : new Date(last.date).getTime(); + const prevT = + prev.date instanceof Date ? prev.date.getTime() : new Date(prev.date).getTime(); + // Bucket width = gap between consecutive points. If "now" lies within the + // last bucket's window, it's in progress. + const bucketMs = lastT - prevT; + return now >= lastT && now < lastT + bucketMs; +} diff --git a/apps/start/src/components/charts/op-date-pill.tsx b/apps/start/src/components/charts/op-date-pill.tsx new file mode 100644 index 000000000..3622cd838 --- /dev/null +++ b/apps/start/src/components/charts/op-date-pill.tsx @@ -0,0 +1,168 @@ +import { motion, useSpring } from 'motion/react'; +import { useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { differenceInHours, getISOWeek } from 'date-fns'; +import type { IInterval } from '@openpanel/validation'; +import { + type TooltipData, + useChartHover, + useChartStable, +} from './chart-context'; + +// Near-instant — original 300/30 felt sluggish snapping between data points. +const SPRING = { stiffness: 1000, damping: 60 }; + +interface OPDatePillProps { + /** Interval drives format selection together with the data span. */ + interval?: IInterval; +} + +/** + * Compact date pill drawn at the bottom of the chart, replacing bklit's + * `` so we can (a) format the label based on `interval` + data span, + * and (b) use OpenPanel's smaller type scale. + * + * Visibility guard sits in the outer wrapper; the inner component (which + * owns the `useSpring`) only mounts when `tooltipData` exists, so the + * spring initializes at the cursor's actual position. Without this split + * the spring would init at `margin.left` (with `tooltipData` null) and + * have to slide across the chart on first hover — pill looks like it + * "fades in from the left edge". + */ +export function OPDatePill({ interval }: OPDatePillProps) { + const { containerRef } = useChartStable(); + const { tooltipData } = useChartHover(); + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted || !containerRef.current || !tooltipData) { + return null; + } + + return ( + + ); +} + +OPDatePill.displayName = 'OPDatePill'; + +function OPDatePillInner({ + container, + interval, + tooltipData, +}: { + container: HTMLElement; + interval?: IInterval; + tooltipData: TooltipData; +}) { + const { data, xAccessor, margin, width } = useChartStable(); + + const xCenter = tooltipData.x + margin.left; + const animatedX = useSpring(xCenter, SPRING); + animatedX.set(xCenter); + + const label = useMemo( + () => + formatPillDate( + xAccessor(tooltipData.point), + interval ?? 'day', + data, + xAccessor, + ), + [tooltipData, data, xAccessor, interval], + ); + + // Stable across hovers — depends only on chart dimensions. + const maxWidth = useMemo( + () => Math.max(120, width - margin.left - margin.right), + [width, margin.left, margin.right], + ); + + if (!label) return null; + + return createPortal( + +
+ {label} +
+
, + container, + ); +} + +const SHORT_WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const SHORT_MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +function pad(n: number) { + return n < 10 ? `0${n}` : String(n); +} + +function formatPillDate( + date: Date, + interval: IInterval, + data: Record[], + xAccessor: (d: Record) => Date, +): string { + const first = data[0] ? xAccessor(data[0]) : date; + const last = data[data.length - 1] ? xAccessor(data[data.length - 1]!) : date; + const spanHours = Math.max(0, differenceInHours(last, first)); + + const dayMonth = `${SHORT_MONTHS[date.getMonth()]} ${date.getDate()}`; + const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`; + + switch (interval) { + case 'minute': + if (spanHours <= 1) return time; + if (spanHours <= 24) return time; + return `${dayMonth} ${time}`; + + case 'hour': + // ≤ ~25h span → single day, show only the hour + if (spanHours <= 25) return `${pad(date.getHours())}:00`; + // Otherwise include the date + return `${dayMonth} ${pad(date.getHours())}:00`; + + case 'day': + // ≤ 14 days → include weekday, otherwise just the date + if (spanHours <= 24 * 14) { + return `${SHORT_WEEKDAYS[date.getDay()]}, ${dayMonth}`; + } + return dayMonth; + + case 'week': + return `W${getISOWeek(date)} · ${dayMonth}`; + + case 'month': + return `${SHORT_MONTHS[date.getMonth()]} ${date.getFullYear()}`; + + default: + return dayMonth; + } +} diff --git a/apps/start/src/components/charts/op-hover-probe.tsx b/apps/start/src/components/charts/op-hover-probe.tsx new file mode 100644 index 000000000..6045e3ea0 --- /dev/null +++ b/apps/start/src/components/charts/op-hover-probe.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useChart } from './chart-context'; + +interface OPHoverProbeProps { + /** Called whenever the hovered data-point index changes. */ + onChange: (index: number | null) => void; +} + +/** + * Tiny no-render child that exposes a bklit chart's hovered index via callback. + * Drop inside any bklit time-series / bar chart to wire it up to external state. + */ +export function OPHoverProbe({ onChange }: OPHoverProbeProps) { + const { tooltipData } = useChart(); + const index = tooltipData?.index ?? null; + useEffect(() => { + onChange(index); + }, [index, onChange]); + return null; +} diff --git a/apps/start/src/components/charts/op-marker-layer.tsx b/apps/start/src/components/charts/op-marker-layer.tsx new file mode 100644 index 000000000..e21265279 --- /dev/null +++ b/apps/start/src/components/charts/op-marker-layer.tsx @@ -0,0 +1,379 @@ +'use client'; + +import { memo, type ReactNode, useCallback, useMemo } from 'react'; +import { + type TooltipData, + useChartHover, + useChartStable, +} from './chart-context'; +import { type ChartMarker, MarkerGroup } from './markers/marker-group'; + +export interface OPMarkerItem { + date: Date | string; + icon: ReactNode; + title: string; + description?: string; + content?: ReactNode; + color?: string; + onClick?: () => void; + href?: string; + target?: '_blank' | '_self'; +} + +/** + * A group of related markers that render together. Single-item clusters are + * just one marker; multi-item clusters get a count badge and fan out on + * hover (or when the chart's crosshair lands on one of the cluster's + * bucket indices). + */ +export interface OPMarkerCluster { + /** + * Date that drives the marker's x position on the chart. For multi-item + * clusters, this is typically the most important item's date. + */ + anchorDate: Date | string; + /** Items in this cluster. The first item's icon is what shows when collapsed. */ + items: OPMarkerItem[]; +} + +interface OPMarkerLayerProps { + clusters: OPMarkerCluster[] | null | undefined; + /** Marker circle size in px. Default 18 (matches favicon pills elsewhere). */ + size?: number; + /** Show vertical guide line below each cluster. Default false. */ + showLines?: boolean; + /** + * Merge clusters whose anchor x-positions are within this pixel distance. + * Scales sensibly with the chart's xScale: a year of daily data on a + * narrow chart clusters aggressively; a month of daily data on a wide + * chart barely clusters at all. Default 32 (marker width + small gap). + * Pass 0 to disable. + */ + clusterPixelDistance?: number; + /** Count-badge circle radius in px. Default 6. */ + badgeRadius?: number; + /** Count-badge text size in px. Default 8. */ + badgeFontSize?: number; + /** Offset of badge from marker corner along x/-y. Default 0. */ + badgeOffset?: number; +} + +// Markers in this layer use a clean "app icon" look: the icon fills the +// circle edge-to-edge with a high-contrast border (foreground token = +// white in dark mode, dark in light mode). Callers pass fill-aware icons +// (e.g. ``) so they actually fill the larger +// foreignObject the iconFill flag opens up. +const MARKER_BORDER_COLOR = 'var(--foreground)'; +// Cap fanned markers — beyond ~8 the semicircular arc gets too tight and +// markers stack visually. The count badge still shows the true cluster +// size, and the tooltip lists all referrers via its "+ also" line. +const MAX_FANNED_MARKERS = 8; + +/** + * Unified annotation layer for the chart. Both user-curated references and + * auto-detected referrer spikes route through this — new annotation types + * (GitHub commits, deploys, etc.) plug in the same way by adapting their + * data into `OPMarkerCluster[]`. + * + * Hover behavior is bidirectional: + * - Hovering a marker writes tooltipData for the cluster's anchor bucket, + * so the main chart tooltip + crosshair light up as if the user hovered + * the chart at that point. + * - When the chart's crosshair lands on a bucket covered by any item in a + * cluster, that cluster's MarkerGroup fans out automatically. + */ +export function OPMarkerLayer({ + clusters, + size = 18, + showLines = false, + clusterPixelDistance = 32, + badgeRadius = 6, + badgeFontSize = 8, + badgeOffset = 0, +}: OPMarkerLayerProps) { + const merged = usePixelClusters(clusters, clusterPixelDistance); + if (merged.length === 0) { + return null; + } + return ( + <> + {merged.map((cluster, index) => ( + + ))} + + ); +} + +/** + * Merge input clusters whose anchor x-positions fall within + * `pixelDistance` of each other. Walks the input sorted by pixel x, + * accumulating into the current merge group while distance allows. Merged + * clusters preserve item order (first input's items first) and keep the + * first input's anchorDate as the merged anchor. + */ +function usePixelClusters( + clusters: OPMarkerCluster[] | null | undefined, + pixelDistance: number +): OPMarkerCluster[] { + const { xScale } = useChartStable(); + return useMemo(() => { + if (!clusters || clusters.length === 0) { + return []; + } + if (pixelDistance <= 0) { + return clusters; + } + + // Resolve anchor dates + pixel x once, then sort by x so merging is a + // single pass. Inputs with no resolvable x are dropped. + const positioned = clusters + .map((cluster) => { + const anchorDate = + typeof cluster.anchorDate === 'string' + ? new Date(cluster.anchorDate) + : cluster.anchorDate; + const x = xScale(anchorDate); + return x == null ? null : { cluster, anchorDate, x }; + }) + .filter( + (p): p is { cluster: OPMarkerCluster; anchorDate: Date; x: number } => + p !== null + ) + .sort((a, b) => a.x - b.x); + + if (positioned.length === 0) { + return []; + } + + const merged: OPMarkerCluster[] = []; + let currentItems: OPMarkerItem[] = [...positioned[0]!.cluster.items]; + let currentAnchor: Date | string = positioned[0]!.anchorDate; + let currentX = positioned[0]!.x; + + for (let i = 1; i < positioned.length; i++) { + const next = positioned[i]!; + if (next.x - currentX <= pixelDistance) { + // Merge into the current cluster — concat items, keep anchor of + // the earliest member (also gives the visible icon a stable + // identity across renders). + currentItems = currentItems.concat(next.cluster.items); + } else { + merged.push({ anchorDate: currentAnchor, items: currentItems }); + currentItems = [...next.cluster.items]; + currentAnchor = next.anchorDate; + currentX = next.x; + } + } + merged.push({ anchorDate: currentAnchor, items: currentItems }); + return merged; + }, [clusters, xScale, pixelDistance]); +} + +OPMarkerLayer.displayName = 'OPMarkerLayer'; +// Render after the mouse overlay so markers stay clickable. +(OPMarkerLayer as { __isChartMarkers?: boolean }).__isChartMarkers = true; + +function getClusterKey(cluster: OPMarkerCluster): string { + const anchor = + typeof cluster.anchorDate === 'string' + ? cluster.anchorDate + : cluster.anchorDate.toISOString(); + return `${anchor}-${cluster.items[0]?.title ?? ''}`; +} + +interface OPMarkerClusterRendererProps { + cluster: OPMarkerCluster; + index: number; + size: number; + showLine: boolean; + badgeRadius: number; + badgeFontSize: number; + badgeOffset: number; +} + +const OPMarkerClusterRenderer = memo(function OPMarkerClusterRenderer({ + cluster, + index, + size, + showLine, + badgeRadius, + badgeFontSize, + badgeOffset, +}: OPMarkerClusterRendererProps) { + const { + data, + xScale, + yScale, + xAccessor, + lines, + innerHeight, + innerWidth, + margin, + containerRef, + animationDuration, + } = useChartStable(); + const { tooltipData, setTooltipData } = useChartHover(); + + const anchorDate = useMemo( + () => + typeof cluster.anchorDate === 'string' + ? new Date(cluster.anchorDate) + : cluster.anchorDate, + [cluster.anchorDate] + ); + + // Pre-compute which data bucket indices this cluster's items map to. + // On hover the chart provides tooltipData.index; we just check membership. + const matchingIndices = useMemo(() => { + const set = new Set(); + for (const item of cluster.items) { + const itemDate = + typeof item.date === 'string' ? new Date(item.date) : item.date; + const idx = findNearestIndex(itemDate, data, xAccessor); + if (idx >= 0) { + set.add(idx); + } + } + return set; + }, [cluster.items, data, xAccessor]); + + const isActive = tooltipData ? matchingIndices.has(tooltipData.index) : false; + // Fade this cluster whenever the user is interacting with the chart but + // not on this cluster's bucket — keeps the spotlight on whichever one is + // active so adjacent clusters don't visually crowd the open fan. + const isMuted = tooltipData !== null && !isActive; + + // Convert items to ChartMarker payloads — stable per cluster so MarkerGroup + // doesn't see fresh references on every render. + const markers = useMemo( + () => + cluster.items.map((item) => ({ + date: typeof item.date === 'string' ? new Date(item.date) : item.date, + icon: item.icon, + title: item.title, + description: item.description, + content: item.content, + color: item.color, + onClick: item.onClick, + href: item.href, + target: item.target, + })), + [cluster.items] + ); + + // Marker hover writes the cluster's anchor bucket into the chart's + // tooltipData. We don't clear on leave — let the chart's own mouseleave + // handle that, so moving from a marker into the chart doesn't flicker. + const handleHover = useCallback( + (hovered: ChartMarker[] | null) => { + if (!hovered) { + return; + } + const anchorIdx = findNearestIndex(anchorDate, data, xAccessor); + if (anchorIdx < 0) { + return; + } + // Skip the setState entirely if the chart is already on this bucket. + if (tooltipData?.index === anchorIdx) { + return; + } + const point = data[anchorIdx]; + if (!point) { + return; + } + const yPositions: Record = {}; + for (const line of lines) { + const value = point[line.dataKey]; + if (typeof value === 'number') { + yPositions[line.dataKey] = yScale(value) ?? 0; + } + } + const next: TooltipData = { + point, + index: anchorIdx, + x: xScale(anchorDate) ?? 0, + yPositions, + }; + setTooltipData(next); + }, + [ + anchorDate, + data, + xAccessor, + xScale, + yScale, + lines, + tooltipData, + setTooltipData, + ] + ); + + // Clamp x so the marker (and its badge) stay fully inside the plot area — + // otherwise a marker on the first/last bucket gets half-clipped by the SVG + // and the `overflow-clip` chart wrapper. + const rawX = xScale(anchorDate) ?? 0; + const edgePadding = size / 2 + badgeRadius + badgeOffset; + const x = Math.min(Math.max(rawX, edgePadding), innerWidth - edgePadding); + const delay = animationDuration / 1000 + index * 0.05; + + return ( + + ); +}); + +function findNearestIndex( + target: Date, + data: Record[], + xAccessor: (d: Record) => Date +): number { + if (data.length === 0) { + return -1; + } + const targetTime = target.getTime(); + let nearestIdx = 0; + let minDiff = Number.POSITIVE_INFINITY; + for (let i = 0; i < data.length; i++) { + const point = data[i]; + if (!point) { + continue; + } + const diff = Math.abs(xAccessor(point).getTime() - targetTime); + if (diff < minDiff) { + minDiff = diff; + nearestIdx = i; + } + } + return nearestIdx; +} diff --git a/apps/start/src/components/charts/op-references.tsx b/apps/start/src/components/charts/op-references.tsx new file mode 100644 index 000000000..441d008a1 --- /dev/null +++ b/apps/start/src/components/charts/op-references.tsx @@ -0,0 +1,68 @@ +import { FlagIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import type { ChartMarker } from './markers/marker-group'; +import { + OPMarkerLayer, + type OPMarkerCluster, + type OPMarkerItem, +} from './op-marker-layer'; + +export interface OPReferenceItem { + id: string; + date: Date | string; + title: string; + description?: string | null; +} + +/** + * Convert OpenPanel reference rows into ChartMarker[] for use by the + * tooltip's `useReferencesForHoveredPoint` lookup (independent of the chart + * marker rendering, which goes through OPMarkerLayer). + */ +export function toChartMarkers( + items: OPReferenceItem[] | null | undefined, +): ChartMarker[] { + if (!items || items.length === 0) return []; + return items.map((item) => ({ + date: typeof item.date === 'string' ? new Date(item.date) : item.date, + icon: , + title: item.title, + description: item.description ?? undefined, + })); +} + +interface OPReferencesProps { + items: OPReferenceItem[] | null | undefined; + /** Marker circle size in px. Defaults via OPMarkerLayer. */ + size?: number; + /** Show vertical guide line under each marker. Default: true. */ + showLines?: boolean; +} + +/** + * Renders user-curated reference annotations through the shared marker + * layer. One cluster per reference — OPMarkerLayer handles visual density + * via pixel-based merging using the live chart xScale, so dense ranges + * naturally fan instead of overlapping. Inherits bidirectional hover + * (marker ↔ chart tooltip) from OPMarkerLayer. + */ +export function OPReferences({ + items, + size, + showLines = true, +}: OPReferencesProps) { + const clusters = useMemo(() => { + if (!items || items.length === 0) return []; + return items.map((ref) => { + const date = typeof ref.date === 'string' ? new Date(ref.date) : ref.date; + const item: OPMarkerItem = { + date, + icon: , + title: ref.title, + description: ref.description ?? undefined, + }; + return { anchorDate: date, items: [item] }; + }); + }, [items]); + return ; +} diff --git a/apps/start/src/components/charts/op-referrer-spikes.tsx b/apps/start/src/components/charts/op-referrer-spikes.tsx new file mode 100644 index 000000000..7edb8ad72 --- /dev/null +++ b/apps/start/src/components/charts/op-referrer-spikes.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useMemo } from 'react'; +import { SerieIcon } from '@/components/report-chart/common/serie-icon'; +import { + OPMarkerLayer, + type OPMarkerCluster, + type OPMarkerItem, +} from './op-marker-layer'; + +export interface OPReferrerSpikeItem { + date: string | Date; + referrer_name: string; + sessions: number; + baseline: number; + ratio: number; + share: number; + isNew: boolean; + others: Array<{ referrer_name: string; sessions: number; ratio: number }>; +} + +export interface OPReferrerSpikeCluster { + /** Top-score spike's date — drives marker x position. */ + anchorDate: string | Date; + /** Spikes in this cluster, sorted by score descending. Length >= 1. */ + spikes: OPReferrerSpikeItem[]; +} + +interface OPReferrerSpikesProps { + items: OPReferrerSpikeCluster[] | null | undefined; + /** Marker circle size in px. Defaults via OPMarkerLayer. */ + size?: number; + /** + * Fired when the user clicks a spike marker (collapsed favicon, or any + * fanned-out individual marker). Receives the referrer's name so the + * caller can drive a filter, navigation, etc. + */ + onSpikeClick?: (referrerName: string) => void; +} + +/** + * Renders auto-detected referrer spike clusters through the shared marker + * layer. Multi-spike clusters get count badges and fan out (either via + * direct hover or when the chart's crosshair lands on one of their buckets). + * Bidirectional hover is inherited from OPMarkerLayer. The favicon fills + * the marker circle (see OPMarkerLayer's iconFill default). + */ +export function OPReferrerSpikes({ + items, + size, + onSpikeClick, +}: OPReferrerSpikesProps) { + const clusters = useMemo(() => { + if (!items || items.length === 0) return []; + return items.map((cluster) => { + const markerItems: OPMarkerItem[] = cluster.spikes.map((spike) => ({ + date: + typeof spike.date === 'string' ? new Date(spike.date) : spike.date, + icon: , + title: spike.referrer_name, + onClick: onSpikeClick + ? () => onSpikeClick(spike.referrer_name) + : undefined, + })); + return { anchorDate: cluster.anchorDate, items: markerItems }; + }); + }, [items, onSpikeClick]); + + return ; +} diff --git a/apps/start/src/components/charts/op-series-dots.tsx b/apps/start/src/components/charts/op-series-dots.tsx new file mode 100644 index 000000000..e7989380d --- /dev/null +++ b/apps/start/src/components/charts/op-series-dots.tsx @@ -0,0 +1,106 @@ +import { motion, useSpring } from 'motion/react'; +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useChart } from './chart-context'; + +const SPRING = { stiffness: 1000, damping: 60 }; + +export interface OPSeriesDotConfig { + /** dataKey of the series this dot belongs to. */ + dataKey: string; + /** Fill color of the dot. */ + color: string; + /** Optional 1-character label (e.g. "$") drawn inside the dot. */ + label?: string; + /** Dot radius. Defaults to 5 (8 when `label` is set). */ + radius?: number; +} + +interface OPSeriesDotsProps { + dots: OPSeriesDotConfig[]; +} + +/** + * Custom hover dots for bklit charts. Use when you need per-series styling + * (e.g. a "$" inside the revenue dot) — bklit's `` is fixed-shape. + * + * Portals to the chart container above the crosshair (z-[51] > bklit's z-50) + * so dots stay visible on top of the vertical crosshair indicator. + * + * Pair with `showDots={false}` on `` so dots aren't drawn twice. + */ +export function OPSeriesDots({ dots }: OPSeriesDotsProps) { + const { tooltipData, containerRef, margin } = useChart(); + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted || !containerRef.current || !tooltipData) return null; + + return createPortal( + , + containerRef.current, + ); +} + +interface OPDotProps { + x: number; + y: number; + color: string; + label?: string; + radius: number; +} + +function OPDot({ x, y, color, label, radius }: OPDotProps) { + const animatedX = useSpring(x, SPRING); + const animatedY = useSpring(y, SPRING); + animatedX.set(x); + animatedY.set(y); + + return ( + + + {label && ( + + {label} + + )} + + ); +} diff --git a/apps/start/src/components/charts/op-stat-hover-bridge.tsx b/apps/start/src/components/charts/op-stat-hover-bridge.tsx new file mode 100644 index 000000000..a19d8cc46 --- /dev/null +++ b/apps/start/src/components/charts/op-stat-hover-bridge.tsx @@ -0,0 +1,37 @@ +import { useEffect } from 'react'; +import { useChart } from './chart-context'; + +export interface OPStatHoverState> { + /** Index of the hovered data point (null on mouse-leave). */ + index: number | null; + /** The hovered data point (null on mouse-leave). */ + point: T | null; +} + +interface OPStatHoverBridgeProps> { + onHoverChange: (state: OPStatHoverState) => void; +} + +/** + * Tiny no-render child that lifts a bklit chart's hovered point out of context + * and into parent state. Pair with `` (or any other display + * primitive) to swap the visible value to the hovered one — used by stat cards + * that don't want a popup tooltip. + * + * Modeled on bklit's `StatCardHoverBridge` from + * `/Users/lindesvard/Projects/bklit-ui/apps/web/blocks/stat-card-area-01/`. + */ +export function OPStatHoverBridge>({ + onHoverChange, +}: OPStatHoverBridgeProps) { + const { tooltipData } = useChart(); + const index = tooltipData?.index ?? null; + const point = (tooltipData?.point as T | undefined) ?? null; + + // biome-ignore lint/correctness/useExhaustiveDependencies: only fire when hovered point changes + useEffect(() => { + onHoverChange({ index, point }); + }, [index, point]); + + return null; +} diff --git a/apps/start/src/components/charts/op-tooltip.tsx b/apps/start/src/components/charts/op-tooltip.tsx new file mode 100644 index 000000000..dd74dab7b --- /dev/null +++ b/apps/start/src/components/charts/op-tooltip.tsx @@ -0,0 +1,469 @@ +import { getPreviousMetric } from '@openpanel/common'; +import type { IInterval } from '@openpanel/validation'; +import { type ReactNode, useMemo } from 'react'; +import { SerieIcon } from '@/components/report-chart/common/serie-icon'; +import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator'; +import { useChart } from './chart-context'; +import type { ChartMarker } from './markers/marker-group'; +import type { OPReferrerSpikeItem } from './op-referrer-spikes'; +import { type OPReferenceItem, toChartMarkers } from './op-references'; +import { ChartTooltip, type ChartTooltipProps } from './tooltip/chart-tooltip'; +import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; +import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter'; +import { cn } from '@/utils/cn'; + +export type OPTooltipUnit = + | 'currency' + | 'min' + | 'pct' + | 'count' + | 'raw' + | string; + +export interface OPTooltipRow { + /** Color of the left strip — typically getChartColor(index) */ + color: string; + /** Row label / series name */ + label: ReactNode; + /** Primary numeric value. Omit for rows that only use `sub`. */ + value?: number | null; + /** How to format `value`. Default: 'count' (locale string). */ + unit?: OPTooltipUnit; + /** Optional previous-period value — shown as "(prev)" next to value */ + previous?: number | null; + /** Whether higher previous is better (e.g. bounce rate is inverted) */ + inverted?: boolean; + /** Whether to render the percentage diff badge */ + showDiff?: boolean; + /** Optional percentage to append after the value, e.g. "12.3 (45%)" */ + percentage?: number | null; + /** Optional icon shown before the label */ + icon?: ReactNode; + /** Sub-rows shown below the main row (no left strip color) */ + sub?: Array<{ + label: ReactNode; + value: number | string | null | undefined; + unit?: OPTooltipUnit; + color?: string; + }>; +} + +export interface OPChartTooltipProps> + extends Omit { + /** Interval drives the date format in the title (when title is omitted). */ + interval?: IInterval; + /** Override the default date title. Return null to hide it. */ + title?: (point: T) => ReactNode; + /** Map a hovered point to one or more tooltip rows. */ + rows: (point: T) => OPTooltipRow[]; + /** Render extra content below the rows (e.g. referrer breakdown). */ + extra?: (point: T) => ReactNode; + /** Min width of the tooltip card. Default 200. */ + minWidth?: number; + /** When set, references matching the hovered date are listed below the rows. */ + references?: OPReferenceItem[] | null; + /** Max references to list in the tooltip. Default 3. */ + referencesLimit?: number; + /** Auto-detected referrer spikes; the matching bucket's spike is shown below. */ + spikes?: OPReferrerSpikeItem[] | null; +} + +export function OPChartTooltip>({ + interval, + title, + rows, + extra, + minWidth = 200, + references, + referencesLimit = 3, + spikes, + ...rest +}: OPChartTooltipProps) { + const referenceMarkers = useMemo( + () => toChartMarkers(references), + [references] + ); + + return ( + ( + + )} + /> + ); +} + +interface OPTooltipBodyProps { + point: T; + interval?: IInterval; + title?: ReactNode; + rows: OPTooltipRow[]; + extra?: ReactNode; + minWidth: number; + referenceMarkers: ChartMarker[]; + referencesLimit: number; + spikes: OPReferrerSpikeItem[] | null; +} + +function OPTooltipBody>({ + point, + interval, + title, + rows, + extra, + minWidth, + referenceMarkers, + referencesLimit, + spikes, +}: OPTooltipBodyProps) { + const formatDate = useFormatDateInterval({ + interval: interval ?? 'day', + short: false, + }); + const activeReferences = useReferencesForHoveredPoint(referenceMarkers); + const activeSpike = useSpikeForHoveredPoint(spikes); + + const resolvedTitle = useMemo(() => { + if (title === null) { + return null; + } + if (title !== undefined) { + return title; + } + const dateValue = point.date as Date | string | number | undefined; + if (!dateValue) { + return null; + } + return formatDate(new Date(dateValue)); + }, [title, point, formatDate]); + + return ( + + {resolvedTitle != null && ( +
+
{resolvedTitle}
+
+ )} + {rows.map((row, index) => ( + + ))} + {extra} + +
+ ); +} + +/** + * Returns references whose timestamp falls in the bucket of the currently + * hovered data point. We snap each reference to its nearest data index and + * compare against `tooltipData.index` — fine-grained enough for hourly / + * minute intervals (bklit's built-in `useActiveMarkers` only matches by day). + * + * The nearest-index pass is O(refs × data) and only depends on inputs that + * change rarely (refs, data), so it's precomputed once and cached. Hover + * filtering then runs in O(refs) per mouse move instead of O(refs × data). + */ +function useReferencesForHoveredPoint( + references: ChartMarker[] +): ChartMarker[] { + const { tooltipData, data, xAccessor } = useChart(); + + const nearestIndices = useMemo( + () => computeNearestIndices(references, data, xAccessor, (m) => m.date), + [references, data, xAccessor] + ); + + return useMemo(() => { + if (!tooltipData || references.length === 0) { + return []; + } + const hoveredIndex = tooltipData.index; + return references.filter( + (_, refIdx) => nearestIndices[refIdx] === hoveredIndex + ); + }, [tooltipData, references, nearestIndices]); +} + +function useSpikeForHoveredPoint( + spikes: OPReferrerSpikeItem[] | null, +): OPReferrerSpikeItem | null { + const { tooltipData, data, xAccessor } = useChart(); + + const nearestIndices = useMemo( + () => + computeNearestIndices(spikes ?? [], data, xAccessor, (s) => + typeof s.date === 'string' ? new Date(s.date) : s.date, + ), + [spikes, data, xAccessor], + ); + + return useMemo(() => { + if (!tooltipData || !spikes || spikes.length === 0) { + return null; + } + const hoveredIndex = tooltipData.index; + for (let i = 0; i < spikes.length; i++) { + if (nearestIndices[i] === hoveredIndex) { + return spikes[i] ?? null; + } + } + return null; + }, [tooltipData, spikes, nearestIndices]); +} + +/** + * For each item, return the index of the nearest data point by xAccessor time. + * Pre-computed once per (items, data, xAccessor) so hover handlers can do O(1) + * lookups instead of O(items × data) scans on every mouse move. + */ +function computeNearestIndices( + items: T[], + data: Record[], + xAccessor: (d: Record) => Date, + getDate: (item: T) => Date, +): number[] { + if (items.length === 0 || data.length === 0) { + return []; + } + const dataTimes = new Float64Array(data.length); + for (let i = 0; i < data.length; i++) { + const point = data[i]; + dataTimes[i] = point ? xAccessor(point).getTime() : Number.NaN; + } + return items.map((item) => { + const target = getDate(item).getTime(); + let nearestIdx = 0; + let minDiff = Number.POSITIVE_INFINITY; + for (let i = 0; i < dataTimes.length; i++) { + const t = dataTimes[i]!; + if (Number.isNaN(t)) continue; + const diff = Math.abs(t - target); + if (diff < minDiff) { + minDiff = diff; + nearestIdx = i; + } + } + return nearestIdx; + }); +} + +function OPAnnotationsBlock({ + references, + referencesLimit, + spike, +}: { + references: ChartMarker[]; + referencesLimit: number; + spike: OPReferrerSpikeItem | null; +}) { + const visibleRefs = references.slice(0, referencesLimit); + const hiddenRefs = Math.max(0, references.length - referencesLimit); + if (visibleRefs.length === 0 && !spike) return null; + + return ( +
+ {visibleRefs.map((marker) => ( + + ))} + {hiddenRefs > 0 && ( +
+ +{hiddenRefs} more +
+ )} + {spike && } +
+ ); +} + +function OPSpikeAnnotation({ spike }: { spike: OPReferrerSpikeItem }) { + const number = useNumber(); + const sessionsLabel = `${number.short(spike.sessions)} sessions`; + const comparison = spike.isNew + ? 'first time this period' + : `${spike.ratio.toFixed(1)}× typical`; + const sharePct = `${Math.round(spike.share * 100)}% of bucket`; + + const description = ( + <> +
+ {sessionsLabel} · {comparison} · {sharePct} +
+ {spike.others.length > 0 && ( +
+ + also: {spike.others.map((o) => o.referrer_name).join(', ')} +
+ )} + + ); + + return ( + } + title={`Spike from ${spike.referrer_name}`} + description={description} + /> + ); +} + +/** + * Single row used for every tooltip annotation (user references + auto- + * detected referrer spikes). Matches the chart-marker visual: the icon + * fills the circle, surrounded by a high-contrast border. Callers pass + * fill-aware icons (e.g. `` or a sized Lucide icon). + */ +function OPAnnotationRow({ + icon, + title, + description, +}: { + icon: ReactNode; + title: ReactNode; + description?: ReactNode; +}) { + return ( +
+
+ {icon} +
+
+
+ {title} +
+ {description && ( +
+ {description} +
+ )} +
+
+ ); +} + +export function OPTooltipCard({ + children, + className, + style, +}: { + children: ReactNode; + className?: string; + style?: React.CSSProperties; +}) { + return ( +
+ {children} +
+ ); +} + +function OPTooltipRowView({ row }: { row: OPTooltipRow }) { + const number = useNumber(); + const hasValue = row.value != null; + const prev = row.previous ?? null; + const showPrev = prev != null && prev !== 0; + const showDiff = row.showDiff !== false && showPrev; + const diff = + showDiff && hasValue ? getPreviousMetric(row.value!, prev) : null; + + return ( +
+
+
+
+ {row.icon && ( + {row.icon} + )} + {row.label} +
+ {hasValue && ( +
+
+ {formatOPValue(row.value!, row.unit, number)} + {showPrev && ( + + ({formatOPValue(prev, row.unit, number)}) + + )} + {row.percentage != null && ( + + ({number.format(row.percentage)}%) + + )} +
+ {diff && ( + + )} +
+ )} + + {row.sub && row.sub.length > 0 && ( +
+ {row.sub.map((s, i) => ( +
+ {s.label} + + {formatOPValue(s.value as number | string, s.unit, number)} + +
+ ))} +
+ )} +
+
+ ); +} + +function formatOPValue( + value: number | string | null | undefined, + unit: OPTooltipUnit | undefined, + number: ReturnType +): string { + if (value == null) { + return '–'; + } + if (typeof value === 'string') { + return value; + } + + switch (unit) { + case 'currency': + // Stored in cents — convert to dollars + return number.currency(value / 100, { short: true }); + case 'min': + return fancyMinutes(value); + case 'pct': + return `${number.format(value)} %`; + case 'raw': + return String(value); + default: + return number.short(value); + } +} diff --git a/apps/start/src/components/charts/path-stroke-utils.ts b/apps/start/src/components/charts/path-stroke-utils.ts new file mode 100644 index 000000000..90adc88dd --- /dev/null +++ b/apps/start/src/components/charts/path-stroke-utils.ts @@ -0,0 +1,75 @@ +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; +} + +export function usePathStrokeMetrics( + pathRef: RefObject, + remeasureKey: string +) { + const [pathLength, setPathLength] = useState(0); + const [pathD, setPathD] = useState(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: remeasure when series geometry changes + useEffect(() => { + const path = pathRef.current; + if (!path) { + return; + } + const len = path.getTotalLength(); + const d = path.getAttribute("d"); + if (len > 0) { + setPathLength(len); + } + if (d) { + setPathD(d); + } + }, [remeasureKey, pathRef]); + + return { pathLength, pathD }; +} + +export function resolveDashTailBounds( + dashFromIndex: number | undefined, + dataLength: number +): boolean { + return ( + dashFromIndex != null && + dashFromIndex >= 0 && + dashFromIndex < dataLength - 1 + ); +} + +export function resolveDashStartX( + data: Record[], + dashFromIndex: number, + xScale: (value: Date | number) => number | undefined, + xAccessor: (datum: Record) => Date | number +): number { + const dashFromPoint = data[dashFromIndex]; + if (!dashFromPoint) { + return 0; + } + return xScale(xAccessor(dashFromPoint)) ?? 0; +} diff --git a/apps/start/src/components/charts/pattern-area.tsx b/apps/start/src/components/charts/pattern-area.tsx new file mode 100644 index 000000000..99a317c81 --- /dev/null +++ b/apps/start/src/components/charts/pattern-area.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { curveMonotoneX } from "@visx/curve"; +import { AreaClosed } from "@visx/shape"; +import { useChartStable } from "./chart-context"; + +// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type +type CurveFactory = any; + +export interface PatternAreaProps { + /** Key in data to use for y values */ + dataKey: string; + /** Fill color or pattern URL (e.g. `url(#pattern-id)`) */ + fill: string; + /** Curve function. Default: curveMonotoneX */ + curve?: CurveFactory; + /** @deprecated Pattern fill is not clip-revealed; only the stroke `Area` animates. */ + animate?: boolean; +} + +/** + * Filled area using an SVG pattern (`url(#id)`). + * Pair with `PatternLines` in `AreaChart` children and an `Area` with `fillOpacity={0}` for the stroke line. + */ +export function PatternArea({ + dataKey, + fill, + curve = curveMonotoneX, +}: PatternAreaProps) { + const { data, xScale, yScale, xAccessor } = useChartStable(); + + return ( + xScale(xAccessor(d)) ?? 0} + y={(d) => { + const v = d[dataKey]; + return typeof v === "number" ? (yScale(v) ?? 0) : 0; + }} + yScale={yScale} + /> + ); +} + +PatternArea.displayName = "PatternArea"; + +export default PatternArea; diff --git a/apps/start/src/components/charts/series-bar-layout.ts b/apps/start/src/components/charts/series-bar-layout.ts new file mode 100644 index 000000000..ed2249b85 --- /dev/null +++ b/apps/start/src/components/charts/series-bar-layout.ts @@ -0,0 +1,61 @@ +export function computeSeriesBarWidth(input: { + innerWidth: number; + dataLength: number; + columnWidth: number; + seriesCount: number; + composedBarSize?: number; + composedMaxBarSize?: number; + composedBarGap?: number; + stacked?: boolean; +}): number { + const { + innerWidth, + dataLength, + columnWidth, + seriesCount, + composedBarSize, + composedMaxBarSize, + composedBarGap = 4, + stacked = false, + } = input; + + const gap = composedBarGap; + const groupCount = stacked ? 1 : Math.max(1, seriesCount); + let slot = columnWidth; + if (slot <= 0) { + slot = dataLength < 2 ? innerWidth : innerWidth / (dataLength - 1); + } + + let width = + composedBarSize ?? + Math.min(slot * 0.88, composedMaxBarSize ?? Number.POSITIVE_INFINITY); + if (composedMaxBarSize != null) { + width = Math.min(width, composedMaxBarSize); + } + if (groupCount > 1) { + const maxGroup = slot * 0.92; + const needed = groupCount * width + (groupCount - 1) * gap; + if (needed > maxGroup && maxGroup > 0) { + width = Math.max(4, (maxGroup - (groupCount - 1) * gap) / groupCount); + } + } + + return Math.max(2, width); +} + +/** Half-width of the bar group at each x — used to pad reveal clips. */ +export function computeSeriesBarRevealClipPadding(input: { + barWidth: number; + seriesCount: number; + gap?: number; + stacked?: boolean; +}): number { + const { barWidth, seriesCount, gap = 4, stacked = false } = input; + + if (stacked || seriesCount <= 1) { + return Math.ceil(barWidth / 2); + } + + const groupWidth = seriesCount * barWidth + (seriesCount - 1) * gap; + return Math.ceil(groupWidth / 2); +} diff --git a/apps/start/src/components/charts/series-bar.tsx b/apps/start/src/components/charts/series-bar.tsx new file mode 100644 index 000000000..1da6b55fa --- /dev/null +++ b/apps/start/src/components/charts/series-bar.tsx @@ -0,0 +1,316 @@ +"use client"; + +import type { Transition } from "motion/react"; +import { motion } from "motion/react"; +import { useMemo } from "react"; +import { chartCssVars, useChart } from "./chart-context"; +import { transitionWithDelay } from "./motion-utils"; +import { computeSeriesBarWidth } from "./series-bar-layout"; + +function computeSeriesBarLayout(input: { + stacked: boolean; + composedStackOffsets: Map> | undefined; + rowIndex: number; + dataKey: string; + value: number; + yScale: (n: number) => number | undefined; + innerHeight: number; + xCenter: number; + barWidth: number; + seriesCount: number; + gap: number; + seriesIndex: number; + stackGap: number; + isLastSeries: boolean; + radius: number; +}): { + barLeft: number; + barHeight: number; + effectiveRadius: number; + valueY: number; +} { + const { + stacked, + composedStackOffsets, + rowIndex, + dataKey, + value, + yScale, + innerHeight, + xCenter, + barWidth, + seriesCount, + gap, + seriesIndex, + stackGap, + isLastSeries, + radius, + } = input; + + if (stacked && composedStackOffsets) { + const offset = composedStackOffsets.get(rowIndex)?.get(dataKey) ?? 0; + const valuePos = yScale(value) ?? 0; + let barHeight = innerHeight - valuePos; + const offsetY = yScale(offset) ?? innerHeight; + const gapOffset = seriesIndex * stackGap; + const valueY = offsetY - barHeight - gapOffset; + if (!isLastSeries && stackGap > 0) { + barHeight = Math.max(0, barHeight - stackGap); + } + const barLeft = xCenter - barWidth / 2; + const applyRounding = stackGap > 0 || isLastSeries; + return { + barLeft, + barHeight, + effectiveRadius: applyRounding ? radius : 0, + valueY, + }; + } + + const groupWidth = + seriesCount * barWidth + (seriesCount > 1 ? (seriesCount - 1) * gap : 0); + const valueY = yScale(value) ?? innerHeight; + return { + barLeft: xCenter - groupWidth / 2 + seriesIndex * (barWidth + gap), + barHeight: innerHeight - valueY, + effectiveRadius: radius, + valueY, + }; +} + +export interface SeriesBarProps { + /** Key in data for bar height (y value) */ + dataKey: string; + /** Fill color. Default: var(--chart-line-primary) */ + fill?: string; + /** Tooltip dot color when fill is gradient/pattern. Default: fill */ + stroke?: string; + /** Corner radius for bar top corners. Default: 0 (square tops, similar to Bar lineCap="butt") */ + radius?: number; + /** Animate grow from baseline. Default: true */ + animate?: boolean; + /** Opacity for non-hovered bars when another point is hovered (matches BarChart). Default: 0.3 */ + fadedOpacity?: number; +} + +export function SeriesBar({ + dataKey, + fill = chartCssVars.linePrimary, + radius = 0, + animate = true, + fadedOpacity = 0.3, +}: SeriesBarProps) { + const { + data, + xScale, + yScale, + xAccessor, + innerHeight, + innerWidth, + columnWidth, + isLoaded, + animationDuration, + enterTransition, + revealEpoch = 0, + barScale, + composedBarDataKeys, + composedBarSize, + composedMaxBarSize, + composedBarGap, + composedStacked, + composedStackOffsets, + composedStackGap, + tooltipData, + } = useChart(); + + const barKeys = useMemo(() => { + if (composedBarDataKeys && composedBarDataKeys.length > 0) { + return composedBarDataKeys; + } + return [dataKey]; + }, [composedBarDataKeys, dataKey]); + + const seriesIndex = useMemo(() => { + const idx = barKeys.indexOf(dataKey); + return idx >= 0 ? idx : 0; + }, [barKeys, dataKey]); + + const n = barKeys.length; + const gap = composedBarGap ?? 4; + const stackGap = composedStackGap ?? 0; + + const stacked = + Boolean(composedStacked) && + composedStackOffsets != null && + composedBarDataKeys != null && + composedBarDataKeys.length > 0; + + const isLastSeries = seriesIndex === n - 1; + + const barWidth = useMemo( + () => + computeSeriesBarWidth({ + innerWidth, + dataLength: data.length, + columnWidth, + seriesCount: n, + composedBarSize, + composedMaxBarSize, + composedBarGap: gap, + stacked, + }), + [ + columnWidth, + composedBarSize, + composedMaxBarSize, + data.length, + gap, + innerWidth, + n, + stacked, + ] + ); + + const totalAnimDuration = animationDuration || 1100; + const staggerSpread = totalAnimDuration * 0.4; + const calculatedStaggerDelay = + data.length > 1 ? staggerSpread / 1000 / data.length : 0; + if (barScale) { + console.warn( + "SeriesBar is for time-based ComposedChart / LineChart context. Use Bar inside BarChart for categorical x." + ); + return null; + } + + const hoveredIndex = tooltipData?.index ?? null; + + return ( + + {data.map((d, i) => { + const value = d[dataKey]; + if (typeof value !== "number") { + return null; + } + + const xCenter = xScale(xAccessor(d)) ?? 0; + + const { barLeft, valueY, barHeight, effectiveRadius } = + computeSeriesBarLayout({ + stacked, + composedStackOffsets, + rowIndex: i, + dataKey, + value, + yScale, + innerHeight, + xCenter, + barWidth, + seriesCount: n, + gap, + seriesIndex, + stackGap, + isLastSeries, + radius, + }); + + const categoryLabel = String(xAccessor(d).getTime()); + const isFaded = hoveredIndex !== null && hoveredIndex !== i; + + if (animate && !isLoaded) { + return ( + + ); + } + + return ( + + ); + })} + + ); +} + +SeriesBar.displayName = "SeriesBar"; + +interface SeriesBarRectProps { + x: number; + y: number; + barWidth: number; + barHeight: number; + fill: string; + radius: number; + index: number; + innerHeight: number; + calculatedStaggerDelay: number; + enterTransition?: Transition; + revealEpoch: number; + isFaded: boolean; + fadedOpacity: number; +} + +function SeriesBarRect({ + x, + y, + barWidth, + barHeight, + fill, + radius, + index, + innerHeight, + calculatedStaggerDelay, + enterTransition, + revealEpoch, + isFaded, + fadedOpacity, +}: SeriesBarRectProps) { + const enterAnim = transitionWithDelay( + enterTransition, + index * calculatedStaggerDelay + ); + + return ( + + ); +} + +export default SeriesBar; diff --git a/apps/start/src/components/charts/series-dash-tail-overlay.tsx b/apps/start/src/components/charts/series-dash-tail-overlay.tsx new file mode 100644 index 000000000..514e2d7d5 --- /dev/null +++ b/apps/start/src/components/charts/series-dash-tail-overlay.tsx @@ -0,0 +1,82 @@ +"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[]; + pathD: string | null; + pathLength: number; + innerWidth: number; + innerHeight: number; + stroke: string; + strokeWidth: number; + xScale: (value: Date | number) => number | undefined; + xAccessor: (datum: Record) => 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 ( + + ); +} + +// 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); diff --git a/apps/start/src/components/charts/series-highlight-layer.tsx b/apps/start/src/components/charts/series-highlight-layer.tsx new file mode 100644 index 000000000..c2dbb3cb3 --- /dev/null +++ b/apps/start/src/components/charts/series-highlight-layer.tsx @@ -0,0 +1,49 @@ +"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; + 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 / 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 ( + + ); +} + +SeriesHighlightLayer.displayName = "SeriesHighlightLayer"; + +export default SeriesHighlightLayer; diff --git a/apps/start/src/components/charts/series-hover-dim.tsx b/apps/start/src/components/charts/series-hover-dim.tsx new file mode 100644 index 000000000..eb935a3dc --- /dev/null +++ b/apps/start/src/components/charts/series-hover-dim.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { motion } from "motion/react"; +import type { ReactNode } from "react"; +import { useChartHover } from "./chart-context"; + +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; + /** 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, + children, +}: SeriesHoverDimProps) { + const { tooltipData, selection } = useChartHover(); + const isHovering = tooltipData !== null || selection?.active === true; + const opacity = enabled && isHovering ? dimOpacity : 1; + return ( + + {children} + + ); +} + +SeriesHoverDim.displayName = "SeriesHoverDim"; + +export default SeriesHoverDim; diff --git a/apps/start/src/components/charts/series-markers.tsx b/apps/start/src/components/charts/series-markers.tsx new file mode 100644 index 000000000..7372f5d46 --- /dev/null +++ b/apps/start/src/components/charts/series-markers.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { type ReactNode, useCallback, useMemo } from "react"; +import { clipRevealTransition } from "./animation"; +import { + defaultScatterColors, + useChartHover, + useChartStable, +} from "./chart-context"; +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, + yScale, + 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 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; + 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, + children, +}: SeriesMarkersDimWrapperProps) { + const { tooltipData } = useChartHover(); + const dimBase = enabled && tooltipData !== null; + 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; diff --git a/apps/start/src/components/charts/series-point-marker.tsx b/apps/start/src/components/charts/series-point-marker.tsx new file mode 100644 index 000000000..d352bd3db --- /dev/null +++ b/apps/start/src/components/charts/series-point-marker.tsx @@ -0,0 +1,209 @@ +"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 ? ( + + ) : null} + + {strokeWidth > 0 ? ( + + ) : 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 ( + + + + ); +}); + +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 ( + + + + + + ); +} + +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; +} diff --git a/apps/start/src/components/charts/time-series-chart-shell.tsx b/apps/start/src/components/charts/time-series-chart-shell.tsx new file mode 100644 index 000000000..70ef5702e --- /dev/null +++ b/apps/start/src/components/charts/time-series-chart-shell.tsx @@ -0,0 +1,440 @@ +"use client"; + +import { scaleLinear, scaleTime } from "@visx/scale"; +import { bisector, extent } from "d3-array"; +import type { Transition } from "motion/react"; +import { + Children, + cloneElement, + isValidElement, + memo, + type ReactElement, + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { DEFAULT_ANIMATION_EASING } from "./animation"; +import { ChartProvider, type LineConfig, type Margin } from "./chart-context"; +import { isGradientDefComponent, isPatternDefComponent } from "./chart-defs"; +import { shortDateFmt } from "./chart-formatters"; +import { ChartRevealClip } from "./chart-reveal-clip"; +import { + decimateTimeSeries, + maxRenderPointsForWidth, +} from "./decimate-time-series"; +import { + computeSeriesBarRevealClipPadding, + computeSeriesBarWidth, +} from "./series-bar-layout"; +import { useChartInteraction } from "./use-chart-interaction"; + +function collectNumericExtents( + data: Record[], + dataKeys: string[] +) { + let minValue = Number.POSITIVE_INFINITY; + let maxValue = Number.NEGATIVE_INFINITY; + + for (const d of data) { + for (const key of dataKeys) { + const value = d[key]; + if (typeof value === "number") { + if (value < minValue) { + minValue = value; + } + if (value > maxValue) { + maxValue = value; + } + } + } + } + + if (minValue === Number.POSITIVE_INFINITY) { + return { minValue: 0, maxValue: 100 }; + } + + return { minValue, maxValue }; +} + +function resolveTimeSeriesYDomain( + data: Record[], + dataKeys: string[], + yScaleDomainMax: number | undefined +): [number, number] { + if (yScaleDomainMax != null && yScaleDomainMax > 0) { + return [0, yScaleDomainMax * 1.1]; + } + + const { minValue, maxValue } = collectNumericExtents(data, dataKeys); + + if (minValue >= 0) { + const top = maxValue <= 0 ? 100 : maxValue * 1.1; + return [0, top]; + } + + const padding = (maxValue - minValue) * 0.05 || 1; + return [minValue - padding, maxValue + padding]; +} + +/** Markers render after the interaction overlay so they stay clickable. */ +export function isPostOverlayComponent(child: ReactElement): boolean { + const childType = child.type as { + displayName?: string; + name?: string; + __isChartMarkers?: boolean; + }; + + if (childType.__isChartMarkers) { + return true; + } + + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; + + return componentName === "ChartMarkers" || componentName === "MarkerGroup"; +} + +function ensureChildKey(child: ReactElement, index: number): ReactElement { + if (child.key != null) { + return child; + } + return cloneElement(child, { key: `chart-child-${index}` }); +} + +export interface TimeSeriesChartInnerProps { + width: number; + height: number; + data: Record[]; + xDataKey: string; + margin: Margin; + animationDuration: number; + animationEasing?: string; + enterTransition?: Transition; + /** Signature of motion URL state — triggers reveal replay when it changes. */ + revealSignature?: string; + children: ReactNode; + containerRef: React.RefObject; + /** Series keys driving y-domain and tooltip (Line / Area / SeriesBar configs). */ + lines: LineConfig[]; + /** SVG clipPath id for grow animation. */ + clipPathId: string; + /** Optional ComposedChart bar layout (forwarded into context). */ + composedBarDataKeys?: string[]; + composedBarSize?: number; + composedMaxBarSize?: number; + composedBarGap?: number; + composedStacked?: boolean; + composedStackOffsets?: Map>; + composedStackGap?: number; + /** When set, drives the y-axis max instead of scanning `lines` (e.g. stacked bar totals). */ + yScaleDomainMax?: number; +} + +export function TimeSeriesChartInner(props: TimeSeriesChartInnerProps) { + const { width, height } = props; + if (width < 10 || height < 10) { + return null; + } + return ; +} + +const TimeSeriesChartCore = memo(function TimeSeriesChartCore({ + width, + height, + data, + xDataKey, + margin, + animationDuration, + animationEasing = DEFAULT_ANIMATION_EASING, + enterTransition, + revealSignature = "", + children, + containerRef, + lines, + clipPathId, + composedBarDataKeys, + composedBarSize, + composedMaxBarSize, + composedBarGap, + composedStacked, + composedStackOffsets, + composedStackGap, + yScaleDomainMax, +}: TimeSeriesChartInnerProps) { + const [isLoaded, setIsLoaded] = useState(false); + const [revealEpoch, setRevealEpoch] = useState(0); + + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + const xAccessor = useCallback( + (d: Record): Date => { + const value = d[xDataKey]; + return value instanceof Date ? value : new Date(value as string | number); + }, + [xDataKey] + ); + + const bisectDate = useMemo( + () => bisector, Date>((d) => xAccessor(d)).left, + [xAccessor] + ); + + const xScale = useMemo(() => { + const timeExtent = extent(data, (d) => xAccessor(d).getTime()); + const minTime = timeExtent[0] ?? 0; + const maxTime = timeExtent[1] ?? minTime; + + return scaleTime({ + range: [0, innerWidth], + domain: [minTime, maxTime], + }); + }, [innerWidth, data, xAccessor]); + + const renderData = useMemo(() => { + const valueKeys = lines.map((line) => line.dataKey); + return decimateTimeSeries( + data, + maxRenderPointsForWidth(innerWidth), + valueKeys + ); + }, [data, innerWidth, lines]); + + const columnWidth = useMemo(() => { + if (data.length < 2) { + return 0; + } + return innerWidth / (data.length - 1); + }, [innerWidth, data.length]); + + const yScale = useMemo(() => { + const dataKeys = lines.map((line) => line.dataKey); + const domain = resolveTimeSeriesYDomain(data, dataKeys, yScaleDomainMax); + + return scaleLinear({ + range: [innerHeight, 0], + domain, + nice: true, + }); + }, [innerHeight, data, lines, yScaleDomainMax]); + + const dateLabels = useMemo( + () => data.map((d) => shortDateFmt.format(xAccessor(d))), + [data, xAccessor] + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature + useEffect(() => { + setRevealEpoch((n) => n + 1); + setIsLoaded(false); + const timer = setTimeout(() => { + setIsLoaded(true); + }, animationDuration); + return () => clearTimeout(timer); + }, [animationDuration, revealSignature]); + + const canInteract = isLoaded; + + const { + tooltipData, + setTooltipData, + selection, + clearSelection, + interactionHandlers, + interactionStyle, + } = useChartInteraction({ + xScale, + yScale, + data, + lines, + margin, + xAccessor, + bisectDate, + canInteract, + }); + + const defsChildren: ReactElement[] = []; + const preOverlayChildren: ReactElement[] = []; + const postOverlayChildren: ReactElement[] = []; + + Children.forEach(children, (child, index) => { + if (!isValidElement(child)) { + return; + } + + const keyedChild = ensureChildKey(child, index); + + if (isGradientDefComponent(keyedChild)) { + defsChildren.push(keyedChild); + } else if (isPatternDefComponent(keyedChild)) { + // Keep pattern defs in the plot (same as main) — hoisting breaks url(#id) fills. + preOverlayChildren.push(keyedChild); + } else if (isPostOverlayComponent(keyedChild)) { + postOverlayChildren.push(keyedChild); + } else { + preOverlayChildren.push(keyedChild); + } + }); + + const contextValue = useMemo( + () => ({ + data, + renderData, + xScale, + yScale, + width, + height, + innerWidth, + innerHeight, + margin, + columnWidth, + tooltipData, + setTooltipData, + containerRef, + lines, + isLoaded, + animationDuration, + animationEasing, + enterTransition, + revealEpoch, + xAccessor, + dateLabels, + selection, + clearSelection, + composedBarDataKeys, + composedBarSize, + composedMaxBarSize, + composedBarGap, + composedStacked, + composedStackOffsets, + composedStackGap, + }), + [ + data, + renderData, + xScale, + yScale, + width, + height, + innerWidth, + innerHeight, + margin, + columnWidth, + tooltipData, + setTooltipData, + containerRef, + lines, + isLoaded, + animationDuration, + animationEasing, + enterTransition, + revealEpoch, + xAccessor, + dateLabels, + selection, + clearSelection, + composedBarDataKeys, + composedBarSize, + composedMaxBarSize, + composedBarGap, + composedStacked, + composedStackOffsets, + composedStackGap, + ] + ); + + // Single shared reveal clip for every series. Replaces the per- / + // per- `` motion.rects: one motion-driven attribute + // animation instead of N, with all series referencing the same ``. + // The wipe semantics (left-to-right unveil of static path geometry) are + // identical to the previous per-series clips. + // animationDuration === 0 truly disables the reveal (no clipPath wrapper), + // so consumers can opt out without having to also pass enterTransition. + const showReveal = + renderData.length > 1 && innerWidth > 0 && animationDuration > 0; + // If the consumer didn't pass an explicit enterTransition, derive one from + // animationDuration so clipRevealTransition picks up the override instead + // of falling back to its 1100ms default. + const effectiveEnterTransition: Transition = enterTransition ?? { + type: "tween", + duration: animationDuration / 1000, + }; + + const revealClipPadding = useMemo(() => { + if (!composedBarDataKeys?.length) { + return 0; + } + const barWidth = computeSeriesBarWidth({ + innerWidth, + dataLength: data.length, + columnWidth, + seriesCount: composedBarDataKeys.length, + composedBarSize, + composedMaxBarSize, + composedBarGap, + stacked: composedStacked, + }); + return computeSeriesBarRevealClipPadding({ + barWidth, + seriesCount: composedBarDataKeys.length, + gap: composedBarGap, + stacked: composedStacked, + }); + }, [ + columnWidth, + composedBarDataKeys, + composedBarGap, + composedBarSize, + composedMaxBarSize, + composedStacked, + data.length, + innerWidth, + ]); + + return ( + + + + ); +}); diff --git a/apps/start/src/components/charts/tooltip/chart-tooltip.tsx b/apps/start/src/components/charts/tooltip/chart-tooltip.tsx new file mode 100644 index 000000000..e9237d255 --- /dev/null +++ b/apps/start/src/components/charts/tooltip/chart-tooltip.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import { memo, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { type SpringConfig, useChartConfig } from "../chart-config-context"; +import { + chartCssVars, + type LineConfig, + useChart, + useChartStable, +} from "../chart-context"; +import { weekdayDateFmt } from "../chart-formatters"; +import { DateTicker } from "./date-ticker"; +import { TooltipBox } from "./tooltip-box"; +import { TooltipContent, type TooltipRow } from "./tooltip-content"; +import { TooltipDot } from "./tooltip-dot"; +import { TooltipIndicator } from "./tooltip-indicator"; + +export interface ChartTooltipProps { + /** Whether to show the date pill at bottom. Default: true */ + showDatePill?: boolean; + /** Whether to show the vertical crosshair line. Default: true */ + showCrosshair?: boolean; + /** Whether to show dots on the lines. Default: true */ + showDots?: boolean; + /** + * Color for the crosshair/indicator line. When a function, receives the hovered point + * (e.g. for candlestick: match candle color from close vs open). Default: --chart-crosshair. + */ + indicatorColor?: string | ((point: Record) => string); + /** Custom content renderer for the tooltip box */ + content?: (props: { + point: Record; + index: number; + }) => React.ReactNode; + /** Custom row renderer - return array of TooltipRow */ + rows?: (point: Record) => TooltipRow[]; + /** + * Override tooltip dot fill. When omitted and `rows` is set, dot colors match row colors. + * When a function, receives the hovered point and line config. + */ + dotColor?: + | string + | ((point: Record, line: LineConfig) => string); + /** Additional content to show below rows (e.g., markers) */ + children?: React.ReactNode; + /** Custom class name */ + className?: string; + /** Per-chart override for the crosshair / dot / date-pill spring. */ + springConfig?: SpringConfig; + /** Per-chart override for the floating-panel spring. */ + boxSpringConfig?: SpringConfig; +} + +interface ChartTooltipInnerProps extends ChartTooltipProps { + container: HTMLElement; +} + +const ChartTooltipInner = memo(function ChartTooltipInner({ + showDatePill = true, + showCrosshair = true, + showDots = true, + indicatorColor: indicatorColorProp, + content, + rows: rowsRenderer, + dotColor: dotColorProp, + children, + className = "", + container, + springConfig, + boxSpringConfig, +}: ChartTooltipInnerProps) { + const { + tooltipData, + width, + height, + innerHeight, + margin, + columnWidth, + lines, + xAccessor, + dateLabels, + containerRef, + orientation, + barXAccessor, + } = useChart(); + + const isHorizontal = orientation === "horizontal"; + const discreteInteraction = dateLabels.length > 60; + + const visible = tooltipData !== null; + const x = tooltipData?.x ?? 0; + const xWithMargin = x + margin.left; + + // For horizontal charts, get the y position from the first line's yPosition (center of bar) + const firstLineDataKey = lines[0]?.dataKey; + const firstLineY = firstLineDataKey + ? (tooltipData?.yPositions[firstLineDataKey] ?? 0) + : 0; + const yWithMargin = firstLineY + margin.top; + + const tooltipRows = useMemo(() => { + if (!tooltipData) { + return []; + } + + if (rowsRenderer) { + return rowsRenderer(tooltipData.point); + } + + // Default: generate rows from registered lines + return lines.map((line) => ({ + color: line.stroke, + label: line.dataKey, + value: (tooltipData.point[line.dataKey] as number) ?? 0, + })); + }, [tooltipData, lines, rowsRenderer]); + + const resolveDotColor = useMemo(() => { + return (line: LineConfig, index: number): string => { + if (rowsRenderer && tooltipRows[index]?.color) { + return tooltipRows[index].color; + } + if (dotColorProp != null) { + if (typeof dotColorProp === "function" && tooltipData) { + return dotColorProp(tooltipData.point, line); + } + if (typeof dotColorProp === "string") { + return dotColorProp; + } + } + return line.stroke; + }; + }, [dotColorProp, rowsRenderer, tooltipData, tooltipRows]); + + // Resolve indicator color (static or from hovered point) + const indicatorColor = useMemo(() => { + if (indicatorColorProp == null) { + return chartCssVars.crosshair; + } + if (typeof indicatorColorProp === "function") { + return tooltipData + ? indicatorColorProp(tooltipData.point) + : chartCssVars.crosshair; + } + return indicatorColorProp; + }, [indicatorColorProp, tooltipData]); + + // Title from date or category + const title = useMemo(() => { + if (!tooltipData) { + return undefined; + } + // For bar charts (horizontal or vertical), use the category name + if (barXAccessor) { + return barXAccessor(tooltipData.point); + } + // For line/area charts, use the date + return weekdayDateFmt.format(xAccessor(tooltipData.point)); + }, [tooltipData, barXAccessor, xAccessor]); + + const tooltipContent = ( + <> + {/* Crosshair indicator - rendered as SVG overlay */} + {showCrosshair && ( + + )} + + {/* Dots on bars/lines - show for vertical charts only */} + {showDots && visible && !isHorizontal && ( + + )} + + {/* Tooltip Box */} + + {content && tooltipData + ? content({ + point: tooltipData.point, + index: tooltipData.index, + }) + : !content && ( + + {children} + + )} + + + {/* Date/Category Ticker - only show for vertical charts */} + + + ); + + return createPortal(tooltipContent, container); +}); + +export function ChartTooltip(props: ChartTooltipProps) { + const { containerRef } = useChartStable(); + const [mounted, setMounted] = useState(false); + + // Only render portals on client side after mount + useEffect(() => { + setMounted(true); + }, []); + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + return ; +} + +ChartTooltip.displayName = "ChartTooltip"; + +interface DatePillTrackerProps { + enabled: boolean; + visible: boolean; + labels: string[]; + currentIndex: number; + xWithMargin: number; + discreteInteraction: boolean; + springConfig?: SpringConfig; +} + +// Inner-only-on-visible so `useSpring` initializes at the real cursor x +// instead of `margin.left` on first hover. +function DatePillTracker(props: DatePillTrackerProps) { + if (!(props.enabled && props.visible && props.labels.length > 0)) { + return null; + } + return ; +} + +function DatePillTrackerInner({ + labels, + currentIndex, + xWithMargin, + discreteInteraction, + springConfig, + visible, +}: DatePillTrackerProps) { + const { tooltipSpring } = useChartConfig(); + const effectiveSpring = springConfig ?? tooltipSpring; + const animatedX = useSpring(xWithMargin, effectiveSpring); + + if (!discreteInteraction) { + animatedX.set(xWithMargin); + } + + return ( + + + + ); +} + +export default ChartTooltip; diff --git a/apps/start/src/components/charts/tooltip/date-ticker.tsx b/apps/start/src/components/charts/tooltip/date-ticker.tsx new file mode 100644 index 000000000..d4a99e061 --- /dev/null +++ b/apps/start/src/components/charts/tooltip/date-ticker.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import { memo, useMemo, useRef } from "react"; + +const TICKER_ITEM_HEIGHT = 24; +/** Full scroll stacks are skipped above this count — single label + instant updates. */ +const COMPACT_TICKER_THRESHOLD = 60; + +export interface DateTickerProps { + currentIndex: number; + labels: string[]; + visible: boolean; +} + +const DateTickerCompact = memo(function DateTickerCompact({ + currentIndex, + labels, +}: Omit) { + const label = labels[currentIndex] ?? labels[0] ?? ""; + + return ( +
+
+ {label} +
+
+ ); +}); + +const DateTickerInner = memo(function DateTickerInner({ + currentIndex, + labels, +}: Omit) { + // Parse labels into month and day parts + const parsedLabels = useMemo(() => { + return labels.map((label, index) => { + const parts = label.split(" "); + const month = parts[0] || ""; + const day = parts[1] || ""; + return { month, day, full: label, key: `${label}::${index}` }; + }); + }, [labels]); + + // Month segments: one entry per consecutive run (Jan → Feb → …), keyed by start index + const monthSegments = useMemo(() => { + const segments: { month: string; key: string; startIndex: number }[] = []; + + parsedLabels.forEach((label, index) => { + const prev = segments.at(-1); + if (!prev || prev.month !== label.month) { + segments.push({ + month: label.month, + key: `${label.month}-${index}`, + startIndex: index, + }); + } + }); + + return segments; + }, [parsedLabels]); + + // Index into monthSegments for the current data point + const currentMonthIndex = useMemo(() => { + if (currentIndex < 0 || currentIndex >= parsedLabels.length) { + return 0; + } + for (let i = monthSegments.length - 1; i >= 0; i--) { + const segment = monthSegments[i]; + if (segment && segment.startIndex <= currentIndex) { + return i; + } + } + return 0; + }, [currentIndex, parsedLabels.length, monthSegments]); + + // Track previous month index + const prevMonthIndexRef = useRef(-1); + + // Animated Y offsets + const dayY = useSpring(0, { stiffness: 400, damping: 35 }); + const monthY = useSpring(0, { stiffness: 400, damping: 35 }); + + dayY.set(-currentIndex * TICKER_ITEM_HEIGHT); + + if (currentMonthIndex >= 0) { + const isFirstRender = prevMonthIndexRef.current === -1; + const monthChanged = prevMonthIndexRef.current !== currentMonthIndex; + if (isFirstRender || monthChanged) { + monthY.set(-currentMonthIndex * TICKER_ITEM_HEIGHT); + prevMonthIndexRef.current = currentMonthIndex; + } + } + + return ( +
+
+
+ {/* Month stack */} +
+ + {monthSegments.map((segment) => ( +
+ + {segment.month} + +
+ ))} +
+
+ + {/* Day stack */} +
+ + {parsedLabels.map((label) => ( +
+ + {label.day} + +
+ ))} +
+
+
+
+
+ ); +}); + +export function DateTicker({ currentIndex, labels, visible }: DateTickerProps) { + if (!visible || labels.length === 0) { + return null; + } + + if (labels.length > COMPACT_TICKER_THRESHOLD) { + return ; + } + + return ; +} + +DateTicker.displayName = "DateTicker"; + +export default DateTicker; diff --git a/apps/start/src/components/charts/tooltip/index.ts b/apps/start/src/components/charts/tooltip/index.ts new file mode 100644 index 000000000..5b4dafcf0 --- /dev/null +++ b/apps/start/src/components/charts/tooltip/index.ts @@ -0,0 +1,14 @@ +export { ChartTooltip, type ChartTooltipProps } from "./chart-tooltip"; +export { DateTicker, type DateTickerProps } from "./date-ticker"; +export { TooltipBox, type TooltipBoxProps } from "./tooltip-box"; +export { + TooltipContent, + type TooltipContentProps, + type TooltipRow, +} from "./tooltip-content"; +export { TooltipDot, type TooltipDotProps } from "./tooltip-dot"; +export { + type IndicatorWidth, + TooltipIndicator, + type TooltipIndicatorProps, +} from "./tooltip-indicator"; diff --git a/apps/start/src/components/charts/tooltip/tooltip-box.tsx b/apps/start/src/components/charts/tooltip/tooltip-box.tsx new file mode 100644 index 000000000..af6a04496 --- /dev/null +++ b/apps/start/src/components/charts/tooltip/tooltip-box.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import type { RefObject } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "@/lib/utils"; +import { type SpringConfig, useChartConfig } from "../chart-config-context"; + +export interface TooltipBoxProps { + /** X position in pixels (relative to container) */ + x: number; + /** Y position in pixels (relative to container) */ + y: number; + /** Whether the tooltip is visible */ + visible: boolean; + /** Container ref for portal rendering */ + containerRef: RefObject; + /** Container width for flip detection */ + containerWidth: number; + /** Container height for bounds clamping */ + containerHeight: number; + /** Offset from the target position */ + offset?: number; + /** Custom class name */ + className?: string; + /** Tooltip content */ + children: React.ReactNode; + /** Override left position (bypasses internal calculation) */ + left?: number | ReturnType; + /** Override top position (bypasses internal calculation) */ + top?: number | ReturnType; + /** Force flip direction (for custom positioning) */ + flipped?: boolean; + /** Per-chart override; falls back to `ChartConfigProvider.tooltipBoxSpring`. */ + springConfig?: SpringConfig; +} + +// Inner-only-on-visible so `useSpring` initializes at the cursor's actual x/y +// instead of (0, 0) on first hover. +export function TooltipBox(props: TooltipBoxProps) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const container = props.containerRef.current; + if (!(mounted && container)) { + return null; + } + if (!props.visible) { + return null; + } + return ; +} + +function TooltipBoxInner({ + x, + y, + containerWidth, + containerHeight, + offset = 16, + className = "", + children, + left: leftOverride, + top: topOverride, + flipped: flippedOverride, + springConfig, + container, +}: Omit & { + container: HTMLElement; +}) { + const { tooltipBoxSpring } = useChartConfig(); + const effectiveSpring = springConfig ?? tooltipBoxSpring; + + const tooltipRef = useRef(null); + const tooltipWidthRef = useRef(180); + const tooltipHeightRef = useRef(80); + + const tw = tooltipWidthRef.current; + const th = tooltipHeightRef.current; + const shouldFlipX = x + tw + offset > containerWidth; + const targetX = shouldFlipX ? x - offset - tw : x + offset; + const targetY = Math.max( + offset, + Math.min(y - th / 2, containerHeight - th - offset) + ); + + const animatedLeft = useSpring(targetX, effectiveSpring); + const animatedTop = useSpring(targetY, effectiveSpring); + + if (leftOverride === undefined) { + animatedLeft.set(targetX); + } + if (topOverride === undefined) { + animatedTop.set(targetY); + } + + useLayoutEffect(() => { + if (!tooltipRef.current) { + return; + } + const el = tooltipRef.current; + const w = el.offsetWidth; + const h = el.offsetHeight; + if (w > 0) { + tooltipWidthRef.current = w; + } + if (h > 0) { + tooltipHeightRef.current = h; + } + const w2 = tooltipWidthRef.current; + const h2 = tooltipHeightRef.current; + const flip = x + w2 + offset > containerWidth; + const tx = flip ? x - offset - w2 : x + offset; + const ty = Math.max( + offset, + Math.min(y - h2 / 2, containerHeight - h2 - offset) + ); + if (leftOverride === undefined) { + animatedLeft.set(tx); + } + if (topOverride === undefined) { + animatedTop.set(ty); + } + }, [ + x, + y, + containerWidth, + containerHeight, + offset, + leftOverride, + topOverride, + animatedLeft, + animatedTop, + ]); + + const prevFlipRef = useRef(shouldFlipX); + const [flipKey, setFlipKey] = useState(0); + + useEffect(() => { + if (prevFlipRef.current !== shouldFlipX) { + setFlipKey((k) => k + 1); + prevFlipRef.current = shouldFlipX; + } + }, [shouldFlipX]); + + const finalLeft = leftOverride ?? animatedLeft; + const finalTop = topOverride ?? animatedTop; + const isFlipped = flippedOverride ?? shouldFlipX; + const transformOrigin = isFlipped ? "right top" : "left top"; + + return createPortal( + + + {children} + + , + container + ); +} + +TooltipBox.displayName = "TooltipBox"; + +export default TooltipBox; diff --git a/apps/start/src/components/charts/tooltip/tooltip-content.tsx b/apps/start/src/components/charts/tooltip/tooltip-content.tsx new file mode 100644 index 000000000..7e071190e --- /dev/null +++ b/apps/start/src/components/charts/tooltip/tooltip-content.tsx @@ -0,0 +1,62 @@ +"use client"; + +import type { ReactNode } from "react"; +import { intFmt } from "../chart-formatters"; + +export interface TooltipRow { + color: string; + label: string; + value: string | number; +} + +export interface TooltipContentProps { + title?: string; + rows: TooltipRow[]; + /** Optional additional content (e.g., markers) */ + children?: ReactNode; +} + +export function TooltipContent({ title, rows, children }: TooltipContentProps) { + return ( +
+
+ {title && ( +
+ {title} +
+ )} +
+ {rows.map((row) => ( +
+
+ + + {row.label} + +
+ + {typeof row.value === "number" ? intFmt(row.value) : row.value} + +
+ ))} +
+ + {children && ( +
+ {children} +
+ )} +
+
+ ); +} + +TooltipContent.displayName = "TooltipContent"; + +export default TooltipContent; diff --git a/apps/start/src/components/charts/tooltip/tooltip-dot.tsx b/apps/start/src/components/charts/tooltip/tooltip-dot.tsx new file mode 100644 index 000000000..f360a1f60 --- /dev/null +++ b/apps/start/src/components/charts/tooltip/tooltip-dot.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import { type SpringConfig, useChartConfig } from "../chart-config-context"; +import { chartCssVars } from "../chart-context"; + +export interface TooltipDotProps { + x: number; + y: number; + visible: boolean; + color: string; + size?: number; + strokeColor?: string; + strokeWidth?: number; + /** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */ + springConfig?: SpringConfig; +} + +export function TooltipDot({ + x, + y, + visible, + color, + size = 5, + strokeColor = chartCssVars.background, + strokeWidth = 2, + springConfig, +}: TooltipDotProps) { + const { tooltipSpring } = useChartConfig(); + const effectiveSpring = springConfig ?? tooltipSpring; + const animatedX = useSpring(x, effectiveSpring); + const animatedY = useSpring(y, effectiveSpring); + + animatedX.set(x); + animatedY.set(y); + + if (!visible) { + return null; + } + + return ( + + ); +} + +TooltipDot.displayName = "TooltipDot"; + +export default TooltipDot; diff --git a/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx b/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx new file mode 100644 index 000000000..446c815ab --- /dev/null +++ b/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import { type SpringConfig, useChartConfig } from "../chart-config-context"; +import { chartCssVars } from "../chart-context"; + +export type IndicatorWidth = + | number // Pixel width + | "line" // 1px line (default) + | "thin" // 2px + | "medium" // 4px + | "thick"; // 8px + +export interface TooltipIndicatorProps { + /** X position in pixels (center of the indicator) */ + x: number; + /** Height of the indicator */ + height: number; + /** Whether the indicator is visible */ + visible: boolean; + /** + * Width of the indicator - number (pixels) or preset. + * Ignored if `span` is provided. + */ + width?: IndicatorWidth; + /** + * Number of columns/days to span, with current point centered. + * Requires `columnWidth` to be set. + */ + span?: number; + /** Width of a single column/day in pixels. Required when using `span`. */ + columnWidth?: number; + /** Primary color at edges (10% and 90%) */ + colorEdge?: string; + /** Secondary color at center (50%) */ + colorMid?: string; + /** Whether to fade to transparent at 0% and 100% */ + fadeEdges?: boolean; + /** Animate position with a spring. Default: true */ + animate?: boolean; + /** Unique ID for the gradient */ + gradientId?: string; + /** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */ + springConfig?: SpringConfig; +} + +function resolveWidth(width: IndicatorWidth): number { + if (typeof width === "number") { + return width; + } + switch (width) { + case "line": + return 1; + case "thin": + return 2; + case "medium": + return 4; + case "thick": + return 8; + default: + return 1; + } +} + +// Inner-only-on-visible so `useSpring` initializes at the real cursor x +// instead of 0 on first hover. +export function TooltipIndicator(props: TooltipIndicatorProps) { + if (!props.visible) { + return null; + } + return ; +} + +function TooltipIndicatorInner({ + x, + height, + width = "line", + span, + columnWidth, + colorEdge = chartCssVars.crosshair, + colorMid = chartCssVars.crosshair, + fadeEdges = true, + animate = true, + gradientId = "tooltip-indicator-gradient", + springConfig, +}: Omit) { + const { tooltipSpring } = useChartConfig(); + const effectiveSpring = springConfig ?? tooltipSpring; + + const pixelWidth = + span !== undefined && columnWidth !== undefined + ? span * columnWidth + : resolveWidth(width); + + const rectX = x - pixelWidth / 2; + const animatedX = useSpring(rectX, effectiveSpring); + + if (animate) { + animatedX.set(rectX); + } + + const edgeOpacity = fadeEdges ? 0 : 1; + + return ( + + + + + + + + + + + {animate ? ( + + ) : ( + + )} + + ); +} + +TooltipIndicator.displayName = "TooltipIndicator"; + +export default TooltipIndicator; diff --git a/apps/start/src/components/charts/use-area-segment-highlight.ts b/apps/start/src/components/charts/use-area-segment-highlight.ts new file mode 100644 index 000000000..2a4327285 --- /dev/null +++ b/apps/start/src/components/charts/use-area-segment-highlight.ts @@ -0,0 +1,163 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import type { TooltipData } from "./chart-context"; +import type { ChartSelection } from "./use-chart-interaction"; + +interface UseAreaSegmentHighlightOptions { + data: Record[]; + dataKey: string; + tooltipData: TooltipData | null; + selection: ChartSelection | null | undefined; + xScale: (value: Date | number) => number | undefined; + yScale: (value: number) => number | undefined; + xAccessor: (datum: Record) => Date | number; +} + +function buildChordMetrics( + data: Record[], + getY: (datum: Record) => number, + xScale: (value: Date | number) => number | undefined, + xAccessor: (datum: Record) => Date | number +) { + const cumulative: number[] = [0]; + let total = 0; + for (let i = 1; i < data.length; i++) { + const d0 = data[i - 1]; + const d1 = data[i]; + if (!(d0 && d1)) { + continue; + } + const x0 = xScale(xAccessor(d0)) ?? 0; + const x1 = xScale(xAccessor(d1)) ?? 0; + const y0 = getY(d0); + const y1 = getY(d1); + total += Math.hypot(x1 - x0, y1 - y0); + cumulative.push(total); + } + return { cumulative, total }; +} + +function approximateLengthAtX( + targetX: number, + data: Record[], + chordMetrics: { cumulative: number[]; total: number }, + xScale: (value: Date | number) => number | undefined, + xAccessor: (datum: Record) => Date | number +) { + if (data.length < 2) { + return 0; + } + const { cumulative } = chordMetrics; + for (let i = 1; i < data.length; i++) { + const dPrev = data[i - 1]; + const dCur = data[i]; + if (!(dPrev && dCur)) { + continue; + } + const x0 = xScale(xAccessor(dPrev)) ?? 0; + const x1 = xScale(xAccessor(dCur)) ?? 0; + const atLast = i === data.length - 1; + const spanEnd = Math.max(x0, x1); + if (targetX <= spanEnd || atLast) { + const prev = cumulative[i - 1] ?? 0; + const segLen = (cumulative[i] ?? 0) - prev; + const denom = x1 - x0; + if (Math.abs(denom) < 1e-6) { + return prev; + } + const t = Math.max(0, Math.min(1, (targetX - x0) / denom)); + return prev + t * segLen; + } + } + return chordMetrics.total; +} + +export function useAreaSegmentHighlight({ + data, + dataKey, + tooltipData, + selection, + xScale, + yScale, + xAccessor, +}: UseAreaSegmentHighlightOptions) { + const getY = useCallback( + (d: Record) => { + const value = d[dataKey]; + return typeof value === "number" ? (yScale(value) ?? 0) : 0; + }, + [dataKey, yScale] + ); + + const chordMetrics = useMemo( + () => buildChordMetrics(data, getY, xScale, xAccessor), + [data, getY, xScale, xAccessor] + ); + + const segmentBounds = useMemo(() => { + if (data.length < 2 || chordMetrics.total <= 0) { + return { startLength: 0, segmentLength: 0, isActive: false }; + } + + if (selection?.active) { + const startLength = approximateLengthAtX( + selection.startX, + data, + chordMetrics, + xScale, + xAccessor + ); + const endLength = approximateLengthAtX( + selection.endX, + data, + chordMetrics, + xScale, + xAccessor + ); + return { + startLength, + segmentLength: Math.max(0, endLength - startLength), + isActive: true, + }; + } + + if (!tooltipData) { + return { startLength: 0, segmentLength: 0, isActive: false }; + } + + 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 { startLength: 0, segmentLength: 0, isActive: false }; + } + + const startX = xScale(xAccessor(startPoint)) ?? 0; + const endX = xScale(xAccessor(endPoint)) ?? 0; + const startLength = approximateLengthAtX( + startX, + data, + chordMetrics, + xScale, + xAccessor + ); + const endLength = approximateLengthAtX( + endX, + data, + chordMetrics, + xScale, + xAccessor + ); + + return { + startLength, + segmentLength: Math.max(0, endLength - startLength), + isActive: true, + }; + }, [tooltipData, selection, data, xScale, xAccessor, chordMetrics]); + + return { chordMetrics, segmentBounds, getY }; +} diff --git a/apps/start/src/components/charts/use-chart-interaction.ts b/apps/start/src/components/charts/use-chart-interaction.ts new file mode 100644 index 000000000..e1b3c0629 --- /dev/null +++ b/apps/start/src/components/charts/use-chart-interaction.ts @@ -0,0 +1,330 @@ +"use client"; + +import { localPoint } from "@visx/event"; +import type { scaleLinear, scaleTime } from "@visx/scale"; +import { useCallback, useRef, useState } from "react"; +import type { LineConfig, Margin, TooltipData } from "./chart-context"; +import { useScheduledTooltip } from "./use-scheduled-tooltip"; + +type ScaleTime = ReturnType>; +type ScaleLinear = ReturnType>; + +export interface ChartSelection { + startX: number; + endX: number; + startIndex: number; + endIndex: number; + active: boolean; +} + +interface UseChartInteractionParams { + xScale: ScaleTime; + yScale: ScaleLinear; + data: Record[]; + lines: LineConfig[]; + margin: Margin; + xAccessor: (d: Record) => Date; + bisectDate: ( + data: Record[], + date: Date, + lo: number + ) => number; + canInteract: boolean; +} + +interface ChartInteractionResult { + tooltipData: TooltipData | null; + setTooltipData: React.Dispatch>; + selection: ChartSelection | null; + clearSelection: () => void; + interactionHandlers: { + onMouseMove?: (event: React.MouseEvent) => void; + onMouseLeave?: () => void; + onMouseDown?: (event: React.MouseEvent) => void; + onMouseUp?: () => void; + onTouchStart?: (event: React.TouchEvent) => void; + onTouchMove?: (event: React.TouchEvent) => void; + onTouchEnd?: () => void; + }; + interactionStyle: React.CSSProperties; +} + +export function useChartInteraction({ + xScale, + yScale, + data, + lines, + margin, + xAccessor, + bisectDate, + canInteract, +}: UseChartInteractionParams): ChartInteractionResult { + const [selection, setSelection] = useState(null); + const { + tooltipData, + setTooltipData, + scheduleTooltip, + clearTooltip, + resetTooltipDedupe, + } = useScheduledTooltip(); + + const isDraggingRef = useRef(false); + const dragStartXRef = useRef(0); + + const resolveTooltipFromX = useCallback( + (pixelX: number): TooltipData | null => { + const x0 = xScale.invert(pixelX); + const index = bisectDate(data, x0, 1); + const d0 = data[index - 1]; + const d1 = data[index]; + + if (!d0) { + return null; + } + + let d = d0; + let finalIndex = index - 1; + if (d1) { + const d0Time = xAccessor(d0).getTime(); + const d1Time = xAccessor(d1).getTime(); + if (x0.getTime() - d0Time > d1Time - x0.getTime()) { + d = d1; + finalIndex = index; + } + } + + const yPositions: Record = {}; + for (const line of lines) { + const value = d[line.dataKey]; + if (typeof value === "number") { + yPositions[line.dataKey] = yScale(value) ?? 0; + } + } + + return { + point: d, + index: finalIndex, + x: xScale(xAccessor(d)) ?? 0, + yPositions, + }; + }, + [xScale, yScale, data, lines, xAccessor, bisectDate] + ); + + const resolveIndexFromX = useCallback( + (pixelX: number): number => { + const x0 = xScale.invert(pixelX); + const index = bisectDate(data, x0, 1); + const d0 = data[index - 1]; + const d1 = data[index]; + if (!d0) { + return 0; + } + if (d1) { + const d0Time = xAccessor(d0).getTime(); + const d1Time = xAccessor(d1).getTime(); + if (x0.getTime() - d0Time > d1Time - x0.getTime()) { + return index; + } + } + return index - 1; + }, + [xScale, data, xAccessor, bisectDate] + ); + + const getChartX = useCallback( + ( + event: React.MouseEvent | React.TouchEvent, + touchIndex = 0 + ): number | null => { + let point: { x: number; y: number } | null = null; + + if ("touches" in event) { + const touch = event.touches[touchIndex]; + if (!touch) { + return null; + } + const svg = event.currentTarget.ownerSVGElement; + if (!svg) { + return null; + } + point = localPoint(svg, touch as unknown as MouseEvent); + } else { + point = localPoint(event); + } + + if (!point) { + return null; + } + return point.x - margin.left; + }, + [margin.left] + ); + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const chartX = getChartX(event); + if (chartX === null) { + return; + } + + if (isDraggingRef.current) { + const startX = Math.min(dragStartXRef.current, chartX); + const endX = Math.max(dragStartXRef.current, chartX); + setSelection({ + startX, + endX, + startIndex: resolveIndexFromX(startX), + endIndex: resolveIndexFromX(endX), + active: true, + }); + return; + } + + const tooltip = resolveTooltipFromX(chartX); + if (tooltip) { + scheduleTooltip(tooltip); + } + }, + [getChartX, resolveTooltipFromX, resolveIndexFromX, scheduleTooltip] + ); + + const handleMouseLeave = useCallback(() => { + clearTooltip(); + if (isDraggingRef.current) { + isDraggingRef.current = false; + } + setSelection(null); + }, [clearTooltip]); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + const chartX = getChartX(event); + if (chartX === null) { + return; + } + isDraggingRef.current = true; + dragStartXRef.current = chartX; + clearTooltip(); + setSelection(null); + }, + [getChartX, clearTooltip] + ); + + const handleMouseUp = useCallback(() => { + if (isDraggingRef.current) { + isDraggingRef.current = false; + } + setSelection(null); + }, []); + + const handleTouchStart = useCallback( + (event: React.TouchEvent) => { + if (event.touches.length === 1) { + event.preventDefault(); + const chartX = getChartX(event, 0); + if (chartX === null) { + return; + } + const tooltip = resolveTooltipFromX(chartX); + if (tooltip) { + scheduleTooltip(tooltip); + } + } else if (event.touches.length === 2) { + event.preventDefault(); + resetTooltipDedupe(); + clearTooltip(); + const x0 = getChartX(event, 0); + const x1 = getChartX(event, 1); + if (x0 === null || x1 === null) { + return; + } + const startX = Math.min(x0, x1); + const endX = Math.max(x0, x1); + setSelection({ + startX, + endX, + startIndex: resolveIndexFromX(startX), + endIndex: resolveIndexFromX(endX), + active: true, + }); + } + }, + [ + getChartX, + resolveTooltipFromX, + resolveIndexFromX, + scheduleTooltip, + resetTooltipDedupe, + clearTooltip, + ] + ); + + const handleTouchMove = useCallback( + (event: React.TouchEvent) => { + if (event.touches.length === 1) { + event.preventDefault(); + const chartX = getChartX(event, 0); + if (chartX === null) { + return; + } + const tooltip = resolveTooltipFromX(chartX); + if (tooltip) { + scheduleTooltip(tooltip); + } + } else if (event.touches.length === 2) { + event.preventDefault(); + const x0 = getChartX(event, 0); + const x1 = getChartX(event, 1); + if (x0 === null || x1 === null) { + return; + } + const startX = Math.min(x0, x1); + const endX = Math.max(x0, x1); + setSelection({ + startX, + endX, + startIndex: resolveIndexFromX(startX), + endIndex: resolveIndexFromX(endX), + active: true, + }); + } + }, + [getChartX, resolveTooltipFromX, resolveIndexFromX, scheduleTooltip] + ); + + const handleTouchEnd = useCallback(() => { + clearTooltip(); + setSelection(null); + }, [clearTooltip]); + + const clearSelection = useCallback(() => { + setSelection(null); + }, []); + + const interactionHandlers = canInteract + ? { + onMouseMove: handleMouseMove, + onMouseLeave: handleMouseLeave, + onMouseDown: handleMouseDown, + onMouseUp: handleMouseUp, + onTouchStart: handleTouchStart, + onTouchMove: handleTouchMove, + onTouchEnd: handleTouchEnd, + } + : {}; + + const interactionStyle: React.CSSProperties = { + cursor: canInteract ? "crosshair" : "default", + touchAction: "none", + }; + + return { + tooltipData, + setTooltipData, + selection, + clearSelection, + interactionHandlers, + interactionStyle, + }; +} diff --git a/apps/start/src/components/charts/use-highlight-segment.ts b/apps/start/src/components/charts/use-highlight-segment.ts new file mode 100644 index 000000000..f5cb8bce0 --- /dev/null +++ b/apps/start/src/components/charts/use-highlight-segment.ts @@ -0,0 +1,61 @@ +"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; `` 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; + widthSpring: ReturnType; + 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 }; +} diff --git a/apps/start/src/components/charts/use-line-segment-highlight.ts b/apps/start/src/components/charts/use-line-segment-highlight.ts new file mode 100644 index 000000000..5b9740d07 --- /dev/null +++ b/apps/start/src/components/charts/use-line-segment-highlight.ts @@ -0,0 +1,115 @@ +"use client"; + +import { useMemo } from "react"; +import type { TooltipData } from "./chart-context"; +import type { ChartSelection } from "./use-chart-interaction"; + +interface UseLineSegmentHighlightOptions { + pathLength: number; + data: Record[]; + tooltipData: TooltipData | null; + selection: ChartSelection | null | undefined; + xScale: (value: Date | number) => number | undefined; + yScale: (value: number) => number | undefined; + xAccessor: (datum: Record) => Date | number; + dataKey: string; +} + +/** + * Highlight bounds for the hover/selection overlay. + * + * The naive implementation called `path.getPointAtLength` inside a binary + * search per call — ~30-60 DOM measurements per Line/Area per hover bucket, + * which made the chart jank on every bucket boundary. This version computes + * cumulative chord lengths between data points in pixel space (pure + * arithmetic, memoized on `(data, scales, dataKey)`), then scales them to + * the real SVG path length so the dasharray offsets still line up with the + * curved stroke. Same approach as bklit-ui issue #54's approximation. + */ +export function useLineSegmentHighlight({ + pathLength, + data, + tooltipData, + selection, + xScale, + yScale, + xAccessor, + dataKey, +}: UseLineSegmentHighlightOptions) { + const chordLengths = useMemo(() => { + if (data.length < 2) { + return { lengths: new Float64Array(0), total: 0 }; + } + const lengths = new Float64Array(data.length); + lengths[0] = 0; + let total = 0; + + let prevX = xScale(xAccessor(data[0]!)) ?? 0; + const firstValue = data[0]![dataKey]; + let prevY = + typeof firstValue === "number" + ? (yScale(firstValue) ?? yScale(0) ?? 0) + : (yScale(0) ?? 0); + + for (let i = 1; i < data.length; i++) { + const point = data[i]!; + const x = xScale(xAccessor(point)) ?? 0; + const value = point[dataKey]; + const y = + typeof value === "number" + ? (yScale(value) ?? yScale(0) ?? 0) + : (yScale(0) ?? 0); + const dx = x - prevX; + const dy = y - prevY; + total += Math.sqrt(dx * dx + dy * dy); + lengths[i] = total; + prevX = x; + prevY = y; + } + return { lengths, total }; + }, [data, xScale, yScale, xAccessor, dataKey]); + + return useMemo(() => { + const { lengths, total: chordTotal } = chordLengths; + if (lengths.length === 0 || pathLength === 0 || chordTotal === 0) { + return { startLength: 0, segmentLength: 0, isActive: false }; + } + // Scale chord-space lengths into SVG path-space so the dasharray on the + // curved stroke lines up. Assumes roughly uniform curvature — accurate + // within a couple of pixels for typical chart curves. + const scale = pathLength / chordTotal; + + if (selection?.active) { + const startIdx = clampIndex(selection.startIndex, lengths.length); + const endIdx = clampIndex(selection.endIndex, lengths.length); + const startLength = (lengths[startIdx] ?? 0) * scale; + const endLength = (lengths[endIdx] ?? 0) * scale; + return { + startLength, + segmentLength: Math.max(0, endLength - startLength), + isActive: true, + }; + } + + if (!tooltipData) { + return { startLength: 0, segmentLength: 0, isActive: false }; + } + + const idx = tooltipData.index; + const startIdx = clampIndex(idx - 1, lengths.length); + const endIdx = clampIndex(idx + 1, lengths.length); + const startLength = (lengths[startIdx] ?? 0) * scale; + const endLength = (lengths[endIdx] ?? 0) * scale; + return { + startLength, + segmentLength: Math.max(0, endLength - startLength), + isActive: true, + }; + }, [chordLengths, pathLength, tooltipData, selection]); +} + +function clampIndex(idx: number, length: number): number { + if (idx < 0) return 0; + if (idx >= length) return length - 1; + return idx; +} diff --git a/apps/start/src/components/charts/use-mount-progress.ts b/apps/start/src/components/charts/use-mount-progress.ts new file mode 100644 index 000000000..cf7bf64c5 --- /dev/null +++ b/apps/start/src/components/charts/use-mount-progress.ts @@ -0,0 +1,29 @@ +"use client"; + +import { animate, type Transition, useMotionValue } from "motion/react"; +import { useEffect, useRef } from "react"; +import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation"; + +/** Drives 0→1 enter progress using the studio motion transition (spring or tween). */ +export function useMountProgress( + enterTransition: Transition | undefined, + delaySeconds: number, + replayKey: number | string +) { + const progress = useMotionValue(0); + const transitionRef = useRef(enterTransition); + transitionRef.current = enterTransition; + + // replayKey intentionally retriggers enter when motion settings change + // biome-ignore lint/correctness/useExhaustiveDependencies: replayKey + useEffect(() => { + progress.set(0); + const controls = animate(progress, 1, { + ...(transitionRef.current ?? DEFAULT_CHART_ENTER_TRANSITION), + delay: delaySeconds, + }); + return () => controls.stop(); + }, [delaySeconds, replayKey, progress]); + + return progress; +} diff --git a/apps/start/src/components/charts/use-scheduled-tooltip.ts b/apps/start/src/components/charts/use-scheduled-tooltip.ts new file mode 100644 index 000000000..6c4175edd --- /dev/null +++ b/apps/start/src/components/charts/use-scheduled-tooltip.ts @@ -0,0 +1,93 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface ScheduledTooltipControls { + tooltipData: T | null; + setTooltipData: React.Dispatch>; + scheduleTooltip: (tooltip: T, dedupeKey?: string) => void; + clearTooltip: () => void; + resetTooltipDedupe: () => void; +} + +function defaultDedupeKey(tooltip: T): string { + if ( + typeof tooltip === "object" && + tooltip !== null && + "index" in tooltip && + typeof (tooltip as { index: unknown }).index === "number" + ) { + return String((tooltip as { index: number }).index); + } + return JSON.stringify(tooltip); +} + +export function useScheduledTooltip(): ScheduledTooltipControls { + const [tooltipData, setTooltipData] = useState(null); + const lastKeyRef = useRef(null); + const pendingRef = useRef(null); + const rafRef = useRef(null); + const pendingKeyRef = useRef(null); + + useEffect(() => { + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + }; + }, []); + + const commitTooltip = useCallback((tooltip: T, dedupeKey: string) => { + if (dedupeKey === lastKeyRef.current) { + return; + } + lastKeyRef.current = dedupeKey; + setTooltipData(tooltip); + }, []); + + const scheduleTooltip = useCallback( + (tooltip: T, dedupeKey?: string) => { + const key = dedupeKey ?? defaultDedupeKey(tooltip); + pendingRef.current = tooltip; + pendingKeyRef.current = key; + if (key === lastKeyRef.current) { + return; + } + if (rafRef.current !== null) { + return; + } + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + const next = pendingRef.current; + const nextKey = pendingKeyRef.current; + if (next && nextKey) { + commitTooltip(next, nextKey); + } + }); + }, + [commitTooltip] + ); + + const clearTooltip = useCallback(() => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + pendingRef.current = null; + pendingKeyRef.current = null; + lastKeyRef.current = null; + setTooltipData(null); + }, []); + + const resetTooltipDedupe = useCallback(() => { + lastKeyRef.current = null; + }, []); + + return { + tooltipData, + setTooltipData, + scheduleTooltip, + clearTooltip, + resetTooltipDedupe, + }; +} diff --git a/apps/start/src/components/charts/x-axis.tsx b/apps/start/src/components/charts/x-axis.tsx new file mode 100644 index 000000000..1e23ba33f --- /dev/null +++ b/apps/start/src/components/charts/x-axis.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { memo, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "@/lib/utils"; +import { useChart, useChartStable } from "./chart-context"; +import { shortDateFmt } from "./chart-formatters"; + +export interface XAxisProps { + /** Number of ticks to show (including first and last). Default: 5. Used when `tickMode` is `"domain"`. */ + numTicks?: number; + /** Width of the date ticker box for fade calculation. Default: 50 */ + tickerHalfWidth?: number; + /** + * `"domain"` — evenly spaced ticks across the time domain (default). + * `"data"` — one label per data row at its x value (better with sparse or monthly bars). + */ + tickMode?: "domain" | "data"; +} + +interface XAxisLabelProps { + label: string; + x: number; + crosshairX: number | null; + isHovering: boolean; + tickerHalfWidth: number; +} + +function XAxisLabel({ + label, + x, + crosshairX, + isHovering, + tickerHalfWidth, +}: XAxisLabelProps) { + const fadeBuffer = 20; + const fadeRadius = tickerHalfWidth + fadeBuffer; + + let opacity = 1; + if (isHovering && crosshairX !== null) { + const distance = Math.abs(x - crosshairX); + if (distance < tickerHalfWidth) { + opacity = 0; + } else if (distance < fadeRadius) { + opacity = (distance - tickerHalfWidth) / fadeBuffer; + } + } + + // Zero-width container approach for perfect centering + // The wrapper is positioned exactly at x with width:0 + // The inner span overflows and is centered via text-align + return ( +
+ + {label} + +
+ ); +} + +export function XAxis(props: XAxisProps) { + const { containerRef } = useChartStable(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + return ; +} + +const XAxisInner = memo(function XAxisInner({ + numTicks = 5, + tickerHalfWidth = 50, + tickMode = "domain", + container, +}: XAxisProps & { container: HTMLDivElement }) { + const { xScale, margin, tooltipData, data, xAccessor, dateLabels } = + useChart(); + + // Generate tick labels: evenly spaced along the domain, or one per data row + const labelsToShow = useMemo(() => { + if (tickMode === "data") { + return data.map((d, i) => ({ + date: xAccessor(d), + x: (xScale(xAccessor(d)) ?? 0) + margin.left, + label: dateLabels[i] ?? shortDateFmt.format(xAccessor(d)), + })); + } + + const domain = xScale.domain(); + const startDate = domain[0]; + const endDate = domain[1]; + + if (!(startDate && endDate)) { + return []; + } + + const startTime = startDate.getTime(); + const endTime = endDate.getTime(); + const timeRange = endTime - startTime; + + // Create evenly spaced dates from start to end + const tickCount = Math.max(2, numTicks); // At least first and last + const dates: Date[] = []; + + for (let i = 0; i < tickCount; i++) { + const t = i / (tickCount - 1); // 0 to 1 + const time = startTime + t * timeRange; + dates.push(new Date(time)); + } + + return dates.map((date) => ({ + date, + x: (xScale(date) ?? 0) + margin.left, + label: shortDateFmt.format(date), + })); + }, [tickMode, data, xAccessor, xScale, margin.left, dateLabels, numTicks]); + + const isHovering = tooltipData !== null; + const crosshairX = tooltipData ? tooltipData.x + margin.left : null; + + return createPortal( +
+ {labelsToShow.map((item) => ( + + ))} +
, + container + ); +}); + +XAxis.displayName = "XAxis"; + +export default XAxis; diff --git a/apps/start/src/components/charts/y-axis.tsx b/apps/start/src/components/charts/y-axis.tsx new file mode 100644 index 000000000..691f3c29e --- /dev/null +++ b/apps/start/src/components/charts/y-axis.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { memo, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { useChartStable } from "./chart-context"; + +export interface YAxisProps { + /** Number of ticks to show. Default: 5 */ + numTicks?: number; + /** Format large numbers (e.g. 1000 as "1k"). Default: true */ + formatLargeNumbers?: boolean; + /** Custom formatter for tick labels (e.g. USD). Overrides formatLargeNumbers when set. */ + formatValue?: (value: number) => string; +} + +function formatLabel( + value: number, + formatLargeNumbers: boolean, + formatValue?: (value: number) => string +): string { + if (formatValue) { + return formatValue(value); + } + if (formatLargeNumbers && value >= 1000) { + return `${(value / 1000).toFixed(0)}k`; + } + return String(value); +} + +export function YAxis(props: YAxisProps) { + const { containerRef } = useChartStable(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + return ; +} + +const YAxisInner = memo(function YAxisInner({ + numTicks = 5, + formatLargeNumbers = true, + formatValue, + container, +}: YAxisProps & { container: HTMLDivElement }) { + const { yScale, margin } = useChartStable(); + + const ticks = useMemo(() => { + const tickValues = yScale.ticks(numTicks); + return tickValues.map((value) => ({ + value, + y: (yScale(value) ?? 0) + margin.top, + label: formatLabel(value, formatLargeNumbers, formatValue), + })); + }, [yScale, margin.top, numTicks, formatLargeNumbers, formatValue]); + + return createPortal( +
+ {ticks.map((tick) => ( +
+ {tick.label} +
+ ))} +
, + container + ); +}); + +YAxis.displayName = "YAxis"; + +export default YAxis; diff --git a/apps/start/src/components/overview/overview-line-chart-tooltip.tsx b/apps/start/src/components/overview/overview-line-chart-tooltip.tsx deleted file mode 100644 index b03bcbdda..000000000 --- a/apps/start/src/components/overview/overview-line-chart-tooltip.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; -import { useNumber } from '@/hooks/use-numer-formatter'; -import React from 'react'; - -import { - ChartTooltipContainer, - ChartTooltipHeader, - ChartTooltipItem, - createChartTooltip, -} from '@/components/charts/chart-tooltip'; -import type { IInterval } from '@openpanel/validation'; -import { SerieIcon } from '../report-chart/common/serie-icon'; - -type Data = { - date: string; - timestamp: number; - [key: `${string}:sessions`]: number; - [key: `${string}:pageviews`]: number; - [key: `${string}:revenue`]: number | undefined; - [key: `${string}:payload`]: { - name: string; - prefix?: string; - color: string; - }; -}; - -type Context = { - interval: IInterval; -}; - -export const OverviewLineChartTooltip = createChartTooltip( - ({ context: { interval }, data }) => { - const formatDate = useFormatDateInterval({ - interval, - short: false, - }); - const number = useNumber(); - - if (!data || data.length === 0) { - return null; - } - - const firstItem = data[0]; - - // Get all payload items from the first data point - // Keys are in format "prefix:name:payload" or "name:payload" - const payloadItems = Object.keys(firstItem) - .filter((key) => key.endsWith(':payload')) - .map((key) => { - const payload = firstItem[key as keyof typeof firstItem] as { - name: string; - prefix?: string; - color: string; - }; - // Extract the base key (without :payload) to access sessions/pageviews/revenue - const baseKey = key.replace(':payload', ''); - return { - payload, - baseKey, - }; - }) - .filter( - (item) => - item.payload && - typeof item.payload === 'object' && - 'name' in item.payload, - ); - - // Sort by sessions (descending) - const sorted = payloadItems.sort((a, b) => { - const aSessions = - (firstItem[ - `${a.baseKey}:sessions` as keyof typeof firstItem - ] as number) ?? 0; - const bSessions = - (firstItem[ - `${b.baseKey}:sessions` as keyof typeof firstItem - ] as number) ?? 0; - return bSessions - aSessions; - }); - - const limit = 3; - const visible = sorted.slice(0, limit); - const hidden = sorted.slice(limit); - - return ( - <> - {visible.map((item, index) => { - const sessions = - (firstItem[ - `${item.baseKey}:sessions` as keyof typeof firstItem - ] as number) ?? 0; - const pageviews = - (firstItem[ - `${item.baseKey}:pageviews` as keyof typeof firstItem - ] as number) ?? 0; - const revenue = firstItem[ - `${item.baseKey}:revenue` as keyof typeof firstItem - ] as number | undefined; - - return ( - - {index === 0 && firstItem.date && ( - -
{formatDate(new Date(firstItem.date))}
-
- )} - -
- -
- {item.payload.prefix && ( - <> - - {item.payload.prefix} - - / - - )} - {item.payload.name || 'Not set'} -
-
-
- {revenue !== undefined && revenue > 0 && ( -
- Revenue - - {number.currency(revenue / 100, { short: true })} - -
- )} -
- Pageviews - {number.short(pageviews)} -
-
- Sessions - {number.short(sessions)} -
-
-
-
- ); - })} - {hidden.length > 0 && ( -
- and {hidden.length} more {hidden.length === 1 ? 'item' : 'items'} -
- )} - - ); - }, -); diff --git a/apps/start/src/components/overview/overview-line-chart.tsx b/apps/start/src/components/overview/overview-line-chart.tsx index 423fa5c4d..5ad30012d 100644 --- a/apps/start/src/components/overview/overview-line-chart.tsx +++ b/apps/start/src/components/overview/overview-line-chart.tsx @@ -1,22 +1,19 @@ -import { useNumber } from '@/hooks/use-numer-formatter'; -import { cn } from '@/utils/cn'; -import { getChartColor } from '@/utils/theme'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - CartesianGrid, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; - -import type { RouterOutputs } from '@/trpc/client'; +import type { timeWindows } from '@openpanel/constants'; import type { IInterval } from '@openpanel/validation'; -import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis'; +import { curveMonotoneX } from '@visx/curve'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Grid } from '../charts/grid'; +import { Line } from '../charts/line'; +import { LineChart } from '../charts/line-chart'; +import { useDashedTail } from '../charts/op-dashed-tail'; +import { OPDatePill } from '../charts/op-date-pill'; +import { OPChartTooltip } from '../charts/op-tooltip'; +import { XAxis } from '../charts/x-axis'; +import { YAxis } from '../charts/y-axis'; import { SerieIcon } from '../report-chart/common/serie-icon'; -import { OverviewLineChartTooltip } from './overview-line-chart-tooltip'; +import type { RouterOutputs } from '@/trpc/client'; +import { cn } from '@/utils/cn'; +import { getChartColor } from '@/utils/theme'; type SeriesData = RouterOutputs['overview']['topGenericSeries']['items'][number]; @@ -24,113 +21,94 @@ type SeriesData = interface OverviewLineChartProps { data: RouterOutputs['overview']['topGenericSeries']; interval: IInterval; + range?: keyof typeof timeWindows; searchQuery?: string; className?: string; } -function transformDataForRecharts( - items: SeriesData[], - searchQuery?: string, -): Array<{ +const VISIBLE_LIMIT = 5; +const TOOLTIP_LIMIT = 3; + +interface SeriesMeta { + key: string; + name: string; + prefix?: string; + color: string; +} + +interface ChartPoint { date: string; - timestamp: number; - [key: `${string}:sessions`]: number; - [key: `${string}:pageviews`]: number; - [key: `${string}:revenue`]: number | undefined; - [key: `${string}:payload`]: { - name: string; - prefix?: string; - color: string; - }; -}> { - // Filter items by search query - const filteredItems = searchQuery - ? items.filter((item) => { - const queryLower = searchQuery.toLowerCase(); - return ( - (item.name?.toLowerCase().includes(queryLower) ?? false) || - (item.prefix?.toLowerCase().includes(queryLower) ?? false) - ); - }) - : items; + [key: string]: unknown; +} - // Limit to top 15 - const topItems = filteredItems.slice(0, 15); +function getSeriesKey(item: SeriesData): string { + return item.prefix ? `${item.prefix}:${item.name}` : item.name; +} - // Get all unique dates from all items +function transformData( + items: SeriesData[], + visibleSeries: SeriesMeta[] +): ChartPoint[] { const allDates = new Set(); - topItems.forEach((item) => { + items.forEach((item) => { item.data.forEach((d) => allDates.add(d.date)); }); - const sortedDates = Array.from(allDates).sort(); - - // Transform to recharts format - return sortedDates.map((date) => { - const timestamp = new Date(date).getTime(); - const result: Record = { - date, - timestamp, - }; - - topItems.forEach((item, index) => { - const dataPoint = item.data.find((d) => d.date === date); - if (dataPoint) { - // Use prefix:name as key to avoid collisions when same name exists with different prefixes - const key = item.prefix ? `${item.prefix}:${item.name}` : item.name; - result[`${key}:sessions`] = dataPoint.sessions; - result[`${key}:pageviews`] = dataPoint.pageviews; - if (dataPoint.revenue !== undefined) { - result[`${key}:revenue`] = dataPoint.revenue; + return Array.from(allDates) + .sort() + .map((date) => { + const result: ChartPoint = { date }; + visibleSeries.forEach((series) => { + const item = items.find((i) => getSeriesKey(i) === series.key); + const dataPoint = item?.data.find((d) => d.date === date); + // Always populate — bklit's Line/Area plots undefined values at SVG y=0 + // (chart top), so sparse series would visually shoot to the ceiling. + result[`${series.key}:sessions`] = dataPoint?.sessions ?? 0; + result[`${series.key}:pageviews`] = dataPoint?.pageviews ?? 0; + if (dataPoint?.revenue !== undefined) { + result[`${series.key}:revenue`] = dataPoint.revenue; } - result[`${key}:payload`] = { - name: item.name, - prefix: item.prefix, - color: getChartColor(index), - }; - } + }); + return result; }); - - return result as typeof result & { - date: string; - timestamp: number; - }; - }); } export function OverviewLineChart({ data, interval, + range, searchQuery, className, }: OverviewLineChartProps) { - const number = useNumber(); - - const chartData = useMemo( - () => transformDataForRecharts(data.items, searchQuery), - [data.items, searchQuery], - ); - - const visibleItems = useMemo(() => { + const visibleSeries: SeriesMeta[] = useMemo(() => { const filtered = searchQuery ? data.items.filter((item) => { - const queryLower = searchQuery.toLowerCase(); + const q = searchQuery.toLowerCase(); return ( - (item.name?.toLowerCase().includes(queryLower) ?? false) || - (item.prefix?.toLowerCase().includes(queryLower) ?? false) + (item.name?.toLowerCase().includes(q) ?? false) || + (item.prefix?.toLowerCase().includes(q) ?? false) ); }) : data.items; - return filtered.slice(0, 15); + return filtered.slice(0, VISIBLE_LIMIT).map((item, index) => ({ + key: getSeriesKey(item), + name: item.name, + prefix: item.prefix ?? undefined, + color: getChartColor(index), + })); }, [data.items, searchQuery]); - const xAxisProps = useXAxisProps({ interval, hide: false }); - const yAxisProps = useYAxisProps({}); + const chartData = useMemo( + () => transformData(data.items, visibleSeries), + [data.items, visibleSeries] + ); - if (visibleItems.length === 0) { + const dashFromIndex = useDashedTail({ data: chartData, range, interval }); + + if (visibleSeries.length === 0) { return (
{searchQuery ? 'No results found' : 'No data available'} @@ -141,73 +119,133 @@ export function OverviewLineChart({ return (
-
- - - - - - - } /> - {visibleItems.map((item, index) => { - const color = getChartColor(index); - // Use prefix:name as key to avoid collisions when same name exists with different prefixes - const key = item.prefix - ? `${item.prefix}:${item.name}` - : item.name; - return ( - - ); - })} - - - +
+ + + + + {visibleSeries.map((series) => ( + + ))} + + + extra={() => { + const total = visibleSeries.length; + const hidden = Math.max(0, total - TOOLTIP_LIMIT); + if (hidden === 0) { + return null; + } + return ( +
+ and {hidden} more {hidden === 1 ? 'item' : 'items'} +
+ ); + }} + interval={interval} + rows={(point) => { + const ranked = visibleSeries + .map((series) => { + const sessions = + (point[`${series.key}:sessions`] as number | undefined) ?? + 0; + const pageviews = + (point[`${series.key}:pageviews`] as number | undefined) ?? + 0; + const revenue = point[`${series.key}:revenue`] as + | number + | undefined; + return { series, sessions, pageviews, revenue }; + }) + .sort((a, b) => b.pageviews - a.pageviews); + + const top = ranked.slice(0, TOOLTIP_LIMIT); + + return top.map(({ series, sessions, pageviews, revenue }) => ({ + color: series.color, + icon: , + label: ( + <> + {series.prefix && ( + <> + + {series.prefix} + + / + + )} + {series.name || 'Not set'} + + ), + sub: [ + ...(revenue !== undefined && revenue > 0 + ? [ + { + label: 'Revenue', + value: revenue, + unit: 'currency' as const, + color: 'var(--chart-8)', + }, + ] + : []), + { label: 'Pageviews', value: pageviews }, + { label: 'Sessions', value: sessions }, + ], + })); + }} + showCrosshair + showDatePill={false} + showDots + /> +
- {/* Legend */} - +
); } -function LegendScrollable({ - items, -}: { - items: SeriesData[]; -}) { +function LegendScrollable({ items }: { items: SeriesMeta[] }) { const scrollRef = useRef(null); const [showLeftGradient, setShowLeftGradient] = useState(false); const [showRightGradient, setShowRightGradient] = useState(false); const updateGradients = useCallback(() => { const el = scrollRef.current; - if (!el) return; + if (!el) { + return; + } const { scrollLeft, scrollWidth, clientWidth } = el; const hasOverflow = scrollWidth > clientWidth; setShowLeftGradient(hasOverflow && scrollLeft > 0); setShowRightGradient( - hasOverflow && scrollLeft < scrollWidth - clientWidth - 1, + hasOverflow && scrollLeft < scrollWidth - clientWidth - 1 ); }, []); useEffect(() => { const el = scrollRef.current; - if (!el) return; + if (!el) { + return; + } updateGradients(); @@ -220,54 +258,47 @@ function LegendScrollable({ }; }, [updateGradients]); - // Update gradients when items change useEffect(() => { requestAnimationFrame(updateGradients); }, [items, updateGradients]); return (
- {/* Left gradient */}
- {/* Scrollable legend */}
- {items.map((item, index) => { - const color = getChartColor(index); - return ( -
- - - {item.prefix && ( - <> - {item.prefix} - / - - )} - {item.name || 'Not set'} - -
- ); - })} + {items.map((series) => ( +
+ + + {series.prefix && ( + <> + {series.prefix} + / + + )} + {series.name || 'Not set'} + +
+ ))}
- {/* Right gradient */}
@@ -281,21 +312,17 @@ export function OverviewLineChartLoading({ }) { return (
Loading...
); } -export function OverviewLineChartEmpty({ - className, -}: { - className?: string; -}) { +export function OverviewLineChartEmpty({ className }: { className?: string }) { return (
No data available
diff --git a/apps/start/src/components/overview/overview-live-histogram.tsx b/apps/start/src/components/overview/overview-live-histogram.tsx index 6de8030ca..9632b4a75 100644 --- a/apps/start/src/components/overview/overview-live-histogram.tsx +++ b/apps/start/src/components/overview/overview-live-histogram.tsx @@ -1,45 +1,59 @@ import { useTRPC } from '@/integrations/trpc/react'; -import { useQuery } from '@tanstack/react-query'; - import { useNumber } from '@/hooks/use-numer-formatter'; -import { getChartColor } from '@/utils/theme'; -import * as Portal from '@radix-ui/react-portal'; -import { bind } from 'bind-event-listener'; -import throttle from 'lodash.throttle'; -import React, { useEffect, useState } from 'react'; +import { cn } from '@/utils/cn'; +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { Bar } from '../charts/bar'; +import { BarChart } from '../charts/bar-chart'; import { - Bar, - BarChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; + OPStatHoverBridge, + type OPStatHoverState, +} from '../charts/op-stat-hover-bridge'; import { SerieIcon } from '../report-chart/common/serie-icon'; +import { Skeleton } from '../skeleton'; +import { MetricCardShell } from './overview-metric-card'; + interface OverviewLiveHistogramProps { projectId: string; shareId?: string; } +const PRIMARY_COLOR = 'var(--chart-0)'; + +interface MinutePoint { + time: string; + sessionCount: number; + /** Synthetic per-minute date so bklit's time-based AreaChart can scale the X. */ + date: Date; + [key: string]: unknown; +} + export function OverviewLiveHistogram({ projectId, shareId, }: OverviewLiveHistogramProps) { const trpc = useTRPC(); + const number = useNumber(); - // Use the new liveData endpoint instead of chart props const { data: liveData, isLoading } = useQuery( trpc.overview.liveData.queryOptions({ projectId, shareId }), ); - const totalSessions = liveData?.totalSessions ?? 0; - const chartData = liveData?.minuteCounts ?? []; + const [hover, setHover] = useState>({ + index: null, + point: null, + }); if (isLoading) { return ( - -
- + +
+ + + +
+
+ ); } @@ -47,189 +61,84 @@ export function OverviewLiveHistogram({ return null; } - const maxDomain = - Math.max(...chartData.map((item) => item.sessionCount)) * 1.2; + const rawMinutes = liveData.minuteCounts ?? []; + const now = Date.now(); + const chartData: MinutePoint[] = rawMinutes.map((point, i) => ({ + ...point, + date: new Date(now - (rawMinutes.length - 1 - i) * 60_000), + })); + const totalSessions = liveData.totalSessions ?? 0; + const displayCount = hover.point?.sessionCount ?? totalSessions; + const displayLabel = hover.point ? hover.point.time : 'Last 30 min'; + const referrers = liveData.referrers ?? []; return ( - - {liveData.referrers.slice(0, 3).map((ref, index) => ( -
- - {ref.count} -
- ))} -
- } - > -
- - - +
+
+ + Live · 30 min + + + 0 ? 'bg-emerald-500 animate-ping' : 'bg-destructive', + )} /> - - - 0 ? 'bg-emerald-500' : 'bg-destructive', + )} /> - - -
- - ); -} - -interface WrapperProps { - children: React.ReactNode; - count: number; - icons?: React.ReactNode; -} - -function Wrapper({ children, count, icons }: WrapperProps) { - return ( -
-
-
- {count} sessions last 30 min +
- {icons} -
-
- {children} -
-
- ); -} - -// Custom tooltip component that uses portals to escape overflow hidden -const CustomTooltip = ({ active, payload, coordinate }: any) => { - const number = useNumber(); - const [position, setPosition] = useState<{ x: number; y: number } | null>( - null, - ); - - const inactive = !active || !payload?.length; - useEffect(() => { - const setPositionThrottled = throttle(setPosition, 50); - const unsubMouseMove = bind(window, { - type: 'mousemove', - listener(event) { - if (!inactive) { - setPositionThrottled({ x: event.clientX, y: event.clientY + 20 }); - } - }, - }); - const unsubDragEnter = bind(window, { - type: 'pointerdown', - listener() { - setPosition(null); - }, - }); - - return () => { - unsubMouseMove(); - unsubDragEnter(); - }; - }, [inactive]); - - if (inactive) { - return null; - } - - if (!active || !payload || !payload.length) { - return null; - } - - const data = payload[0].payload; - - const tooltipWidth = 200; - const correctXPosition = (x: number | undefined) => { - if (!x) { - return undefined; - } - - const screenWidth = window.innerWidth; - const newX = x; - - if (newX + tooltipWidth > screenWidth) { - return screenWidth - tooltipWidth; - } - return newX; - }; - - return ( - -
-
{data.time}
-
- -
-
-
-
Sessions
-
-
- {number.formatWithUnit(data.sessionCount)} -
-
-
+
+ + {number.short(displayCount)} +
- {data.referrers && data.referrers.length > 0 && ( -
-
Referrers:
-
- {data.referrers.slice(0, 3).map((ref: any, index: number) => ( +
+ {displayLabel} + {referrers.length > 0 && ( +
+ {referrers.slice(0, 3).map((ref, index) => (
-
- - - {ref.referrer} - -
- {ref.count} + + {ref.count}
))} - {data.referrers.length > 3 && ( -
- +{data.referrers.length - 3} more -
- )}
-
+ )} +
+
+ +
+ {chartData.length > 0 && ( + + + + )} - - +
+ ); -}; +} diff --git a/apps/start/src/components/overview/overview-metric-card.tsx b/apps/start/src/components/overview/overview-metric-card.tsx index a2ea8310f..aa129fd4c 100644 --- a/apps/start/src/components/overview/overview-metric-card.tsx +++ b/apps/start/src/components/overview/overview-metric-card.tsx @@ -1,16 +1,25 @@ +import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; +import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter'; +import { cn } from '@/utils/cn'; +import { timeWindows } from '@openpanel/constants'; import { getPreviousMetric } from '@openpanel/common'; -import { useEffect, useRef, useState } from 'react'; -import AutoSizer from 'react-virtualized-auto-sizer'; -import { Bar, BarChart, Tooltip } from 'recharts'; +import type { IInterval } from '@openpanel/validation'; +import { curveMonotoneX } from '@visx/curve'; +import { type ReactNode, useState } from 'react'; +import { Area } from '../charts/area'; +import { AreaChart } from '../charts/area-chart'; +import { useDashedTail } from '../charts/op-dashed-tail'; import { - getDiffIndicator, - PreviousDiffIndicatorPure, -} from '../report-chart/common/previous-diff-indicator'; + OPStatHoverBridge, + type OPStatHoverState, +} from '../charts/op-stat-hover-bridge'; +import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator'; import { Skeleton } from '../skeleton'; -import { Tooltiper } from '../ui/tooltip'; -import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter'; -import { cn } from '@/utils/cn'; -import { formatDate, timeAgo } from '@/utils/date'; +import { formatDate as formatAbsoluteDate, timeAgo } from '@/utils/date'; + +const PRIMARY_COLOR = 'var(--chart-0)'; + +export type MetricUnit = '' | 'date' | 'timeAgo' | 'min' | '%' | 'currency'; interface MetricCardProps { id: string; @@ -23,12 +32,16 @@ interface MetricCardProps { current: number; previous?: number | null; }; - unit?: '' | 'date' | 'timeAgo' | 'min' | '%' | 'currency'; + unit?: MetricUnit; label: string; onClick?: () => void; active?: boolean; inverted?: boolean; isLoading?: boolean; + /** Interval drives the hover-label date format. */ + interval?: IInterval; + /** Range drives the default label ("Last 30 days") and dashed-tail logic. */ + range?: keyof typeof timeWindows; } export function OverviewMetricCard({ @@ -41,187 +54,166 @@ export function OverviewMetricCard({ active, inverted = false, isLoading = false, + interval = 'day', + range, }: MetricCardProps) { - const [currentIndex, setCurrentIndex] = useState(null); - const number = useNumber(); - const { current, previous } = metric; - const timer = useRef(null); - - useEffect(() => { - if (timer.current) { - clearTimeout(timer.current); - } - - if (currentIndex) { - timer.current = setTimeout(() => { - setCurrentIndex(null); - }, 1000); - } - - return () => { - if (timer.current) { - clearTimeout(timer.current); - } - }; - }, [currentIndex]); - - const renderValue = (value: number, unitClassName?: string, short = true) => { - if (unit === 'date') { - return <>{formatDate(new Date(value))}; - } - - if (unit === 'timeAgo') { - if (!value) { - return <>{'N/A'}; - } - return <>{timeAgo(new Date(value))}; - } - - if (unit === 'min') { - return <>{fancyMinutes(value)}; - } - - if (unit === 'currency') { - // Revenue is stored in cents, convert to dollars - return <>{number.currency(value / 100)}; - } + const formatDate = useFormatDateInterval({ interval, short: false }); + const [hover, setHover] = useState< + OPStatHoverState<{ date: string; current: number; previous?: number }> + >({ index: null, point: null }); - return ( - <> - {short ? number.short(value) : number.format(value)} - {unit && {unit}} - - ); - }; + const hovered = hover.point; + const displayValue = hovered ? (hovered.current ?? 0) : metric.current; + const displayPrev = hovered + ? (hovered.previous ?? null) + : (metric.previous ?? null); + const displayLabel = hovered + ? formatDate(new Date(hovered.date)) + : (range ? timeWindows[range]?.label : 'Total') || 'Total'; - const graphColors = getDiffIndicator( - inverted, - getPreviousMetric(current, previous)?.state, - '#6ee7b7', // green - '#fda4af', // red - '#93c5fd' // blue - ); + const diff = getPreviousMetric(displayValue, displayPrev); + const dashFromIndex = useDashedTail({ data, range, interval }); - const renderTooltip = () => { - if (currentIndex) { - return ( - - {formatDate(new Date(data[currentIndex]?.date))}:{' '} - - {renderValue( - data[currentIndex].current, - 'ml-1 font-light text-xl', - false - )} + return ( + +
+
+ + {label} - - ); - } + {isLoading ? null : ( + + )} +
+
+ {isLoading ? ( + + ) : ( + + )} +
+
+ {displayLabel} +
+
- return ( - - {label}:{' '} - - {renderValue(metric.current, 'ml-1 font-light text-xl', false)} - - - ); - }; - return ( - - - + + + + )} +
+ ); } -export function OverviewMetricCardNumber({ - label, - value, - enhancer, +/** + * Outer chrome shared by the metric cards and the live histogram card — + * fixed-height button with hover/active states and a left-bar accent. The + * sparkline is expected to be the last child and bleed to the bottom edge. + */ +export function MetricCardShell({ + children, + active, + onClick, className, - isLoading, }: { - label: React.ReactNode; - value: React.ReactNode; - enhancer?: React.ReactNode; + children: ReactNode; + active?: boolean; + onClick?: () => void; className?: string; - isLoading?: boolean; }) { + const Tag: 'button' | 'div' = onClick ? 'button' : 'div'; return ( -
-
- - {label} - -
- {isLoading ? ( -
- - -
- ) : ( -
- {value} -
+ - {enhancer} -
-
+ > + {active && ( + + )} + {children} + ); } + +const VALUE_CLASS = + 'truncate font-mono font-semibold text-xl text-foreground tracking-tight tabular-nums'; + +function MetricValue({ value, unit }: { value: number; unit?: MetricUnit }) { + const number = useNumber(); + + if (unit === 'date') { + return ( + + {value ? formatAbsoluteDate(new Date(value)) : 'N/A'} + + ); + } + + if (unit === 'timeAgo') { + return ( + {value ? timeAgo(new Date(value)) : 'N/A'} + ); + } + + if (unit === 'min') { + return {fancyMinutes(value)}; + } + + if (unit === 'currency') { + return ( + + {number.currency(value / 100, { short: true })} + + ); + } + + if (unit === '%') { + return ( + <> + {number.format(value)} + + % + + + ); + } + + return {number.short(value)}; +} diff --git a/apps/start/src/components/overview/overview-metrics.tsx b/apps/start/src/components/overview/overview-metrics.tsx index 7ffed140c..8e7b07e7d 100644 --- a/apps/start/src/components/overview/overview-metrics.tsx +++ b/apps/start/src/components/overview/overview-metrics.tsx @@ -1,44 +1,41 @@ -import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; -import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; -import { cn } from '@/utils/cn'; - -import { useDashedStroke } from '@/hooks/use-dashed-stroke'; -import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; -import { useNumber } from '@/hooks/use-numer-formatter'; -import { useTRPC } from '@/integrations/trpc/react'; -import type { RouterOutputs } from '@/trpc/client'; -import { getChartColor } from '@/utils/theme'; -import { getPreviousMetric } from '@openpanel/common'; import type { IInterval } from '@openpanel/validation'; import { useQuery } from '@tanstack/react-query'; -import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; -import { last } from 'ramda'; -import React, { useState } from 'react'; -import { - Area, - Bar, - CartesianGrid, - Cell, - ComposedChart, - Customized, - Line, - ReferenceLine, - ResponsiveContainer, - XAxis, - YAxis, -} from 'recharts'; -import { createChartTooltip } from '../charts/chart-tooltip'; -import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis'; -import { PreviousDiffIndicatorPure } from '../report-chart/common/previous-diff-indicator'; +import { curveMonotoneX } from '@visx/curve'; +import { DollarSignIcon } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { Area } from '../charts/area'; +import { ComposedChart } from '../charts/composed-chart'; +import { Grid } from '../charts/grid'; +import { Line } from '../charts/line'; +import { useDashedTail } from '../charts/op-dashed-tail'; +import { OPDatePill } from '../charts/op-date-pill'; +import { OPReferences } from '../charts/op-references'; +import { OPReferrerSpikes } from '../charts/op-referrer-spikes'; +import { OPSeriesDots } from '../charts/op-series-dots'; +import { OPChartTooltip } from '../charts/op-tooltip'; +import { XAxis } from '../charts/x-axis'; +import { YAxis } from '../charts/y-axis'; import { Skeleton } from '../skeleton'; import { OverviewLiveHistogram } from './overview-live-histogram'; import { OverviewMetricCard } from './overview-metric-card'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; +import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; +import { useTRPC } from '@/integrations/trpc/react'; +import type { RouterOutputs } from '@/trpc/client'; +import { cn } from '@/utils/cn'; +import { getChartColor } from '@/utils/theme'; interface OverviewMetricsProps { projectId: string; shareId?: string; } +// Softer than --chart-8's electric emerald, still clearly "green = money". +const REVENUE_COLOR = 'oklch(0.68 0.11 158)'; +// Bump alpha (0.2 → 0.4) so the bklit hover highlight — which inherits the +// line's stroke color — has enough body to be perceptible on hover. +const PREV_LINE_COLOR = 'oklch(from var(--foreground) l c h / 0.4)'; + const TITLES = [ { title: 'Unique Visitors', @@ -103,164 +100,122 @@ export default function OverviewMetrics({ filters, startDate, endDate, - }), + }) ); - const data = - overviewQuery.data?.series?.map((item) => ({ - ...item, - timestamp: new Date(item.date).getTime(), - })) || []; + const series = overviewQuery.data?.series ?? []; + const [mockRevenue, setMockRevenue] = useState(false); + const data = useMemo( + () => (mockRevenue ? injectMockRevenue(series) : series), + [series, mockRevenue] + ); return ( - <> -
-
- {TITLES.map((title, index) => ( - setMetric(index)} - label={title.title} - metric={{ - current: overviewQuery.data?.metrics[title.key] ?? 0, - previous: overviewQuery.data?.metrics[`prev_${title.key}`], - }} - unit={title.unit} - data={data.map((item) => ({ - date: item.date, - current: item[title.key], - previous: item[`prev_${title.key}`], - }))} - active={metric === index} - isLoading={overviewQuery.isLoading} - inverted={title.inverted} - /> - ))} - -
- -
-
+
+
+ {TITLES.map((title, index) => ( + ({ + date: item.date, + current: item[title.key], + previous: item[`prev_${title.key}`], + }))} + id={title.key} + interval={interval} + inverted={title.inverted} + isLoading={overviewQuery.isLoading} + key={title.key} + label={title.title} + metric={{ + current: overviewQuery.data?.metrics[title.key] ?? 0, + previous: overviewQuery.data?.metrics[`prev_${title.key}`], + }} + onClick={() => setMetric(index)} + range={range} + unit={title.unit} + /> + ))} + + +
-
-
-
- {activeMetric.title} -
+
+
+
+ {activeMetric.title}
-
- {overviewQuery.isLoading && } + {import.meta.env.DEV && ( + + )} +
+
+ {overviewQuery.isLoading && } + {!overviewQuery.isLoading && data.length > 0 && ( -
+ )}
- +
); } -const { Tooltip, TooltipProvider } = createChartTooltip< - RouterOutputs['overview']['stats']['series'][number], - { - anyMetric?: boolean; - metric: (typeof TITLES)[number]; - interval: IInterval; +type SeriesItem = RouterOutputs['overview']['stats']['series'][number]; + +/** + * Synthesize plausible-looking revenue (in cents) so we can preview the + * normalized revenue bars without paying customers. Deterministic per row + * via a tiny hash so toggling on/off doesn't reshuffle the bars. + */ +function injectMockRevenue(series: SeriesItem[]): SeriesItem[] { + if (series.length === 0) { + return series; } ->(({ context: { metric, interval, anyMetric }, data: dataArray }) => { - const data = dataArray[0]; - const formatDate = useFormatDateInterval({ - interval, - short: false, + return series.map((item, i) => { + const seed = hashStr(String(item.date)) + i; + const wave = (Math.sin(seed * 0.7) + 1) / 2; // 0..1 + const jitter = ((seed * 9301 + 49_297) % 233_280) / 233_280; // 0..1 + const revenueCents = Math.round(50_000 + wave * 250_000 + jitter * 80_000); + const prevCents = Math.round(revenueCents * (0.6 + jitter * 0.6)); + return { + ...item, + total_revenue: revenueCents, + prev_total_revenue: prevCents, + }; }); - const number = useNumber(); +} - if (!data) { - return null; +function hashStr(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (h << 5) - h + s.charCodeAt(i); + h |= 0; } + return Math.abs(h); +} - const revenue = data.total_revenue ?? 0; - const prevRevenue = data.prev_total_revenue ?? 0; - - return ( - <> -
-
{formatDate(new Date(data.date))}
-
- -
-
-
-
{metric.title}
-
-
- {metric.unit === 'currency' - ? number.currency((data[metric.key] ?? 0) / 100) - : number.formatWithUnit(data[metric.key], metric.unit)} - {!!data[`prev_${metric.key}`] && ( - - ( - {metric.unit === 'currency' - ? number.currency((data[`prev_${metric.key}`] ?? 0) / 100) - : number.formatWithUnit( - data[`prev_${metric.key}`], - metric.unit, - )} - ) - - )} -
- - -
-
-
- {anyMetric && revenue > 0 && ( -
-
-
-
Revenue
-
-
- {number.currency(revenue / 100)} - {prevRevenue > 0 && ( - - ({number.currency(prevRevenue / 100)}) - - )} -
- {prevRevenue > 0 && ( - - )} -
-
-
- )} - - - ); -}); +type ChartPoint = SeriesItem & { + /** Normalized revenue so bars share the primary metric's Y domain. */ + revenue_norm?: number; +}; function Chart({ activeMetric, @@ -270,19 +225,13 @@ function Chart({ }: { activeMetric: (typeof TITLES)[number]; interval: IInterval; - data: RouterOutputs['overview']['stats']['series']; + data: SeriesItem[]; projectId: string; }) { - const xAxisProps = useXAxisProps({ interval }); - const yAxisProps = useYAxisProps(); - const number = useNumber(); - const revenueYAxisProps = useYAxisProps({ - tickFormatter: (value) => number.short(value / 100), - }); - const [activeBar, setActiveBar] = useState(-1); const { range, startDate, endDate } = useOverviewOptions(); - const trpc = useTRPC(); + const isRevenueTab = activeMetric.key === 'total_revenue'; + const references = useQuery( trpc.reference.getChartReferences.queryOptions( { @@ -291,334 +240,211 @@ function Chart({ endDate, range, }, + { staleTime: 1000 * 60 * 10 } + ) + ); + + const [filters, setFilter] = useEventQueryFilters(); + const referrerSpikes = useQuery( + trpc.overview.getReferrerSpikes.queryOptions( { - staleTime: 1000 * 60 * 10, + projectId, + startDate, + endDate, + range, + interval, + filters, }, - ), + { staleTime: 1000 * 60 * 10 } + ) + ); + // Click a spike's favicon → drop a referrer_name filter on the chart. + // The query above already includes `filters`, so the chart re-fetches + // with the new filter applied (which also re-computes spike detection + // within that filtered slice). + const handleSpikeClick = useCallback( + (referrerName: string) => { + setFilter('referrer_name', referrerName); + }, + [setFilter] + ); + // Flatten clusters → individual spikes for the tooltip's bucket-match + // lookup. The chart marker layer keeps the cluster shape (one marker per + // cluster); the tooltip works at spike granularity so any hovered bucket + // can surface its spike, even one that's not the cluster's anchor. + const flatSpikes = useMemo( + () => referrerSpikes.data?.flatMap((c) => c.spikes) ?? null, + [referrerSpikes.data] ); - // Line chart specific logic - let dotIndex = undefined; - if (interval === 'hour') { - // Find closest index based on times - dotIndex = data.findIndex((item) => { - return isSameHour(item.date, new Date()); - }); - } - - const { calcStrokeDasharray, handleAnimationEnd, getStrokeDasharray } = - useDashedStroke({ - dotIndex, - }); - - const lastSerieDataItem = last(data)?.date || new Date(); - const useDashedLastLine = (() => { - if (range === 'today') { - return true; - } - - if (interval === 'hour') { - return isSameHour(lastSerieDataItem, new Date()); - } - - if (interval === 'day') { - return isSameDay(lastSerieDataItem, new Date()); + const { chartData, showRevenue } = useMemo(() => { + if (isRevenueTab) { + return { + chartData: data as ChartPoint[], + showRevenue: false, + }; } - if (interval === 'month') { - return isSameMonth(lastSerieDataItem, new Date()); + let maxPrimary = 0; + let maxRevenue = 0; + for (const item of data) { + const primary = (item[activeMetric.key] as number | undefined) ?? 0; + const revenue = (item.total_revenue as number | undefined) ?? 0; + if (primary > maxPrimary) { + maxPrimary = primary; + } + if (revenue > maxRevenue) { + maxRevenue = revenue; + } } - if (interval === 'week') { - return isSameWeek(lastSerieDataItem, new Date()); + if (maxRevenue === 0 || maxPrimary === 0) { + return { + chartData: data as ChartPoint[], + showRevenue: false, + }; } - return false; - })(); - - if (activeMetric.key === 'total_revenue') { - return ( - - - - - - - - - - - - - - - - - - - - - 90 - ? false - : { - stroke: 'oklch(from var(--foreground) l c h / 0.1)', - fill: 'transparent', - strokeWidth: 1.5, - r: 2, - } - } - activeDot={{ - stroke: 'oklch(from var(--foreground) l c h / 0.2)', - fill: 'transparent', - strokeWidth: 1.5, - r: 3, - }} - /> - - 90 - ? false - : { - stroke: '#3ba974', - fill: '#3ba974', - strokeWidth: 1.5, - r: 3, - } - } - activeDot={{ - stroke: '#3ba974', - fill: 'var(--def-100)', - strokeWidth: 2, - r: 4, - }} - filter="url(#rainbow-line-glow)" - /> - - {references.data?.map((ref) => ( - - ))} - - - - ); - } + // Pin revenue peak at ~60% of the primary metric's peak so bars stay + // legible without overshadowing the line/area. + const scale = (maxPrimary * 0.4) / maxRevenue; + return { + chartData: data.map((item) => ({ + ...item, + revenue_norm: ((item.total_revenue as number | undefined) ?? 0) * scale, + })) as ChartPoint[], + showRevenue: true, + }; + }, [data, activeMetric.key, isRevenueTab]); + + const dashFromIndex = useDashedTail({ data, range, interval }); + const primaryColor = isRevenueTab ? REVENUE_COLOR : getChartColor(0); return ( - - - { - setActiveBar(e.activeTooltipIndex ?? -1); - }} - > - - - - Math.max(dataMax, 1), - ]} - width={25} - /> - Math.max(max, item.total_revenue ?? 0), - 0, - ) * 1.2, - 1, - ), - ]} - width={30} - allowDataOverflow={false} - /> - - - - - - - - - - - - - - - - - {data.map((item, index) => { - return ( - - ); - })} - - + + + + + + + + + {showRevenue && ( + + )} + + + + + + indicatorColor={primaryColor} + interval={interval} + references={references.data} + rows={(point) => { + const primaryVal = point[activeMetric.key] as number | undefined; + const primaryPrev = point[`prev_${activeMetric.key}`] as + | number + | undefined; + + const rows = [ + { + color: primaryColor, + label: activeMetric.title, + value: primaryVal, + previous: primaryPrev, + inverted: activeMetric.inverted, + unit: tooltipUnitFor(activeMetric.unit), + }, + ]; + + if (!isRevenueTab) { + const revenue = (point.total_revenue as number | undefined) ?? 0; + const prevRevenue = + (point.prev_total_revenue as number | undefined) ?? 0; + if (revenue > 0) { + rows.push({ + color: REVENUE_COLOR, + label: 'Revenue', + value: revenue, + previous: prevRevenue > 0 ? prevRevenue : undefined, + inverted: false, + unit: 'currency', + }); } - isAnimationActive={false} - dot={ - data.length > 90 - ? false - : { - stroke: getChartColor(0), - fill: 'var(--def-100)', - fillOpacity: 1, - strokeWidth: 1.5, - r: 3, - } - } - activeDot={{ - stroke: getChartColor(0), - fill: 'var(--def-100)', - strokeWidth: 2, - r: 4, - }} - filter="url(#rainbow-line-glow)" - /> - - {references.data?.map((ref) => ( - - ))} - - - + } + + return rows; + }} + showDatePill={false} + showDots={false} + spikes={flatSpikes} + /> + + + + ); } + +function tooltipUnitFor(unit: (typeof TITLES)[number]['unit']) { + if (unit === 'currency') { + return 'currency' as const; + } + if (unit === 'min') { + return 'min' as const; + } + if (unit === '%') { + return 'pct' as const; + } + return undefined; +} diff --git a/apps/start/src/components/overview/overview-top-devices.tsx b/apps/start/src/components/overview/overview-top-devices.tsx index a9d3e7f7f..9605a63d4 100644 --- a/apps/start/src/components/overview/overview-top-devices.tsx +++ b/apps/start/src/components/overview/overview-top-devices.tsx @@ -388,6 +388,7 @@ export default function OverviewTopDevices({ ) : ( diff --git a/apps/start/src/components/overview/overview-top-geo.tsx b/apps/start/src/components/overview/overview-top-geo.tsx index a08d8556b..eb2409e15 100644 --- a/apps/start/src/components/overview/overview-top-geo.tsx +++ b/apps/start/src/components/overview/overview-top-geo.tsx @@ -134,6 +134,7 @@ export default function OverviewTopGeo({ ) : ( diff --git a/apps/start/src/components/overview/overview-top-sources.tsx b/apps/start/src/components/overview/overview-top-sources.tsx index 41a498b9e..4c8513814 100644 --- a/apps/start/src/components/overview/overview-top-sources.tsx +++ b/apps/start/src/components/overview/overview-top-sources.tsx @@ -134,6 +134,7 @@ export default function OverviewTopSources({ ) : ( diff --git a/apps/start/src/components/report-chart/common/serie-icon.tsx b/apps/start/src/components/report-chart/common/serie-icon.tsx index 85b67dcaa..043285960 100644 --- a/apps/start/src/components/report-chart/common/serie-icon.tsx +++ b/apps/start/src/components/report-chart/common/serie-icon.tsx @@ -27,8 +27,15 @@ import iconsWithUrls from './serie-icon.urls'; // Types // ============================================================================ -type SerieIconProps = Omit & { +type SerieIconProps = Omit & { name?: string | string[]; + /** + * When true, render the icon at the size of its parent container instead + * of the default max-h-4 cap. Use for circular/full-bleed contexts like + * chart markers where the favicon should fill the surrounding circle. + * Overrides Lucide's `fill` prop semantics for this component. + */ + fill?: boolean; }; type IconType = 'lucide' | 'image' | 'flag'; @@ -279,15 +286,24 @@ function resolveIcon(name: string): ResolvedIcon { return null; } -function IconWrapper({ children }: { children: React.ReactNode }) { +function IconWrapper({ + children, + fill, +}: { children: React.ReactNode; fill?: boolean }) { return ( -
+
{children}
); } -function ImageIcon({ url }: { url: string }) { +function ImageIcon({ url, fill }: { url: string; fill?: boolean }) { const context = useAppContext(); const [hasError, setHasError] = useState(false); @@ -298,11 +314,15 @@ function ImageIcon({ url }: { url: string }) { const fullUrl = context.apiUrl?.replace(/\/$/, '') + url; return ( - + setHasError(true)} @@ -311,11 +331,15 @@ function ImageIcon({ url }: { url: string }) { ); } -function FlagIcon({ code }: { code: string }) { +function FlagIcon({ code, fill }: { code: string; fill?: boolean }) { return ( - + ); @@ -323,18 +347,22 @@ function FlagIcon({ code }: { code: string }) { function LucideIconWrapper({ Icon, + fill, ...props -}: { Icon: React.ComponentType } & LucideProps) { +}: { + Icon: React.ComponentType; + fill?: boolean; +} & Omit) { return ( - - + + ); } // Main component -export function SerieIcon({ name: names, ...props }: SerieIconProps) { +export function SerieIcon({ name: names, fill, ...props }: SerieIconProps) { const name = Array.isArray(names) ? names[0] : names; if (!name) { @@ -349,10 +377,10 @@ export function SerieIcon({ name: names, ...props }: SerieIconProps) { switch (resolved.type) { case 'lucide': - return ; + return ; case 'image': - return ; + return ; case 'flag': - return ; + return ; } } diff --git a/apps/start/src/lib/utils.ts b/apps/start/src/lib/utils.ts index 9ad0df426..365058ceb 100644 --- a/apps/start/src/lib/utils.ts +++ b/apps/start/src/lib/utils.ts @@ -1,5 +1,5 @@ -import { type ClassValue, clsx } from 'clsx'; -import { twMerge } from 'tailwind-merge'; +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); diff --git a/apps/start/src/styles.css b/apps/start/src/styles.css index 85a64b470..2b5731eea 100644 --- a/apps/start/src/styles.css +++ b/apps/start/src/styles.css @@ -21,13 +21,13 @@ code { :root { /* Core colors converted from HSL to OKLCH using convert-to-oklch tool */ --highlight: oklch(55.1% 0.21 262); /* hsl(220, 84%, 53%) -> #2266ec */ - + /* def- color scale converted from HSL to OKLCH */ --def-100: oklch(98.4% 0 248); /* hsl(210, 40%, 98%) -> #F0F4F9 */ --def-200: oklch(96.8% 0 248); /* hsl(210, 40%, 96.1%) -> #E7ECF2 */ --def-300: oklch(91.7% 0 248); /* hsl(210, 15.14%, 89.41%) -> #DDE3E9 */ --def-400: oklch(80.3% 0 248); /* hsl(210, 9.08%, 75.05%) -> #B3BDC7 */ - + /* foregroundish converted from HSL to OKLCH */ --foregroundish: oklch(34.4% 0 273); /* hsl(226.49, 3.06%, 22.62%) */ @@ -50,7 +50,7 @@ code { --border: oklch(92.9% 0.01 256); /* hsl(214.3, 31.8%, 91.4%) -> #E8EBF1 */ --input: oklch(92.9% 0.01 256); /* hsl(214.3, 31.8%, 91.4%) -> #E8EBF1 */ --ring: oklch(86.9% 0.02 253); /* hsl(212.73, 26.83%, 83.92%) -> #CDD6E2 */ - + /* Chart colors converted to OKLCH for consistency */ --chart-0: oklch(54.6% 0.22 263); /* #2563EB */ --chart-1: oklch(72.2% 0.17 33.7); /* #ff7557 */ @@ -65,20 +65,47 @@ code { --chart-10: oklch(71.1% 0.15 320); /* #cb80dc */ --chart-11: oklch(72.1% 0.09 188); /* #5cb7af */ --chart-12: oklch(58.7% 0.24 286); /* #7856ff */ - + --radius: 0.5rem; + + /* Bklit chart tokens — single-series defaults pick OpenPanel brand palette. + We deliberately do NOT redefine --chart-1..5 (OpenPanel uses them as a 13-color + fixed palette via --chart-0..12); for multi-series charts we always pass + explicit colors via getChartColor(i). */ + --chart-background: var(--card); + --chart-foreground: var(--foreground); + --chart-foreground-muted: var(--muted-foreground); + --chart-line-primary: var(--chart-0); + --chart-line-secondary: var(--chart-1); + --chart-crosshair: oklch(from var(--foreground) l c h / 0.4); + --chart-grid: var(--border); + /* Outer tooltip box is transparent — OPTooltipCard owns the visible card. */ + --chart-tooltip-background: transparent; + --chart-tooltip-foreground: var(--background); + --chart-tooltip-muted: oklch(from var(--background) l c h / 0.6); + --chart-marker-background: var(--def-100); + --chart-marker-border: var(--border); + --chart-marker-foreground: var(--foreground); + /* Notification-pill style: high-contrast against the marker circle. Auto- + adapts to light/dark via the foreground/background tokens. Without + these the SVG / in MarkerGroup fall back to black on + black and the count badge renders as a black dot. */ + --chart-marker-badge-background: var(--foreground); + --chart-marker-badge-foreground: var(--background); + --chart-ring-background: oklch(from var(--foreground) l c h / 0.12); + --chart-label: var(--muted-foreground); } .dark { /* Core colors converted from HSL to OKLCH for dark mode using convert-to-oklch tool */ --highlight: oklch(61.3% 0.21 263); /* hsl(221.44, 100%, 62.04%) */ - + /* def- color scale converted from HSL to OKLCH for dark mode */ --def-100: oklch(19.3% 0 0); /* hsl(0, 0%, 8%) -> #0f0f0f */ --def-200: oklch(25% 0 0); /* hsl(0, 0%, 13.12%) -> #212121 */ --def-300: oklch(27.1% 0 0); /* hsl(0, 0%, 15.1%) -> #262626 */ --def-400: oklch(37.3% 0 0); /* hsl(0, 0%, 25.23%) -> #404040 */ - + --foregroundish: oklch(83.9% 0 0); /* hsl(0, 0%, 79.23%) */ --background: oklch(23.8% 0 0); /* hsl(0, 0%, 12.02%) -> #1e1e1e */ @@ -100,6 +127,27 @@ code { --border: oklch(27.1% 0 0); /* hsl(0, 0%, 15.1%) -> #262626 */ --input: oklch(27.1% 0 0); /* hsl(0, 0%, 15.1%) -> #262626 */ --ring: oklch(87.6% 0 0); /* hsl(0, 0%, 83.9%) -> #d6d6d6 */ + + /* Bklit chart tokens (dark) — OpenPanel chart palette (--chart-0..12) stays + theme-agnostic and is defined in :root only, so we do NOT redefine it here. */ + --chart-tooltip-background: transparent; + --chart-tooltip-foreground: var(--background); + --chart-tooltip-muted: oklch(from var(--background) l c h / 0.6); + --chart-1: oklch(1 0 none); + --chart-2: oklch(0.73 0 none); + --chart-3: oklch(0.51 0 none); + --chart-4: oklch(0.39 0 none); + --chart-5: oklch(0.32 0 none); + --chart-background: oklch(0.145 0 0); + --chart-foreground: oklch(0.45 0 0); + --chart-foreground-muted: oklch(0.65 0.01 260); + --chart-crosshair: oklch(0.45 0 0); + --chart-grid: oklch(0.25 0 0); + --chart-marker-background: oklch(0.25 0.01 260); + --chart-marker-border: oklch(0.4 0.01 260); + --chart-marker-foreground: oklch(0.9 0 0); + --chart-ring-background: oklch(0.35 0.01 260 / 0.25); + --chart-label: oklch(0.75 0.01 260); } @theme { @@ -151,7 +199,7 @@ code { --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - + /* Extended chart colors */ --color-chart-0: var(--chart-0); --color-chart-1: var(--chart-1); @@ -166,23 +214,23 @@ code { --color-chart-10: var(--chart-10); --color-chart-11: var(--chart-11); --color-chart-12: var(--chart-12); - + /* Additional colors */ --color-highlight: var(--highlight); --color-foregroundish: var(--foregroundish); - + /* def color scale - properly defined for all variants */ --color-def-100: var(--def-100); --color-def-200: var(--def-200); --color-def-300: var(--def-300); --color-def-400: var(--def-400); - + /* Radius variables */ --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); - + /* Sidebar colors */ --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); @@ -234,7 +282,7 @@ code { .subtitle { @apply text-base font-medium; } - + .card { @apply rounded-md border border-border bg-card; } @@ -272,7 +320,6 @@ button { @apply cursor-pointer; } - .hide-scrollbar::-webkit-scrollbar { display: none; } @@ -452,4 +499,27 @@ button { animation: sticky-shadow linear both; animation-timeline: scroll(); animation-range: 0 1px; +} + +@theme inline { + --color-chart-1: var(----chart-1); + --color-chart-2: var(----chart-2); + --color-chart-3: var(----chart-3); + --color-chart-4: var(----chart-4); + --color-chart-5: var(----chart-5); + --color-chart-background: var(----chart-background); + --color-chart-foreground: var(----chart-foreground); + --color-chart-foreground-muted: var(----chart-foreground-muted); + --chart-line-primary: var(----chart-line-primary); + --chart-line-secondary: var(----chart-line-secondary); + --color-chart-crosshair: var(----chart-crosshair); + --color-chart-grid: var(----chart-grid); + --color-chart-tooltip-background: var(----chart-tooltip-background); + --color-chart-tooltip-foreground: var(----chart-tooltip-foreground); + --color-chart-tooltip-muted: var(----chart-tooltip-muted); + --color-chart-marker-background: var(----chart-marker-background); + --color-chart-marker-border: var(----chart-marker-border); + --color-chart-marker-foreground: var(----chart-marker-foreground); + --color-chart-ring-background: var(----chart-ring-background); + --color-chart-label: var(----chart-label); } \ No newline at end of file diff --git a/bklit-issues.md b/bklit-issues.md new file mode 100644 index 000000000..2d94e085a --- /dev/null +++ b/bklit-issues.md @@ -0,0 +1,565 @@ +# bklit-ui Issues & Patches + +Tracking upstream bugs in `@bklitui/ui` (https://ui.bklit.com / `/Users/lindesvard/Projects/bklit-ui`) that we have patched locally after `shadcn add`. Re-running `shadcn add` will regress these fixes — re-apply from this file. + +--- + +## 1. `require("react-dom")` breaks under Vite / ESM bundlers + +**Symptom** + +``` +ReferenceError: require is not defined + at ChartTooltip (chart-tooltip.tsx:146:28) +``` + +Thrown at runtime in the browser. The chart renders blank or unmounts. + +**Cause** + +Six chart components dodge SSR by lazily resolving `createPortal` with CommonJS: + +```ts +// Dynamic import to avoid SSR issues +const { createPortal } = require("react-dom") as typeof import("react-dom"); +``` + +This works under Next.js (which polyfills CommonJS in client chunks) but fails under Vite / TanStack Start where browser code stays pure ESM. The lazy resolution isn't even needed — every call site is already guarded by a `mounted` state that flips `true` only inside `useEffect`, so `createPortal` only runs on the client. + +**Affected files** (in `apps/start/src/components/charts/`) + +- `tooltip/chart-tooltip.tsx` +- `tooltip/tooltip-box.tsx` +- `x-axis.tsx` +- `y-axis.tsx` +- `bar-x-axis.tsx` +- `bar-y-axis.tsx` + +**Fix** + +Replace the lazy `require` with a top-level static import: + +```ts +import { createPortal } from "react-dom"; +``` + +…and delete the inline `const { createPortal } = require(...)` line just before each `createPortal(...)` call. The existing `mounted && container` guard above the call site already prevents SSR execution. + +**Upstream** + +Present in both the live registry (`https://ui.bklit.com/r/*.json`) and the local source (`/Users/lindesvard/Projects/bklit-ui/packages/ui/src/charts/`). Worth filing / PR'ing upstream. + +--- + +## 2. Registry lags the local `bklit-ui` source + +The shadcn registry build at `apps/web/public/r/*.json` is missing newer features (e.g. `dashFromIndex` on `` / ``, ``, ``, etc.) that exist in `packages/ui/src/charts/`. We synced these by hand: + +- `path-stroke-utils.ts` +- `dash-tail-stroke.tsx` +- `series-dash-tail-overlay.tsx` +- `series-markers.tsx` +- `series-point-marker.tsx` +- `area-gradient-defs.tsx` +- `use-line-segment-highlight.ts` +- `use-area-segment-highlight.ts` +- newer `area.tsx`, `line.tsx` + +Until the registry is rebuilt, re-running `shadcn add line-chart` (or similar) will overwrite our patched copies with the older versions and drop these helpers. Sync from local source after each re-install. + +--- + +## 3. `chart-context` cssVars overwrite OpenPanel palette in `.dark` + +When `shadcn add` injects the `chart-context` registry item, it appends bklit's grayscale `--chart-1` … `--chart-5` overrides into the `.dark { ... }` block in `styles.css`. OpenPanel's existing palette (`--chart-0` … `--chart-12` defined in `:root` only) is intentionally theme-agnostic; the bklit overrides turn `bg-chart-1`, `text-chart-2`, etc. gray in dark mode across the rest of the app (`billing-usage.tsx`, `__root.tsx`, `report-chart/common/loading.tsx`). + +The injection also collapses onto a single line, mangling the `.dark` block formatting. + +**Fix** + +After re-installing, manually remove the appended `--chart-1..5`, `--chart-background`, `--chart-foreground`, `--chart-grid`, `--chart-crosshair`, `--chart-marker-*`, `--chart-ring-background`, `--chart-label`, `--chart-foreground-muted` lines from `.dark`. Keep only: + +```css +--chart-tooltip-background: oklch(from var(--foreground) l c h / 0.92); +--chart-tooltip-foreground: var(--background); +--chart-tooltip-muted: oklch(from var(--background) l c h / 0.6); +``` + +OpenPanel's bklit tokens in `:root` map back to existing vars so a single `:root` block is enough — see the head of `apps/start/src/styles.css`. + +--- + +## 4. No dual Y-axis (`` is single-scale) + +Bklit's `ComposedChart` (and every other cartesian shell) computes a single linear Y scale from `max(all series values)`. There is no `yAxisId` / `orientation="right"` equivalent — you cannot mount a second axis with a different domain. + +This is a regression vs. recharts, which we relied on in `overview-metrics.tsx` to plot revenue (in cents, e.g. 0–5,000,000) alongside a primary metric (e.g. unique visitors, 0–1,000) on the same chart. + +**Workaround in this codebase** + +`overview-metrics.tsx` pre-scales `total_revenue` into a derived `revenue_norm` field so the bars share the primary metric's Y domain: + +```ts +const scale = (maxPrimary * 0.6) / maxRevenue; +chartData = data.map((item) => ({ + ...item, + revenue_norm: (item.total_revenue ?? 0) * scale, +})); +``` + +Bars render at `revenue_norm`; the tooltip shows the real `total_revenue` formatted as currency. Bar height is relative/lossy but visually fits the line/area without dominating. + +**Alternatives** if normalization isn't acceptable for a future chart: + +- Two stacked bklit charts sharing the X domain (height-split, one per scale). +- Render revenue in a separate widget below the primary chart. +- Patch `composed-chart.tsx` / `time-series-chart-shell.tsx` to accept a second `yScale` (non-trivial — would need a new axis component, new context fields, and changes to tooltip positioning). + +--- + +## 5. X-axis ticks don't line up with data points (default `tickMode="domain"`) + +`` defaults to `tickMode="domain"`, which places `numTicks` (default 5) evenly spaced labels across the time domain. Those positions are computed from `xScale.domain()`, not from the actual data points — so the labels rarely coincide with a real tick on the chart. For sparse / non-uniform data (monthly bars, irregular event days) this looks visually off: a label sitting between two bars, or in the middle of a gap. + +**Fix** + +Pass `tickMode="data"` on the ``: + +```tsx + +``` + +This emits one label per data row at the row's x position. Matches the recharts behavior we replaced and is the right default for almost every OpenPanel chart. + +Not yet applied — should be retrofitted onto `overview-metrics.tsx` and `overview-line-chart.tsx`, and made the default for any new chart. + +--- + +## 6. `` get clipped by the chart `` + +Bklit's `MarkerGroup` renders as SVG (``) inside the chart's own ``, **not** as an HTML portal. With marker `y=-8` and `size=24`, the circle's top edge sits at SVG y = `margin.top - 20`. Anything less than `margin.top: 20` clips the marker. + +Bumping `margin.top` works but introduces dead headroom on charts that don't have references on every render. + +**Fix (applied)** + +Patched both chart shells to render their `` with `overflow: visible`: + +- `apps/start/src/components/charts/time-series-chart-shell.tsx` +- `apps/start/src/components/charts/bar-chart.tsx` + +```tsx +