From 26d73b4c8153f8acaff55ab437b44fed5710e642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 25 May 2026 22:31:12 +0200 Subject: [PATCH 1/6] init --- apps/start/components.json | 5 +- apps/start/package.json | 13 +- apps/start/src/components/charts/animation.ts | 33 + .../src/components/charts/area-chart.tsx | 177 ++++ .../components/charts/area-gradient-defs.tsx | 88 ++ apps/start/src/components/charts/area.tsx | 321 +++++++ .../start/src/components/charts/bar-chart.tsx | 608 +++++++++++++ .../src/components/charts/bar-x-axis.tsx | 156 ++++ .../src/components/charts/bar-y-axis.tsx | 145 ++++ apps/start/src/components/charts/bar.tsx | 336 ++++++++ .../src/components/charts/chart-context.tsx | 369 ++++++++ .../start/src/components/charts/chart-defs.ts | 72 ++ .../src/components/charts/chart-formatters.ts | 31 + .../components/charts/chart-reveal-clip.tsx | 48 ++ .../src/components/charts/chart-stat-flow.tsx | 76 ++ .../src/components/charts/composed-chart.tsx | 319 +++++++ .../components/charts/dash-tail-stroke.tsx | 75 ++ apps/start/src/components/charts/grid.tsx | 149 ++++ .../src/components/charts/line-chart.tsx | 169 ++++ apps/start/src/components/charts/line.tsx | 262 ++++++ .../charts/markers/chart-markers.tsx | 214 +++++ .../src/components/charts/markers/index.ts | 12 + .../charts/markers/marker-group.tsx | 518 +++++++++++ .../src/components/charts/motion-utils.ts | 58 ++ .../src/components/charts/op-dashed-tail.ts | 81 ++ .../src/components/charts/op-date-pill.tsx | 168 ++++ .../src/components/charts/op-hover-probe.tsx | 20 + .../src/components/charts/op-marker-layer.tsx | 329 +++++++ .../src/components/charts/op-references.tsx | 68 ++ .../components/charts/op-referrer-spikes.tsx | 70 ++ .../src/components/charts/op-series-dots.tsx | 106 +++ .../charts/op-stat-hover-bridge.tsx | 37 + .../src/components/charts/op-tooltip.tsx | 469 ++++++++++ .../components/charts/path-stroke-utils.ts | 88 ++ .../src/components/charts/pattern-area.tsx | 49 ++ .../src/components/charts/series-bar.tsx | 319 +++++++ .../charts/series-dash-tail-overlay.tsx | 72 ++ .../src/components/charts/series-markers.tsx | 158 ++++ .../components/charts/series-point-marker.tsx | 181 ++++ .../charts/time-series-chart-shell.tsx | 334 ++++++++ .../charts/tooltip/chart-tooltip.tsx | 248 ++++++ .../components/charts/tooltip/date-ticker.tsx | 122 +++ .../src/components/charts/tooltip/index.ts | 14 + .../components/charts/tooltip/tooltip-box.tsx | 183 ++++ .../charts/tooltip/tooltip-content.tsx | 62 ++ .../components/charts/tooltip/tooltip-dot.tsx | 52 ++ .../charts/tooltip/tooltip-indicator.tsx | 132 +++ .../charts/use-area-segment-highlight.ts | 163 ++++ .../charts/use-chart-interaction.ts | 340 ++++++++ .../charts/use-line-segment-highlight.ts | 115 +++ .../components/charts/use-mount-progress.ts | 29 + apps/start/src/components/charts/x-axis.tsx | 183 ++++ apps/start/src/components/charts/y-axis.tsx | 105 +++ .../overview/overview-line-chart-tooltip.tsx | 153 ---- .../overview/overview-line-chart.tsx | 330 +++---- .../overview/overview-live-histogram.tsx | 293 +++---- .../overview/overview-metric-card.tsx | 346 ++++---- .../components/overview/overview-metrics.tsx | 810 +++++++----------- .../overview/overview-top-devices.tsx | 1 + .../components/overview/overview-top-geo.tsx | 1 + .../overview/overview-top-sources.tsx | 1 + .../report-chart/common/serie-icon.tsx | 60 +- apps/start/src/lib/utils.ts | 4 +- apps/start/src/styles.css | 81 +- bklit-issues.md | 565 ++++++++++++ bklit-upstream-issues.md | 341 ++++++++ packages/db/index.ts | 1 + .../src/services/referrer-spikes.service.ts | 305 +++++++ packages/trpc/src/routers/overview.ts | 36 + pnpm-lock.yaml | 330 ++++++- 70 files changed, 10997 insertions(+), 1212 deletions(-) create mode 100644 apps/start/src/components/charts/animation.ts create mode 100644 apps/start/src/components/charts/area-chart.tsx create mode 100644 apps/start/src/components/charts/area-gradient-defs.tsx create mode 100644 apps/start/src/components/charts/area.tsx create mode 100644 apps/start/src/components/charts/bar-chart.tsx create mode 100644 apps/start/src/components/charts/bar-x-axis.tsx create mode 100644 apps/start/src/components/charts/bar-y-axis.tsx create mode 100644 apps/start/src/components/charts/bar.tsx create mode 100644 apps/start/src/components/charts/chart-context.tsx create mode 100644 apps/start/src/components/charts/chart-defs.ts create mode 100644 apps/start/src/components/charts/chart-formatters.ts create mode 100644 apps/start/src/components/charts/chart-reveal-clip.tsx create mode 100644 apps/start/src/components/charts/chart-stat-flow.tsx create mode 100644 apps/start/src/components/charts/composed-chart.tsx create mode 100644 apps/start/src/components/charts/dash-tail-stroke.tsx create mode 100644 apps/start/src/components/charts/grid.tsx create mode 100644 apps/start/src/components/charts/line-chart.tsx create mode 100644 apps/start/src/components/charts/line.tsx create mode 100644 apps/start/src/components/charts/markers/chart-markers.tsx create mode 100644 apps/start/src/components/charts/markers/index.ts create mode 100644 apps/start/src/components/charts/markers/marker-group.tsx create mode 100644 apps/start/src/components/charts/motion-utils.ts create mode 100644 apps/start/src/components/charts/op-dashed-tail.ts create mode 100644 apps/start/src/components/charts/op-date-pill.tsx create mode 100644 apps/start/src/components/charts/op-hover-probe.tsx create mode 100644 apps/start/src/components/charts/op-marker-layer.tsx create mode 100644 apps/start/src/components/charts/op-references.tsx create mode 100644 apps/start/src/components/charts/op-referrer-spikes.tsx create mode 100644 apps/start/src/components/charts/op-series-dots.tsx create mode 100644 apps/start/src/components/charts/op-stat-hover-bridge.tsx create mode 100644 apps/start/src/components/charts/op-tooltip.tsx create mode 100644 apps/start/src/components/charts/path-stroke-utils.ts create mode 100644 apps/start/src/components/charts/pattern-area.tsx create mode 100644 apps/start/src/components/charts/series-bar.tsx create mode 100644 apps/start/src/components/charts/series-dash-tail-overlay.tsx create mode 100644 apps/start/src/components/charts/series-markers.tsx create mode 100644 apps/start/src/components/charts/series-point-marker.tsx create mode 100644 apps/start/src/components/charts/time-series-chart-shell.tsx create mode 100644 apps/start/src/components/charts/tooltip/chart-tooltip.tsx create mode 100644 apps/start/src/components/charts/tooltip/date-ticker.tsx create mode 100644 apps/start/src/components/charts/tooltip/index.ts create mode 100644 apps/start/src/components/charts/tooltip/tooltip-box.tsx create mode 100644 apps/start/src/components/charts/tooltip/tooltip-content.tsx create mode 100644 apps/start/src/components/charts/tooltip/tooltip-dot.tsx create mode 100644 apps/start/src/components/charts/tooltip/tooltip-indicator.tsx create mode 100644 apps/start/src/components/charts/use-area-segment-highlight.ts create mode 100644 apps/start/src/components/charts/use-chart-interaction.ts create mode 100644 apps/start/src/components/charts/use-line-segment-highlight.ts create mode 100644 apps/start/src/components/charts/use-mount-progress.ts create mode 100644 apps/start/src/components/charts/x-axis.tsx create mode 100644 apps/start/src/components/charts/y-axis.tsx delete mode 100644 apps/start/src/components/overview/overview-line-chart-tooltip.tsx create mode 100644 bklit-issues.md create mode 100644 bklit-upstream-issues.md create mode 100644 packages/db/src/services/referrer-spikes.service.ts 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..a89efdc92 --- /dev/null +++ b/apps/start/src/components/charts/area-gradient-defs.tsx @@ -0,0 +1,88 @@ +interface AreaGradientDefsProps { + gradientId: string; + strokeGradientId: string; + edgeMaskId: string; + edgeGradientId: string; + fill: string; + fillOpacity: number; + gradientToOpacity: number; + resolvedStroke: string; + isPatternFill: boolean; + fadeEdges: boolean; + innerWidth: number; + innerHeight: number; +} + +export function AreaGradientDefs({ + gradientId, + strokeGradientId, + edgeMaskId, + edgeGradientId, + fill, + fillOpacity, + gradientToOpacity, + resolvedStroke, + isPatternFill, + fadeEdges, + innerWidth, + innerHeight, +}: AreaGradientDefsProps) { + return ( + + {isPatternFill ? null : ( + + + + + )} + + + + + + + + + {fadeEdges && !isPatternFill ? ( + <> + + + + + + + + + + + ) : 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..c1abf525f --- /dev/null +++ b/apps/start/src/components/charts/area.tsx @@ -0,0 +1,321 @@ +"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 { motion, useMotionTemplate, useSpring } from "motion/react"; +import { useCallback, useId, useMemo, useRef } from "react"; +import { AreaGradientDefs } from "./area-gradient-defs"; +import { chartCssVars, useChart } from "./chart-context"; +import { ChartRevealClip } from "./chart-reveal-clip"; +import { + resolveDashTailBounds, + usePathStrokeMetrics, +} from "./path-stroke-utils"; +import { SeriesDashTailOverlay } from "./series-dash-tail-overlay"; +import { SeriesMarkers } from "./series-markers"; +import type { SeriesPointMarkerStyle } from "./series-point-marker"; +import { useLineSegmentHighlight } from "./use-line-segment-highlight"; + +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; + /** Whether to fade the area fill at left/right edges. Default: false */ + fadeEdges?: 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; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: gradient defs and fill/stroke layers share one composable entry point +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) { + const { + data, + xScale, + yScale, + innerHeight, + innerWidth, + tooltipData, + selection, + isLoaded, + enterTransition, + revealEpoch, + xAccessor, + } = useChart(); + + const pathRef = useRef(null); + const pathMetricsKey = `${data.length}:${innerWidth}:${dashFromIndex}:${showLine}`; + const { pathLength, pathD } = usePathStrokeMetrics(pathRef, pathMetricsKey); + + // Unique IDs for this area. `useId()` gives an SSR-stable string and + // skips per-render `Math.random()` / base36 work. + 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 revealClipId = `grow-clip-area-${dataKey}-${uniqueId}`; + const useRevealClip = animate && data.length > 1 && innerWidth > 0; + + 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]; + if (typeof value === "number") { + return yScale(value) ?? yScale(0) ?? 0; + } + // Missing value: fall back to the baseline (y of 0), NOT raw 0 which is + // SVG-top — see bklit-issues.md. Combined with `isDefined` below, the + // line breaks at missing points rather than drawing to baseline. + return yScale(0) ?? 0; + }, + [dataKey, yScale] + ); + + // Break the path at undefined / non-numeric values. Without this, the line + // would drop to baseline at missing points and create stray "tick" + // artifacts at chart edges where prev-period data is unavailable. + const isDefined = useCallback( + (d: Record) => typeof d[dataKey] === "number", + [dataKey] + ); + + // Use arc-length-based segment math (same as ) so the highlight + // overlay lines up with the curved stroke, not the chord between points. + const segmentBounds = useLineSegmentHighlight({ + pathLength, + data, + tooltipData, + selection, + xScale, + yScale, + xAccessor, + dataKey, + }); + + const isHovering = tooltipData !== null || selection?.active === true; + const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length); + const strokePaint = `url(#${strokeGradientId})`; + + return ( + <> + + + {/* Clip path for grow animation - unique per area */} + {useRevealClip ? ( + + + + ) : null} + + {/* Main area with clip path */} + + + {/* 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 */} + {showHighlight && + showLine && + isHovering && + isLoaded && + pathRef.current && ( + + )} + + {showMarkers ? ( + + ) : null} + + ); +} + +const highlightSpringConfig = { stiffness: 1000, damping: 60 }; + +/** + * Extracted so its position springs only initialize when the highlight is + * actually being shown — see same pattern in Line.tsx. Mounting on hover + * means the springs start at the correct segment offset instead of (0, 0) + * and the highlight doesn't briefly appear at the chart's left edge. + */ +function AreaHighlight({ + pathD, + pathLength, + segmentBounds, + stroke, + strokeWidth, +}: { + pathD: string; + pathLength: number; + segmentBounds: { startLength: number; segmentLength: number; isActive: boolean }; + stroke: string; + strokeWidth: number; +}) { + const offsetSpring = useSpring(-segmentBounds.startLength, highlightSpringConfig); + const segmentLengthSpring = useSpring( + segmentBounds.segmentLength, + highlightSpringConfig, + ); + offsetSpring.set(-segmentBounds.startLength); + segmentLengthSpring.set(segmentBounds.segmentLength); + const animatedDasharray = useMotionTemplate`${segmentLengthSpring} ${pathLength}`; + + return ( + + ); +} + +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..ba6c2e0b7 --- /dev/null +++ b/apps/start/src/components/charts/bar-chart.tsx @@ -0,0 +1,608 @@ +"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, + 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"; + +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({ + width, + height, + data, + xDataKey, + margin, + animationDuration, + animationEasing, + enterTransition, + revealSignature = "", + barGap, + barWidthProp, + orientation, + stacked, + stackGap, + children, + containerRef, +}: ChartInnerProps) { + const [tooltipData, setTooltipData] = useState(null); + const [isLoaded, setIsLoaded] = useState(false); + const [revealEpoch, setRevealEpoch] = useState(0); + const [hoveredBarIndex, setHoveredBarIndex] = useState(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 value.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + } + 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; + } + + setTooltipData({ + point: d, + index: clampedIndex, + x: tooltipX, + yPositions, + xPositions: Object.keys(xPositions).length > 0 ? xPositions : undefined, + }); + setHoveredBarIndex(clampedIndex); + }, + [ + categoryScale, + valueScale, + data, + lines, + margin.left, + margin.top, + categoryAccessor, + columnWidth, + bandWidth, + isHorizontal, + stacked, + stackGap, + ] + ); + + const handleMouseLeave = useCallback(() => { + setTooltipData(null); + setHoveredBarIndex(null); + }, []); + + // Early return if dimensions not ready + if (width < 10 || height < 10) { + return null; + } + + 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, + 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, + setHoveredBarIndex, + 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..4f3056b7f --- /dev/null +++ b/apps/start/src/components/charts/bar-x-axis.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { motion } from "motion/react"; +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "@/lib/utils"; +import { useChart } 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({ + tickerHalfWidth = 50, + showAllLabels = false, + maxLabels = 12, +}: BarXAxisProps) { + const { + margin, + tooltipData, + containerRef, + barScale, + bandWidth, + barXAccessor, + data, + } = useChart(); + const [mounted, setMounted] = useState(false); + + // Only render on client side after mount + useEffect(() => { + setMounted(true); + }, []); + + // 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; + + // Use portal to render into the chart container + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + // Early return if not in a BarChart + if (!barScale) { + return 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..6c188abf2 --- /dev/null +++ b/apps/start/src/components/charts/bar-y-axis.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { motion } from "motion/react"; +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "@/lib/utils"; +import { useChart } 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({ + showAllLabels = true, + maxLabels = 20, +}: BarYAxisProps) { + const { + margin, + containerRef, + barScale, + bandWidth, + barXAccessor, + data, + hoveredBarIndex, + } = useChart(); + const [mounted, setMounted] = useState(false); + + // Only render on client side after mount + useEffect(() => { + setMounted(true); + }, []); + + // 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, + ]); + + // Use portal to render into the chart container + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + // Early return if not in a BarChart + if (!barScale) { + return null; + } + + 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..4160c527f --- /dev/null +++ b/apps/start/src/components/charts/bar.tsx @@ -0,0 +1,336 @@ +"use client"; + +import type { Transition } from "motion/react"; +import { motion } from "motion/react"; +import { useId, useMemo } from "react"; +import { chartCssVars, useChart } from "./chart-context"; +import { transitionWithDelay } from "./motion-utils"; + +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 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 ( + + ); +} + +export function Bar({ + dataKey, + fill = chartCssVars.linePrimary, + lineCap = "round", + animate = true, + animationType = "grow", + fadedOpacity = 0.3, + staggerDelay, + stackGap = 0, + groupGap = 4, +}: BarProps) { + const { + data, + yScale, + innerHeight, + isLoaded, + barScale, + bandWidth, + hoveredBarIndex, + setHoveredBarIndex, + barXAccessor, + 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]); + + // Early return if bar scale not available (not in BarChart) + if (!(barScale && bandWidth && barXAccessor)) { + console.warn("Bar component must be used within a BarChart"); + return null; + } + + 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 ( + setHoveredBarIndex?.(i)} + onMouseLeave={() => setHoveredBarIndex?.(null)} + rx={effectiveRx} + ry={effectiveRy} + style={{ + cursor: "pointer", + }} + transition={{ + opacity: { duration: 0.15 }, + }} + width={barW} + x={x} + y={y} + /> + ); + })} + + ); +} + +Bar.displayName = "Bar"; + +export default Bar; 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..0fdbaf66d --- /dev/null +++ b/apps/start/src/components/charts/chart-context.tsx @@ -0,0 +1,369 @@ +"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; +} + +export interface ChartContextValue { + // Data + data: Record[]; + + // Scales + xScale: ScaleTime; + yScale: ScaleLinear; + + // Dimensions + width: number; + height: number; + innerWidth: number; + innerHeight: number; + margin: Margin; + + // Column width for spacing calculations + columnWidth: number; + + // Tooltip state + tooltipData: TooltipData | null; + setTooltipData: Dispatch>; + + // 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[]; + + // 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 specific (optional - only present in BarChart) + /** Band scale for categorical x-axis (bar charts) */ + barScale?: ScaleBand; + /** Width of each bar band */ + bandWidth?: number; + /** Index of currently hovered bar */ + hoveredBarIndex?: number | null; + /** Setter for hovered bar index */ + setHoveredBarIndex?: (index: number | null) => void; + /** 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>; + + // Candlestick chart specific (optional) + /** Index of currently hovered candle */ + hoveredCandleIndex?: number | null; + /** Setter for hovered candle index */ + setHoveredCandleIndex?: (index: number | null) => void; + + // 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; +} + +/** + * Hover/selection state lives in its own context so consumers that don't + * care about it (Grid, YAxis, PatternArea, etc.) can subscribe to the + * stable slice and skip re-rendering on every hover bucket change. + */ +export interface ChartHoverContextValue { + tooltipData: TooltipData | null; + setTooltipData: Dispatch>; + selection?: ChartSelection | null; + clearSelection?: () => void; + hoveredBarIndex?: number | null; + setHoveredBarIndex?: (index: number | null) => void; + hoveredCandleIndex?: number | null; + setHoveredCandleIndex?: (index: number | null) => void; +} + +export type ChartStableContextValue = Omit< + ChartContextValue, + | "tooltipData" + | "setTooltipData" + | "selection" + | "clearSelection" + | "hoveredBarIndex" + | "setHoveredBarIndex" + | "hoveredCandleIndex" + | "setHoveredCandleIndex" +>; + +const ChartContext = 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. Memoized on individual field + * identities so changing `tooltipData` doesn't bust the stable slice's + * identity — consumers of `useChartStable()` skip re-renders on hover. + */ +export function ChartProvider({ + children, + value, +}: { + children: ReactNode; + value: ChartContextValue; +}) { + const stable = useMemo( + () => ({ + data: value.data, + 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.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} + + + ); +} + +/** + * Returns the 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 = useContext(ChartContext); + const hover = useContext(ChartHoverContext); + if (!stable || !hover) { + throw new Error( + "useChart must be used within a ChartProvider. " + + "Make sure your component is wrapped in , , , or .", + ); + } + // Object spread on every call — fine because consumers destructure rather + // than identity-check the result. + return { ...stable, ...hover }; +} + +/** + * Returns only the stable chart context (scales, data, dimensions, etc.). + * Components using this hook do not re-render when the tooltip / selection + * state changes — use this whenever you don't actually need hover data. + */ +export function useChartStable(): ChartStableContextValue { + const ctx = useContext(ChartContext); + if (!ctx) { + throw new Error( + "useChartStable must be used within a ChartProvider.", + ); + } + return ctx; +} + +/** + * Returns only the volatile hover/selection context. Use this in components + * that only render hover indicators (date pill, crosshair dots, tooltip body) + * so they stay isolated from the rest of the chart subtree. + */ +export function useChartHover(): ChartHoverContextValue { + const ctx = useContext(ChartHoverContext); + if (!ctx) { + throw new Error( + "useChartHover must be used within a ChartProvider.", + ); + } + return ctx; +} + +export default ChartContext; 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..45d3db717 --- /dev/null +++ b/apps/start/src/components/charts/chart-formatters.ts @@ -0,0 +1,31 @@ +/** + * Module-scope Intl formatters. Constructing `Intl.DateTimeFormat` / + * `Intl.NumberFormat` is expensive (~10–50× slower per call than reusing one), + * and the implicit constructions hidden in `toLocaleDateString` / `toLocaleString` + * pile up fast when called inside `.map()` loops over axis ticks, tooltip rows, + * or live-chart data points. + * + * Mirrors bklit-ui's `charts/chart-formatters.ts` (issue #64). + */ + +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 +// and reuse without `this` binding loss. +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..b3d581012 --- /dev/null +++ b/apps/start/src/components/charts/composed-chart.tsx @@ -0,0 +1,319 @@ +"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 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); + lines.push({ + 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) { + lines.push({ + 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) { + lines.push({ + 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/grid.tsx b/apps/start/src/components/charts/grid.tsx new file mode 100644 index 000000000..3ee79faee --- /dev/null +++ b/apps/start/src/components/charts/grid.tsx @@ -0,0 +1,149 @@ +"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; + /** 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", + 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 && ( + + + + )} + {vertical && columnScale && typeof columnScale === "function" && ( + + + + )} + + ); +} + +Grid.displayName = "Grid"; + +export default Grid; 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..1bb5f79f5 --- /dev/null +++ b/apps/start/src/components/charts/line-chart.tsx @@ -0,0 +1,169 @@ +"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 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 }; + +function extractLineConfigs(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 LineProps | undefined; + const isLineComponent = + componentName === "Line" || + child.type === Line || + (props && typeof props.dataKey === "string" && props.dataKey.length > 0); + + if (isLineComponent && props?.dataKey) { + configs.push({ + dataKey: props.dataKey, + stroke: props.stroke || "var(--chart-line-primary)", + strokeWidth: props.strokeWidth || 2.5, + }); + } + }); + + 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..97c8665e9 --- /dev/null +++ b/apps/start/src/components/charts/line.tsx @@ -0,0 +1,262 @@ +"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 { motion, useMotionTemplate, useSpring } from "motion/react"; +import { useCallback, useId, useRef } from "react"; +import { chartCssVars, useChart } from "./chart-context"; +import { ChartRevealClip } from "./chart-reveal-clip"; +import { + resolveDashTailBounds, + usePathStrokeMetrics, +} from "./path-stroke-utils"; +import { SeriesDashTailOverlay } from "./series-dash-tail-overlay"; +import { SeriesMarkers } from "./series-markers"; +import type { SeriesPointMarkerStyle } from "./series-point-marker"; +import { useLineSegmentHighlight } from "./use-line-segment-highlight"; + +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; + /** Whether to fade edges with gradient. Default: true */ + fadeEdges?: boolean; + /** 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) { + const { + data, + xScale, + yScale, + innerHeight, + innerWidth, + tooltipData, + selection, + isLoaded, + enterTransition, + revealEpoch, + xAccessor, + } = useChart(); + + const pathRef = useRef(null); + const pathMetricsKey = `${data.length}:${innerWidth}:${dashFromIndex}:${animate}`; + const { pathLength, pathD } = usePathStrokeMetrics(pathRef, pathMetricsKey); + + // `useId()` gives an SSR-stable string and skips per-render `Math.random()` + // / base36 work that previously violated render purity. + const reactId = useId(); + const gradientId = `line-gradient-${dataKey}-${reactId}`; + + const segmentBounds = useLineSegmentHighlight({ + pathLength, + data, + tooltipData, + selection, + xScale, + yScale, + xAccessor, + dataKey, + }); + + const getY = useCallback( + (d: Record) => { + const value = d[dataKey]; + if (typeof value === "number") { + return yScale(value) ?? yScale(0) ?? 0; + } + // Missing value: fall back to the baseline (y of 0), NOT raw 0 which is + // SVG-top — see bklit-issues.md. Combined with `isDefined` below, the + // line breaks at missing points rather than drawing to baseline. + return yScale(0) ?? 0; + }, + [dataKey, yScale] + ); + + // Break the path at undefined / non-numeric values. Without this, the line + // would drop to baseline at missing points and create stray "tick" + // artifacts at chart edges where prev-period data is unavailable. + const isDefined = useCallback( + (d: Record) => typeof d[dataKey] === "number", + [dataKey] + ); + + const isHovering = tooltipData !== null || selection?.active === true; + const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length); + const lineStroke = fadeEdges ? `url(#${gradientId})` : stroke; + + return ( + <> + {fadeEdges ? ( + + + + + + + + + ) : null} + + {animate && data.length > 1 ? ( + + + + ) : null} + + 1 ? `url(#grow-clip-${dataKey})` : undefined + } + > + + xScale(xAccessor(d)) ?? 0} + y={getY} + /> + + + + + + {showMarkers ? ( + + ) : null} + + {showHighlight && isHovering && isLoaded && pathRef.current ? ( + + ) : null} + + ); +} + +const highlightSpringConfig = { stiffness: 1000, damping: 60 }; + +/** + * Extracted so its position springs only initialize when the highlight is + * actually being shown. If the springs lived on `Line` (always mounted), + * they'd init at `0` and have to animate from the chart's left edge on + * every first hover — leaving a brief stray highlight stroke at x=0. + */ +function LineHighlight({ + pathD, + pathLength, + segmentBounds, + stroke, + strokeWidth, +}: { + pathD: string; + pathLength: number; + segmentBounds: { startLength: number; segmentLength: number; isActive: boolean }; + stroke: string; + strokeWidth: number; +}) { + const offsetSpring = useSpring(-segmentBounds.startLength, highlightSpringConfig); + const segmentLengthSpring = useSpring( + segmentBounds.segmentLength, + highlightSpringConfig, + ); + offsetSpring.set(-segmentBounds.startLength); + segmentLengthSpring.set(segmentBounds.segmentLength); + const animatedDasharray = useMotionTemplate`${segmentLengthSpring} ${pathLength}`; + + return ( + + ); +} + +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..ef1c73117 --- /dev/null +++ b/apps/start/src/components/charts/markers/marker-group.tsx @@ -0,0 +1,518 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import type * as React from "react"; +import { useEffect, useRef, 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; +} + +// 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 — +// without this, adjacent clusters compete visually with the open fan. +const markerEntranceVariants = { + hidden: { + scale: 0.85, + opacity: 0, + filter: "blur(2px)", + }, + visible: { + scale: 1, + opacity: 1, + filter: "blur(0px)", + }, + fanned: { + scale: 0.85, + opacity: 0.4, + filter: "blur(0px)", + }, + muted: { + scale: 0.92, + opacity: 0.25, + 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, +}: MarkerGroupProps) { + const [isHovered, setIsHovered] = useState(false); + const shouldFan = (isHovered || forceOpen) && markers.length > 1; + const hasMultiple = markers.length > 1; + // Trim the fan to a sensible visual cap; the count badge still shows + // the true cluster size so users know more are hidden. + const fannedMarkers = + maxFanned != null && markers.length > maxFanned + ? markers.slice(0, maxFanned) + : markers; + // `animationDelay` is meant for the entrance stagger only — without this + // guard, the same delay is applied to every fan-open / fan-close + // transition, making the marker take ~animationDelay seconds to reappear + // after the user moves off a cluster. + const hasEntered = useRef(false); + useEffect(() => { + hasEntered.current = true; + }, []); + const effectiveDelay = hasEntered.current ? 0 : animationDelay; + + 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 handleMainClick = (e: React.MouseEvent) => { + // Click on the collapsed marker fires the visible item's onClick. + // For multi-item clusters, this is the top item (index 0). Fanned + // markers in the portal have their own per-item click handlers. + const handler = markers[0]?.onClick; + if (!handler) return; + e.stopPropagation(); + handler(); + }; + + 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 ( + + + + ); + })} + + +
+
, + containerRef.current + )} + + ); +} + +interface MarkerCircleProps { + icon: React.ReactNode; + size: number; + color?: string; + onClick?: () => void; + href?: string; + target?: "_blank" | "_self"; + isClickable?: boolean; + /** When true, foreignObject fills the entire circle (no 4px inset). */ + iconFill?: boolean; + /** Override stroke color. Falls back to `chartCssVars.markerBorder`. */ + borderColor?: string; + /** Stroke width in px. Default 1.5. */ + borderWidth?: number; +} + +function MarkerCircle({ + icon, + size, + color, + iconFill = false, + borderColor, + borderWidth = 1.5, +}: MarkerCircleProps) { + const inset = iconFill ? 0 : 4; + const foSize = size - inset * 2; + 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 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..44f72877c --- /dev/null +++ b/apps/start/src/components/charts/op-marker-layer.tsx @@ -0,0 +1,329 @@ +'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; +} + +// 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, +}: 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; +} + +const OPMarkerClusterRenderer = memo(function OPMarkerClusterRenderer({ + cluster, + index, + size, + showLine, +}: OPMarkerClusterRendererProps) { + const { + data, + xScale, + yScale, + xAccessor, + lines, + innerHeight, + 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, + ], + ); + + const x = xScale(anchorDate) ?? 0; + 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..611e8f7d7 --- /dev/null +++ b/apps/start/src/components/charts/path-stroke-utils.ts @@ -0,0 +1,88 @@ +import { type RefObject, useEffect, useRef, 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, + // Kept for callsite ergonomics, no longer used as a dep — see comment + // below. Removing the param would churn every caller for no benefit. + _remeasureKey?: string, +) { + const [pathLength, setPathLength] = useState(0); + const [pathD, setPathD] = useState(null); + const pathLengthRef = useRef(0); + const pathDRef = useRef(null); + + // Runs after every Line/Area render. Necessary because content-only + // changes (e.g. a filter that swaps values but keeps the bucket count) + // don't show up in any deterministic key — the old key-based remeasure + // would skip and leave `pathD` / `pathLength` stale, so SeriesDashTailOverlay + // drew the previous data's path shape after a filter remove. The ref + // comparison early-returns when nothing changed, so this is cheap + // outside of actual geometry updates. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional every-render measure + useEffect(() => { + const path = pathRef.current; + if (!path) { + return; + } + const newD = path.getAttribute("d"); + if (newD && newD !== pathDRef.current) { + pathDRef.current = newD; + setPathD(newD); + } + const newLen = path.getTotalLength(); + if (newLen > 0 && newLen !== pathLengthRef.current) { + pathLengthRef.current = newLen; + setPathLength(newLen); + } + }); + + 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.tsx b/apps/start/src/components/charts/series-bar.tsx new file mode 100644 index 000000000..7c7afb586 --- /dev/null +++ b/apps/start/src/components/charts/series-bar.tsx @@ -0,0 +1,319 @@ +"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"; + +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 slot = useMemo(() => { + if (columnWidth > 0) { + return columnWidth; + } + if (data.length < 2) { + return innerWidth; + } + return innerWidth / (data.length - 1); + }, [columnWidth, data.length, innerWidth]); + + const barWidth = useMemo(() => { + const groupCount = stacked ? 1 : n; + let w = + composedBarSize ?? + Math.min(slot * 0.88, composedMaxBarSize ?? Number.POSITIVE_INFINITY); + if (composedMaxBarSize != null) { + w = Math.min(w, composedMaxBarSize); + } + if (groupCount > 1) { + const maxGroup = slot * 0.92; + const needed = groupCount * w + (groupCount - 1) * gap; + if (needed > maxGroup && maxGroup > 0) { + w = Math.max(4, (maxGroup - (groupCount - 1) * gap) / groupCount); + } + } + return Math.max(2, w); + }, [composedBarSize, composedMaxBarSize, gap, n, slot, 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..57dd5e120 --- /dev/null +++ b/apps/start/src/components/charts/series-dash-tail-overlay.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { memo, type RefObject } from "react"; +import { DashTailStroke } from "./dash-tail-stroke"; +import { + findPathLengthAtX, + resolveDashStartX, + resolveDashTailBounds, +} from "./path-stroke-utils"; + +interface SeriesDashTailOverlayProps { + dashFromIndex?: number; + dashArray: string; + data: Record[]; + pathD: string | null; + pathLength: number; + pathRef: RefObject; + innerWidth: number; + innerHeight: number; + stroke: string; + strokeWidth: number; + xScale: (value: Date | number) => number | undefined; + xAccessor: (datum: Record) => Date | number; +} + +/** + * Wrapped in `memo` because the binary-search `findPathLengthAtX` inside is + * ~30-60 `getPointAtLength` DOM calls and was profiling at 70+ms on dense + * datasets. All inputs are stable on hover (data, pathLength, scales, + * dashFromIndex), so shallow-comparing props lets Area's per-bucket re-render + * skip this entire subtree. + */ +export const SeriesDashTailOverlay = memo(function SeriesDashTailOverlay({ + dashFromIndex, + dashArray, + data, + pathD, + pathLength, + pathRef, + innerWidth, + innerHeight, + stroke, + strokeWidth, + xScale, + xAccessor, +}: SeriesDashTailOverlayProps) { + const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length); + if (!hasDashTail || dashFromIndex == null || pathLength <= 0) { + return null; + } + + const dashStartX = resolveDashStartX(data, dashFromIndex, xScale, xAccessor); + const dashStartLength = findPathLengthAtX( + pathRef.current, + pathLength, + dashStartX + ); + + return ( + + ); +}); 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..302436ee5 --- /dev/null +++ b/apps/start/src/components/charts/series-markers.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { + clipRevealTransition, + DEFAULT_CHART_ENTER_TRANSITION, +} from "./animation"; +import { defaultScatterColors, useChart } from "./chart-context"; +import { + getSeriesMarkerVisualExtent, + SeriesPointMarker, + type SeriesPointMarkerStyle, +} 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; +} + +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) { + const { + data, + xScale, + yScale, + innerWidth, + tooltipData, + enterTransition, + animationDuration, + revealEpoch, + isLoaded, + xAccessor, + lines, + } = useChart(); + + 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 hoverEase = DEFAULT_CHART_ENTER_TRANSITION.ease; + const isRevealing = animate && !isLoaded; + + const getY = useCallback( + (d: Record) => { + const value = d[dataKey]; + return typeof value === "number" ? (yScale(value) ?? 0) : null; + }, + [dataKey, yScale] + ); + + const isHovering = tooltipData !== null; + const activeIndex = tooltipData?.index ?? -1; + + 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, + ] + ); + + return ( + + {points.map((point) => ( + + ))} + + ); +} + +SeriesMarkers.displayName = "SeriesMarkers"; + +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..ec7f4bed0 --- /dev/null +++ b/apps/start/src/components/charts/series-point-marker.tsx @@ -0,0 +1,181 @@ +"use client"; + +import type { Variants } from "motion/react"; +import { motion } from "motion/react"; +import { useMemo } 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. 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; +} + +export interface SeriesPointMarkerProps extends SeriesPointMarkerStyle { + dataKey: string; + index: number; + cx: number; + cy: number; + isActive: boolean; + isHovering: boolean; + revealDelay: number; + revealEpoch: number; + enterDuration: number; + hoverEase: typeof DEFAULT_CHART_ENTER_TRANSITION.ease; +} + +export function SeriesPointMarker({ + dataKey, + index, + cx, + cy, + isActive, + isHovering, + fadeOnHover = true, + inactiveOpacity = 0.5, + inactiveBlur = 2, + enterBlur = 2, + revealDelay, + revealEpoch, + enterDuration, + fill, + stroke, + strokeWidth = 2, + ringGap = 2, + outlineWidth = 0, + outlineColor, + radius = 5, + showActiveHighlight = true, + hoverEase, +}: SeriesPointMarkerProps) { + const resolvedStroke = stroke ?? fill ?? "currentColor"; + const resolvedOutlineColor = outlineColor ?? resolvedStroke; + + const animateState = (() => { + if (isHovering && fadeOnHover) { + return isActive ? "active" : "dimmed"; + } + return "visible"; + })(); + + // Memoized so motion doesn't re-evaluate variants on every chart re-render + // (this component fans out across every data point — hot in dense charts). + const variants = useMemo( + () => ({ + 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, + }, + }, + dimmed: { + opacity: inactiveOpacity, + filter: `blur(${inactiveBlur}px)`, + scale: 1, + transition: { duration: 0.4, ease: hoverEase }, + }, + active: { + opacity: 1, + filter: "blur(0px)", + scale: showActiveHighlight ? 1.35 : 1, + transition: { duration: 0.4, ease: hoverEase }, + }, + }), + [ + enterBlur, + revealDelay, + enterDuration, + inactiveOpacity, + inactiveBlur, + showActiveHighlight, + hoverEase, + ], + ); + + const ringOuter = strokeWidth > 0 ? radius + ringGap + strokeWidth : radius; + const outlineRadius = outlineWidth > 0 ? ringOuter + outlineWidth / 2 : 0; + + return ( + + + {outlineWidth > 0 ? ( + + ) : null} + + {strokeWidth > 0 ? ( + + ) : null} + + + ); +} + +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..12bba7382 --- /dev/null +++ b/apps/start/src/components/charts/time-series-chart-shell.tsx @@ -0,0 +1,334 @@ +'use client'; + +import { scaleLinear, scaleTime } from '@visx/scale'; +import { bisector } from 'd3-array'; +import type { Transition } from 'motion/react'; +import { + Children, + 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 { useChartInteraction } from './use-chart-interaction'; + +/** 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'; +} + +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; +} + +/** + * Outer wrapper owns the dimension guard. All scales / accessors / interaction + * hooks live in the memoized core — so a hidden chart (width < 10) builds zero + * d3 scales, runs zero useMemos, and never instantiates useChartInteraction. + * When the core does mount, the memo() boundary skips re-renders when the + * parent passes the same props (common case for static dashboard panels). + */ +export function TimeSeriesChartInner(props: TimeSeriesChartInnerProps) { + if (props.width < 10 || props.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: _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 dates = data.map((d) => xAccessor(d)); + const minTime = Math.min(...dates.map((d) => d.getTime())); + const maxTime = Math.max(...dates.map((d) => d.getTime())); + + return scaleTime({ + range: [0, innerWidth], + domain: [minTime, maxTime], + }); + }, [innerWidth, data, xAccessor]); + + const columnWidth = useMemo(() => { + if (data.length < 2) { + return 0; + } + return innerWidth / (data.length - 1); + }, [innerWidth, data.length]); + + const yScale = useMemo(() => { + let maxValue = 0; + if (yScaleDomainMax != null && yScaleDomainMax > 0) { + maxValue = yScaleDomainMax; + } else { + for (const line of lines) { + for (const d of data) { + const value = d[line.dataKey]; + if (typeof value === 'number' && value > maxValue) { + maxValue = value; + } + } + } + + if (maxValue === 0) { + maxValue = 100; + } + } + + return scaleLinear({ + range: [innerHeight, 0], + domain: [0, maxValue * 1.1], + 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, preOverlayChildren, postOverlayChildren } = + useMemo(() => { + const defs: ReactElement[] = []; + const pre: ReactElement[] = []; + const post: ReactElement[] = []; + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + if (isGradientDefComponent(child)) { + defs.push(child); + } else if (isPatternDefComponent(child)) { + // Keep pattern defs in the plot (same as main) — hoisting breaks url(#id) fills. + pre.push(child); + } else if (isPostOverlayComponent(child)) { + post.push(child); + } else { + pre.push(child); + } + }); + return { + defsChildren: defs, + preOverlayChildren: pre, + postOverlayChildren: post, + }; + }, [children]); + + // Memoize the context value so identity is stable when its contents don't + // change. Without this, every render allocates a fresh object and forces + // all useContext(ChartContext) consumers (Line, Area, OPReferences, etc.) + // to re-render even when no actual state moved. + const contextValue = useMemo( + () => ({ + data, + 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, + 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, + ] + ); + + 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..dffa2e929 --- /dev/null +++ b/apps/start/src/components/charts/tooltip/chart-tooltip.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import { memo, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { chartCssVars, useChart } 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"; + +// Spring config for crosshair +const crosshairSpringConfig = { stiffness: 300, damping: 30 }; + +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[]; + /** Additional content to show below rows (e.g., markers) */ + children?: React.ReactNode; + /** Custom class name */ + className?: string; +} + +/** + * Outer wrapper owns the mount + container guard. The expensive work + * (useSpring, three useMemos for tooltipRows / indicatorColor / title) lives + * in the memoized inner — so none of it runs before the chart container is + * attached, and the inner can skip re-renders when its props are unchanged. + */ +export function ChartTooltip(props: ChartTooltipProps) { + const { containerRef } = useChart(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + return ; +} + +const ChartTooltipInner = memo(function ChartTooltipInner({ + showDatePill = true, + showCrosshair = true, + showDots = true, + indicatorColor: indicatorColorProp, + content, + rows: rowsRenderer, + children, + className = "", + container, +}: ChartTooltipProps & { container: HTMLElement }) { + const { + tooltipData, + width, + height, + innerHeight, + margin, + columnWidth, + lines, + xAccessor, + dateLabels, + containerRef, + orientation, + barXAccessor, + } = useChart(); + + const isHorizontal = orientation === "horizontal"; + + 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; + + // Animated crosshair position + const animatedX = useSpring(xWithMargin, crosshairSpringConfig); + + animatedX.set(xWithMargin); + + // Generate rows from lines + 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]); + + // 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 */} + {showDatePill && dateLabels.length > 0 && visible && !isHorizontal && ( + + + + )} + + ); + + return createPortal(tooltipContent, container); +}); + +ChartTooltip.displayName = "ChartTooltip"; + +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..1bf9ff98b --- /dev/null +++ b/apps/start/src/components/charts/tooltip/date-ticker.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import { useMemo, useRef } from "react"; + +const TICKER_ITEM_HEIGHT = 24; + +export interface DateTickerProps { + currentIndex: number; + labels: string[]; + visible: boolean; +} + +export function DateTicker({ currentIndex, labels, visible }: DateTickerProps) { + // 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; + } + } + + if (!visible || labels.length === 0) { + return null; + } + + return ( +
+
+
+ {/* Month stack */} +
+ + {monthSegments.map((segment) => ( +
+ + {segment.month} + +
+ ))} +
+
+ + {/* Day stack */} +
+ + {parsedLabels.map((label) => ( +
+ + {label.day} + +
+ ))} +
+
+
+
+
+ ); +} + +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..fe25c5774 --- /dev/null +++ b/apps/start/src/components/charts/tooltip/tooltip-box.tsx @@ -0,0 +1,183 @@ +"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"; + +// Near-instant — original 100/20 felt sluggish. +const springConfig = { stiffness: 1000, damping: 60 }; + +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; +} + +/** + * Outer wrapper owns the mount + visibility guards. The inner component + * (which owns the position springs) only mounts when the tooltip is + * actually being shown, so `useSpring` initializes at the cursor's actual + * x/y instead of at (0, 0) — without this split the tooltip would slide + * in from the top-left of the chart on every 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, + container, +}: Omit & { + container: HTMLElement; +}) { + 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) + ); + + // Init springs at the *current* target — this component only mounts when + // the tooltip is visible, so target values are real cursor coords. + const animatedLeft = useSpring(targetX, springConfig); + const animatedTop = useSpring(targetY, springConfig); + + 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..7bdc116f4 --- /dev/null +++ b/apps/start/src/components/charts/tooltip/tooltip-dot.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import { chartCssVars } from "../chart-context"; + +// Near-instant — original 300/30 felt sluggish snapping between data points. +const crosshairSpringConfig = { stiffness: 1000, damping: 60 }; + +export interface TooltipDotProps { + x: number; + y: number; + visible: boolean; + color: string; + size?: number; + strokeColor?: string; + strokeWidth?: number; +} + +export function TooltipDot({ + x, + y, + visible, + color, + size = 5, + strokeColor = chartCssVars.background, + strokeWidth = 2, +}: TooltipDotProps) { + const animatedX = useSpring(x, crosshairSpringConfig); + const animatedY = useSpring(y, crosshairSpringConfig); + + 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..c788a01ec --- /dev/null +++ b/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import { chartCssVars } from "../chart-context"; + +// Near-instant — original 300/30 felt sluggish snapping between data points. +const crosshairSpringConfig = { stiffness: 1000, damping: 60 }; + +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; + /** Unique ID for the gradient */ + gradientId?: string; +} + +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; + } +} + +/** + * Visibility guard lives in the outer wrapper. Without it, the inner + * component (and its `useSpring`) would mount on first render when + * `tooltipData` is still null (so `x` is 0) and the spring would + * initialize at the chart's left edge. On the user's first hover the line + * would have to spring all the way across to the cursor — a 1px line + * moving at high stiffness is essentially invisible, so users perceive + * "the crosshair didn't appear". Mounting on visibility = spring + * initializes at the correct cursor position. (Same pattern as OPDot.) + */ +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, + gradientId = "tooltip-indicator-gradient", +}: TooltipIndicatorProps) { + const pixelWidth = + span !== undefined && columnWidth !== undefined + ? span * columnWidth + : resolveWidth(width); + + const animatedX = useSpring(x - pixelWidth / 2, crosshairSpringConfig); + + animatedX.set(x - pixelWidth / 2); + + const edgeOpacity = fadeEdges ? 0 : 1; + + return ( + + + + + + + + + + + + + ); +} + +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..c297a94a0 --- /dev/null +++ b/apps/start/src/components/charts/use-chart-interaction.ts @@ -0,0 +1,340 @@ +"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"; + +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 [tooltipData, setTooltipData] = useState(null); + const [selection, setSelection] = useState(null); + + const isDraggingRef = useRef(false); + const dragStartXRef = useRef(0); + // Tracks the data-bucket index currently shown in the tooltip. The chart + // snaps tooltips to data points, so mouse motion within the same bucket + // produces identical tooltip output — coalescing identical-bucket events + // here eliminates ~95% of state updates during hover and stops the entire + // chart subtree from re-rendering on every pixel of mouse motion. + const lastTooltipIndexRef = useRef(-1); + const commitTooltip = useCallback( + (next: TooltipData | null) => { + if (next === null) { + if (lastTooltipIndexRef.current !== -1) { + lastTooltipIndexRef.current = -1; + setTooltipData(null); + } + return; + } + if (next.index === lastTooltipIndexRef.current) return; + lastTooltipIndexRef.current = next.index; + setTooltipData(next); + }, + [], + ); + + 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] + ); + + // --- Mouse handlers --- + + 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) { + commitTooltip(tooltip); + } + }, + [getChartX, resolveTooltipFromX, resolveIndexFromX, commitTooltip] + ); + + const handleMouseLeave = useCallback(() => { + commitTooltip(null); + if (isDraggingRef.current) { + isDraggingRef.current = false; + } + setSelection(null); + }, [commitTooltip]); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + const chartX = getChartX(event); + if (chartX === null) { + return; + } + isDraggingRef.current = true; + dragStartXRef.current = chartX; + commitTooltip(null); + setSelection(null); + }, + [getChartX, commitTooltip] + ); + + const handleMouseUp = useCallback(() => { + if (isDraggingRef.current) { + isDraggingRef.current = false; + } + setSelection(null); + }, []); + + // --- Touch handlers --- + + 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) { + commitTooltip(tooltip); + } + } else if (event.touches.length === 2) { + event.preventDefault(); + commitTooltip(null); + 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] + ); + + 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) { + commitTooltip(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] + ); + + const handleTouchEnd = useCallback(() => { + commitTooltip(null); + setSelection(null); + }, [commitTooltip]); + + 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-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/x-axis.tsx b/apps/start/src/components/charts/x-axis.tsx new file mode 100644 index 000000000..52f316ff2 --- /dev/null +++ b/apps/start/src/components/charts/x-axis.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { memo, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "@/lib/utils"; +import { shortDateFmt } from "./chart-formatters"; +import { useChart } from "./chart-context"; + +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} + +
+ ); +} + +/** + * Outer wrapper owns the mount guard. The expensive `labelsToShow` memo + * (which iterates `data` or builds `numTicks` ticks) lives in the memoized + * inner component — so it doesn't run on every render before the portal + * container is attached, and skips when props haven't changed. + */ +export function XAxis({ + numTicks = 5, + tickerHalfWidth = 50, + tickMode = "domain", +}: XAxisProps) { + const { containerRef } = useChart(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + return ( + + ); +} + +const XAxisInner = memo(function XAxisInner({ + container, + numTicks, + tickMode, + tickerHalfWidth, +}: { + container: HTMLElement; + numTicks: number; + tickMode: "domain" | "data"; + tickerHalfWidth: number; +}) { + 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; + + const tickCount = Math.max(2, numTicks); + const dates: Date[] = []; + + for (let i = 0; i < tickCount; i++) { + const t = i / (tickCount - 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..1a369d520 --- /dev/null +++ b/apps/start/src/components/charts/y-axis.tsx @@ -0,0 +1,105 @@ +"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); +} + +/** + * Outer wrapper owns the mount guard; the tick-generation memo runs only + * once the portal container is attached and skips entirely when the inner + * props are unchanged. + */ +export function YAxis({ + numTicks = 5, + formatLargeNumbers = true, + formatValue, +}: 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({ + container, + numTicks, + formatLargeNumbers, + formatValue, +}: { + container: HTMLElement; + numTicks: number; + formatLargeNumbers: boolean; + formatValue?: (value: number) => string; +}) { + 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..30f84d909 100644 --- a/apps/start/src/components/overview/overview-line-chart.tsx +++ b/apps/start/src/components/overview/overview-line-chart.tsx @@ -1,22 +1,20 @@ -import { useNumber } from '@/hooks/use-numer-formatter'; import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; +import { curveMonotoneX } from '@visx/curve'; 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 { IInterval } from '@openpanel/validation'; -import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis'; +import type { timeWindows } from '@openpanel/constants'; +import { Grid } from '../charts/grid'; +import { Line } from '../charts/line'; +import { LineChart } from '../charts/line-chart'; +import { OPChartTooltip } from '../charts/op-tooltip'; +import { OPDatePill } from '../charts/op-date-pill'; +import { useDashedTail } from '../charts/op-dashed-tail'; +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'; type SeriesData = RouterOutputs['overview']['topGenericSeries']['items'][number]; @@ -24,110 +22,91 @@ 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], + ); + + const dashFromIndex = useDashedTail({ data: chartData, range, interval }); - if (visibleItems.length === 0) { + if (visibleSeries.length === 0) { 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) => ( + + ))} + + + interval={interval} + showDots + showCrosshair + showDatePill={false} + 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 }, + ], + })); + }} + extra={(point) => { + 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'} +
+ ); + }} + /> +
- {/* 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); @@ -220,14 +253,12 @@ 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 */}
>({ + 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..8da3200ca 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..242a84658 100644 --- a/apps/start/src/components/overview/overview-metrics.tsx +++ b/apps/start/src/components/overview/overview-metrics.tsx @@ -1,44 +1,39 @@ -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)'; +const PREV_LINE_COLOR = 'oklch(from var(--foreground) l c h / 0.2)'; + const TITLES = [ { title: 'Unique Visitors', @@ -103,164 +98,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 +223,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 +238,213 @@ 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 && ( + + )} + + {/* Previous-period line (rendered behind the current area). */} + + + {/* Current-period area */} + + + + + {/* Tooltip needs the flat list of spikes (so it can match the hovered + bucket to any spike, not just cluster anchors). */} + + + + + + + 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..8c6c03ca2 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,12 @@ 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); } @theme { @@ -151,7 +184,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 +199,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 +267,7 @@ code { .subtitle { @apply text-base font-medium; } - + .card { @apply rounded-md border border-border bg-card; } @@ -272,7 +305,6 @@ button { @apply cursor-pointer; } - .hide-scrollbar::-webkit-scrollbar { display: none; } @@ -452,4 +484,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 + - {showMarkers ? ( - - ) : null} - - {showHighlight && isHovering && isLoaded && pathRef.current ? ( - - ) : null} + )} ); } -const highlightSpringConfig = { stiffness: 1000, damping: 60 }; - -/** - * Extracted so its position springs only initialize when the highlight is - * actually being shown. If the springs lived on `Line` (always mounted), - * they'd init at `0` and have to animate from the chart's left edge on - * every first hover — leaving a brief stray highlight stroke at x=0. - */ -function LineHighlight({ - pathD, - pathLength, - segmentBounds, - stroke, - strokeWidth, -}: { - pathD: string; - pathLength: number; - segmentBounds: { startLength: number; segmentLength: number; isActive: boolean }; - stroke: string; - strokeWidth: number; -}) { - const offsetSpring = useSpring(-segmentBounds.startLength, highlightSpringConfig); - const segmentLengthSpring = useSpring( - segmentBounds.segmentLength, - highlightSpringConfig, - ); - offsetSpring.set(-segmentBounds.startLength); - segmentLengthSpring.set(segmentBounds.segmentLength); - const animatedDasharray = useMotionTemplate`${segmentLengthSpring} ${pathLength}`; - - return ( - - ); -} - Line.displayName = "Line"; export default Line; diff --git a/apps/start/src/components/charts/markers/marker-group.tsx b/apps/start/src/components/charts/markers/marker-group.tsx index ef1c73117..f87d1e7dc 100644 --- a/apps/start/src/components/charts/markers/marker-group.tsx +++ b/apps/start/src/components/charts/markers/marker-group.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion } from "motion/react"; import type * as React from "react"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; import { chartCssVars } from "../chart-context"; @@ -59,45 +59,9 @@ export interface MarkerGroupProps { 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; } -// 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 — -// without this, adjacent clusters compete visually with the open fan. +// Entrance animation variants const markerEntranceVariants = { hidden: { scale: 0.85, @@ -109,16 +73,6 @@ const markerEntranceVariants = { opacity: 1, filter: "blur(0px)", }, - fanned: { - scale: 0.85, - opacity: 0.4, - filter: "blur(0px)", - }, - muted: { - scale: 0.92, - opacity: 0.25, - filter: "blur(0px)", - }, }; export function MarkerGroup({ @@ -135,31 +89,10 @@ export function MarkerGroup({ animate = true, lineHeight = 0, showLine = true, - forceOpen = false, - iconFill = false, - borderColor, - borderWidth = 1.5, - maxFanned, - isMuted = false, }: MarkerGroupProps) { const [isHovered, setIsHovered] = useState(false); - const shouldFan = (isHovered || forceOpen) && markers.length > 1; + const shouldFan = isHovered && markers.length > 1; const hasMultiple = markers.length > 1; - // Trim the fan to a sensible visual cap; the count badge still shows - // the true cluster size so users know more are hidden. - const fannedMarkers = - maxFanned != null && markers.length > maxFanned - ? markers.slice(0, maxFanned) - : markers; - // `animationDelay` is meant for the entrance stagger only — without this - // guard, the same delay is applied to every fan-open / fan-close - // transition, making the marker take ~animationDelay seconds to reappear - // after the user moves off a cluster. - const hasEntered = useRef(false); - useEffect(() => { - hasEntered.current = true; - }, []); - const effectiveDelay = hasEntered.current ? 0 : animationDelay; const getCirclePosition = (index: number, total: number) => { const startAngle = -90 - FAN_ANGLE / 2; @@ -189,16 +122,6 @@ export function MarkerGroup({ e.stopPropagation(); // Prevent chart crosshair from moving while hovering markers }; - const handleMainClick = (e: React.MouseEvent) => { - // Click on the collapsed marker fires the visible item's onClick. - // For multi-item clusters, this is the top item (index 0). Fanned - // markers in the portal have their own per-item click handlers. - const handler = markers[0]?.onClick; - if (!handler) return; - e.stopPropagation(); - handler(); - }; - const portalX = x + marginLeft; const portalY = y + marginTop; @@ -237,22 +160,19 @@ export function MarkerGroup({ {/* Interactive marker group */} {/* biome-ignore lint/a11y/noStaticElementInteractions: Chart marker interaction */} @@ -267,11 +187,8 @@ export function MarkerGroup({ {/* Main marker */} @@ -339,11 +256,8 @@ export function MarkerGroup({ > {shouldFan && - fannedMarkers.map((marker, index) => { - const position = getCirclePosition( - index, - fannedMarkers.length, - ); + markers.map((marker, index) => { + const position = getCirclePosition(index, markers.length); return ( + + {shouldFan && ( + +
+ + )} +
, containerRef.current @@ -402,24 +335,9 @@ interface MarkerCircleProps { href?: string; target?: "_blank" | "_self"; isClickable?: boolean; - /** When true, foreignObject fills the entire circle (no 4px inset). */ - iconFill?: boolean; - /** Override stroke color. Falls back to `chartCssVars.markerBorder`. */ - borderColor?: string; - /** Stroke width in px. Default 1.5. */ - borderWidth?: number; } -function MarkerCircle({ - icon, - size, - color, - iconFill = false, - borderColor, - borderWidth = 1.5, -}: MarkerCircleProps) { - const inset = iconFill ? 0 : 4; - const foSize = size - inset * 2; +function MarkerCircle({ icon, size, color }: MarkerCircleProps) { return ( @@ -428,14 +346,14 @@ function MarkerCircle({ cy={0} fill={color || chartCssVars.markerBackground} r={size / 2} - stroke={borderColor || chartCssVars.markerBorder} - strokeWidth={borderWidth} + stroke={chartCssVars.markerBorder} + strokeWidth={1.5} />
{icon} @@ -465,9 +381,6 @@ function MarkerCircleHTML({ href, target = "_self", isClickable = false, - iconFill = false, - borderColor, - borderWidth = 1.5, }: MarkerCircleProps) { const hasAction = isClickable || onClick || href; @@ -494,11 +407,9 @@ function MarkerCircleHTML({ onClick={hasAction ? handleClick : undefined} style={{ backgroundColor: color || chartCssVars.markerBackground, - border: `${borderWidth}px solid ${borderColor || chartCssVars.markerBorder}`, + border: `1.5px solid ${chartCssVars.markerBorder}`, fontSize: size * 0.5, color: chartCssVars.markerForeground, - overflow: "hidden", - padding: iconFill ? 0 : undefined, }} transition={{ type: "spring", stiffness: 400, damping: 17 }} whileHover={ diff --git a/apps/start/src/components/charts/pattern-area.tsx b/apps/start/src/components/charts/pattern-area.tsx index 99a317c81..f15dc5257 100644 --- a/apps/start/src/components/charts/pattern-area.tsx +++ b/apps/start/src/components/charts/pattern-area.tsx @@ -2,7 +2,7 @@ import { curveMonotoneX } from "@visx/curve"; import { AreaClosed } from "@visx/shape"; -import { useChartStable } from "./chart-context"; +import { useChart } from "./chart-context"; // biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type type CurveFactory = any; @@ -27,7 +27,7 @@ export function PatternArea({ fill, curve = curveMonotoneX, }: PatternAreaProps) { - const { data, xScale, yScale, xAccessor } = useChartStable(); + const { data, xScale, yScale, xAccessor } = useChart(); return (
- {typeof row.value === "number" ? intFmt(row.value) : row.value} + {typeof row.value === "number" + ? row.value.toLocaleString() + : row.value}
))} diff --git a/apps/start/src/components/charts/tooltip/tooltip-dot.tsx b/apps/start/src/components/charts/tooltip/tooltip-dot.tsx index 7bdc116f4..4ac2ddb4b 100644 --- a/apps/start/src/components/charts/tooltip/tooltip-dot.tsx +++ b/apps/start/src/components/charts/tooltip/tooltip-dot.tsx @@ -3,8 +3,8 @@ import { motion, useSpring } from "motion/react"; import { chartCssVars } from "../chart-context"; -// Near-instant — original 300/30 felt sluggish snapping between data points. -const crosshairSpringConfig = { stiffness: 1000, damping: 60 }; +// Faster spring to stay in sync with indicator +const crosshairSpringConfig = { stiffness: 300, damping: 30 }; export interface TooltipDotProps { x: number; diff --git a/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx b/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx index c788a01ec..83e3835b8 100644 --- a/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx +++ b/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx @@ -3,8 +3,8 @@ import { motion, useSpring } from "motion/react"; import { chartCssVars } from "../chart-context"; -// Near-instant — original 300/30 felt sluggish snapping between data points. -const crosshairSpringConfig = { stiffness: 1000, damping: 60 }; +// Faster spring for crosshair - responsive to mouse movement +const crosshairSpringConfig = { stiffness: 300, damping: 30 }; export type IndicatorWidth = | number // Pixel width @@ -60,26 +60,10 @@ function resolveWidth(width: IndicatorWidth): number { } } -/** - * Visibility guard lives in the outer wrapper. Without it, the inner - * component (and its `useSpring`) would mount on first render when - * `tooltipData` is still null (so `x` is 0) and the spring would - * initialize at the chart's left edge. On the user's first hover the line - * would have to spring all the way across to the cursor — a 1px line - * moving at high stiffness is essentially invisible, so users perceive - * "the crosshair didn't appear". Mounting on visibility = spring - * initializes at the correct cursor position. (Same pattern as OPDot.) - */ -export function TooltipIndicator(props: TooltipIndicatorProps) { - if (!props.visible) { - return null; - } - return ; -} - -function TooltipIndicatorInner({ +export function TooltipIndicator({ x, height, + visible, width = "line", span, columnWidth, @@ -97,6 +81,10 @@ function TooltipIndicatorInner({ animatedX.set(x - pixelWidth / 2); + if (!visible) { + return null; + } + const edgeOpacity = fadeEdges ? 0 : 1; return ( diff --git a/apps/start/src/components/charts/use-chart-interaction.ts b/apps/start/src/components/charts/use-chart-interaction.ts index c297a94a0..5e5eddc1a 100644 --- a/apps/start/src/components/charts/use-chart-interaction.ts +++ b/apps/start/src/components/charts/use-chart-interaction.ts @@ -63,27 +63,6 @@ export function useChartInteraction({ const isDraggingRef = useRef(false); const dragStartXRef = useRef(0); - // Tracks the data-bucket index currently shown in the tooltip. The chart - // snaps tooltips to data points, so mouse motion within the same bucket - // produces identical tooltip output — coalescing identical-bucket events - // here eliminates ~95% of state updates during hover and stops the entire - // chart subtree from re-rendering on every pixel of mouse motion. - const lastTooltipIndexRef = useRef(-1); - const commitTooltip = useCallback( - (next: TooltipData | null) => { - if (next === null) { - if (lastTooltipIndexRef.current !== -1) { - lastTooltipIndexRef.current = -1; - setTooltipData(null); - } - return; - } - if (next.index === lastTooltipIndexRef.current) return; - lastTooltipIndexRef.current = next.index; - setTooltipData(next); - }, - [], - ); const resolveTooltipFromX = useCallback( (pixelX: number): TooltipData | null => { @@ -199,19 +178,19 @@ export function useChartInteraction({ const tooltip = resolveTooltipFromX(chartX); if (tooltip) { - commitTooltip(tooltip); + setTooltipData(tooltip); } }, - [getChartX, resolveTooltipFromX, resolveIndexFromX, commitTooltip] + [getChartX, resolveTooltipFromX, resolveIndexFromX] ); const handleMouseLeave = useCallback(() => { - commitTooltip(null); + setTooltipData(null); if (isDraggingRef.current) { isDraggingRef.current = false; } setSelection(null); - }, [commitTooltip]); + }, []); const handleMouseDown = useCallback( (event: React.MouseEvent) => { @@ -221,10 +200,10 @@ export function useChartInteraction({ } isDraggingRef.current = true; dragStartXRef.current = chartX; - commitTooltip(null); + setTooltipData(null); setSelection(null); }, - [getChartX, commitTooltip] + [getChartX] ); const handleMouseUp = useCallback(() => { @@ -246,11 +225,11 @@ export function useChartInteraction({ } const tooltip = resolveTooltipFromX(chartX); if (tooltip) { - commitTooltip(tooltip); + setTooltipData(tooltip); } } else if (event.touches.length === 2) { event.preventDefault(); - commitTooltip(null); + setTooltipData(null); const x0 = getChartX(event, 0); const x1 = getChartX(event, 1); if (x0 === null || x1 === null) { @@ -280,7 +259,7 @@ export function useChartInteraction({ } const tooltip = resolveTooltipFromX(chartX); if (tooltip) { - commitTooltip(tooltip); + setTooltipData(tooltip); } } else if (event.touches.length === 2) { event.preventDefault(); @@ -304,9 +283,9 @@ export function useChartInteraction({ ); const handleTouchEnd = useCallback(() => { - commitTooltip(null); + setTooltipData(null); setSelection(null); - }, [commitTooltip]); + }, []); const clearSelection = useCallback(() => { setSelection(null); diff --git a/apps/start/src/components/charts/x-axis.tsx b/apps/start/src/components/charts/x-axis.tsx index 52f316ff2..c188bc7e7 100644 --- a/apps/start/src/components/charts/x-axis.tsx +++ b/apps/start/src/components/charts/x-axis.tsx @@ -1,10 +1,9 @@ -"use client"; +'use client'; -import { memo, useEffect, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; -import { cn } from "@/lib/utils"; -import { shortDateFmt } from "./chart-formatters"; -import { useChart } from "./chart-context"; +import { useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useChart } from './chart-context'; +import { cn } from '@/lib/utils'; export interface XAxisProps { /** Number of ticks to show (including first and last). Default: 5. Used when `tickMode` is `"domain"`. */ @@ -15,7 +14,7 @@ export interface XAxisProps { * `"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"; + tickMode?: 'domain' | 'data'; } interface XAxisLabelProps { @@ -56,15 +55,15 @@ function XAxisLabel({ left: x, bottom: 12, width: 0, - display: "flex", - justifyContent: "center", + display: 'flex', + justifyContent: 'center', }} > {label} @@ -73,60 +72,39 @@ function XAxisLabel({ ); } -/** - * Outer wrapper owns the mount guard. The expensive `labelsToShow` memo - * (which iterates `data` or builds `numTicks` ticks) lives in the memoized - * inner component — so it doesn't run on every render before the portal - * container is attached, and skips when props haven't changed. - */ export function XAxis({ numTicks = 5, tickerHalfWidth = 50, - tickMode = "domain", + tickMode = 'domain', }: XAxisProps) { - const { containerRef } = useChart(); + const { + xScale, + margin, + tooltipData, + containerRef, + data, + xAccessor, + dateLabels, + } = useChart(); const [mounted, setMounted] = useState(false); + // Only render on client side after mount useEffect(() => { setMounted(true); }, []); - const container = containerRef.current; - if (!(mounted && container)) { - return null; - } - - return ( - - ); -} - -const XAxisInner = memo(function XAxisInner({ - container, - numTicks, - tickMode, - tickerHalfWidth, -}: { - container: HTMLElement; - numTicks: number; - tickMode: "domain" | "data"; - tickerHalfWidth: number; -}) { - 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") { + 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)), + label: + dateLabels[i] ?? + xAccessor(d).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }), })); } @@ -142,11 +120,12 @@ const XAxisInner = memo(function XAxisInner({ const endTime = endDate.getTime(); const timeRange = endTime - startTime; - const tickCount = Math.max(2, numTicks); + // 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); + const t = i / (tickCount - 1); // 0 to 1 const time = startTime + t * timeRange; dates.push(new Date(time)); } @@ -154,13 +133,23 @@ const XAxisInner = memo(function XAxisInner({ return dates.map((date) => ({ date, x: (xScale(date) ?? 0) + margin.left, - label: shortDateFmt.format(date), + label: date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }), })); }, [tickMode, data, xAccessor, xScale, margin.left, dateLabels, numTicks]); const isHovering = tooltipData !== null; const crosshairX = tooltipData ? tooltipData.x + margin.left : null; + // Use portal to render into the chart container + // Only render after mount on client side + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + return createPortal(
{labelsToShow.map((item) => ( @@ -176,8 +165,8 @@ const XAxisInner = memo(function XAxisInner({
, container ); -}); +} -XAxis.displayName = "XAxis"; +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 index 1a369d520..992a3bd8e 100644 --- a/apps/start/src/components/charts/y-axis.tsx +++ b/apps/start/src/components/charts/y-axis.tsx @@ -1,8 +1,8 @@ -"use client"; +'use client'; -import { memo, useEffect, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; -import { useChartStable } from "./chart-context"; +import { useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useChart } from './chart-context'; export interface YAxisProps { /** Number of ticks to show. Default: 5 */ @@ -27,51 +27,18 @@ function formatLabel( return String(value); } -/** - * Outer wrapper owns the mount guard; the tick-generation memo runs only - * once the portal container is attached and skips entirely when the inner - * props are unchanged. - */ export function YAxis({ numTicks = 5, formatLargeNumbers = true, formatValue, }: YAxisProps) { - const { containerRef } = useChartStable(); + const { yScale, margin, containerRef } = useChart(); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); - const container = containerRef.current; - if (!(mounted && container)) { - return null; - } - - return ( - - ); -} - -const YAxisInner = memo(function YAxisInner({ - container, - numTicks, - formatLargeNumbers, - formatValue, -}: { - container: HTMLElement; - numTicks: number; - formatLargeNumbers: boolean; - formatValue?: (value: number) => string; -}) { - const { yScale, margin } = useChartStable(); - const ticks = useMemo(() => { const tickValues = yScale.ticks(numTicks); return tickValues.map((value) => ({ @@ -81,6 +48,11 @@ const YAxisInner = memo(function YAxisInner({ })); }, [yScale, margin.top, numTicks, formatLargeNumbers, formatValue]); + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + return createPortal(
{tick.label}
@@ -98,8 +70,8 @@ const YAxisInner = memo(function YAxisInner({
, container ); -}); +} -YAxis.displayName = "YAxis"; +YAxis.displayName = 'YAxis'; export default YAxis; diff --git a/apps/start/src/styles.css b/apps/start/src/styles.css index 8c6c03ca2..2b5731eea 100644 --- a/apps/start/src/styles.css +++ b/apps/start/src/styles.css @@ -133,6 +133,21 @@ code { --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 { From f2072c3ea6accb125fa32f3d28c31fc49654b742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 26 May 2026 08:57:27 +0200 Subject: [PATCH 3/6] performance changes --- apps/start/src/components/charts/area.tsx | 2 +- .../src/components/charts/chart-context.tsx | 235 +++++++++++++----- apps/start/src/components/charts/grid.tsx | 4 +- apps/start/src/components/charts/line.tsx | 142 +++-------- .../charts/markers/marker-group.tsx | 118 +++++++-- .../src/components/charts/pattern-area.tsx | 4 +- .../charts/time-series-chart-shell.tsx | 119 ++++++--- .../charts/tooltip/chart-tooltip.tsx | 58 +++-- .../components/charts/tooltip/tooltip-box.tsx | 61 +++-- .../components/charts/tooltip/tooltip-dot.tsx | 4 +- .../charts/tooltip/tooltip-indicator.tsx | 27 +- apps/start/src/components/charts/x-axis.tsx | 66 +++-- apps/start/src/components/charts/y-axis.tsx | 46 +++- .../components/overview/overview-metrics.tsx | 63 ++--- 14 files changed, 605 insertions(+), 344 deletions(-) diff --git a/apps/start/src/components/charts/area.tsx b/apps/start/src/components/charts/area.tsx index 6e33c756b..38d79def3 100644 --- a/apps/start/src/components/charts/area.tsx +++ b/apps/start/src/components/charts/area.tsx @@ -202,7 +202,7 @@ export function Area({ ]); // Springs for smooth highlight animation (both offset AND segment length) - const springConfig = { stiffness: 180, damping: 28 }; + const springConfig = { stiffness: 1000, damping: 60 }; const offsetSpring = useSpring(0, springConfig); const segmentLengthSpring = useSpring(0, springConfig); diff --git a/apps/start/src/components/charts/chart-context.tsx b/apps/start/src/components/charts/chart-context.tsx index 8d0a99776..093348cc9 100644 --- a/apps/start/src/components/charts/chart-context.tsx +++ b/apps/start/src/components/charts/chart-context.tsx @@ -16,9 +16,11 @@ 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'; @@ -60,15 +62,10 @@ export interface Margin { } 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; } @@ -78,7 +75,23 @@ export interface LineConfig { strokeWidth: number; } -export interface ChartContextValue { +/** + * Hover/selection state lives in its own context so consumers that don't + * care about it (Grid, YAxis, PatternArea, etc.) can subscribe to the + * stable slice and skip re-rendering on every hover bucket change. + */ +export interface ChartHoverContextValue { + tooltipData: TooltipData | null; + setTooltipData: Dispatch>; + selection?: ChartSelection | null; + clearSelection?: () => void; + hoveredBarIndex?: number | null; + setHoveredBarIndex?: (index: number | null) => void; + hoveredCandleIndex?: number | null; + setHoveredCandleIndex?: (index: number | null) => void; +} + +export interface ChartContextValue extends ChartHoverContextValue { // Data data: Record[]; @@ -93,108 +106,212 @@ export interface ChartContextValue { innerHeight: number; margin: Margin; - // Column width for spacing calculations columnWidth: number; - - // Tooltip state - tooltipData: TooltipData | null; - setTooltipData: Dispatch>; - - // 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[]; - // 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 specific (optional - only present in BarChart) - /** Band scale for categorical x-axis (bar charts) */ barScale?: ScaleBand; - /** Width of each bar band */ bandWidth?: number; - /** Index of currently hovered bar */ - hoveredBarIndex?: number | null; - /** Setter for hovered bar index */ - setHoveredBarIndex?: (index: number | null) => void; - /** 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>; - // Candlestick chart specific (optional) - /** Index of currently hovered candle */ - hoveredCandleIndex?: number | null; - /** Setter for hovered candle index */ - setHoveredCandleIndex?: (index: number | null) => void; - // 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; } -const ChartContext = createContext(null); +export type ChartStableContextValue = Omit< + ChartContextValue, + | 'tooltipData' + | 'setTooltipData' + | 'selection' + | 'clearSelection' + | 'hoveredBarIndex' + | 'setHoveredBarIndex' + | 'hoveredCandleIndex' + | 'setHoveredCandleIndex' +>; + +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. Memoized on individual field + * identities so changing `tooltipData` doesn't bust the stable slice's + * identity — consumers of `useChartStable()` skip re-renders on hover. + */ export function ChartProvider({ children, value, }: { - children: React.ReactNode; + children: ReactNode; value: ChartContextValue; }) { + const stable = useMemo( + () => ({ + data: value.data, + 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.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} + + + {children} + + ); } -export function useChart(): ChartContextValue { - const context = useContext(ChartContext); +/** + * Stable slice — data, scales, dimensions, etc. 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( - 'useChart must be used within a ChartProvider. ' + + 'useChartStable must be used within a ChartProvider. ' + 'Make sure your component is wrapped in , , , or .' ); } return context; } -export const useChartStable = useChart; -export const useChartHover = useChart; +/** + * 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 ChartContext; +export default ChartStableContext; diff --git a/apps/start/src/components/charts/grid.tsx b/apps/start/src/components/charts/grid.tsx index 6134ffcc7..3ee79faee 100644 --- a/apps/start/src/components/charts/grid.tsx +++ b/apps/start/src/components/charts/grid.tsx @@ -2,7 +2,7 @@ import { GridColumns, GridRows } from "@visx/grid"; import { useId } from "react"; -import { chartCssVars, useChart } from "./chart-context"; +import { chartCssVars, useChartStable } from "./chart-context"; export interface GridProps { /** Show horizontal grid lines. Default: true */ @@ -43,7 +43,7 @@ export function Grid({ fadeVertical = false, }: GridProps) { const { xScale, yScale, innerWidth, innerHeight, orientation, barScale } = - useChart(); + useChartStable(); // For bar charts, determine which scale to use for grid lines // Horizontal bar charts: vertical grid should use yScale (value scale) diff --git a/apps/start/src/components/charts/line.tsx b/apps/start/src/components/charts/line.tsx index b4286b083..429415278 100644 --- a/apps/start/src/components/charts/line.tsx +++ b/apps/start/src/components/charts/line.tsx @@ -8,9 +8,10 @@ import { LinePath } from "@visx/shape"; type CurveFactory = any; import { motion, useMotionTemplate, useSpring } from "motion/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { chartCssVars, useChart } from "./chart-context"; import { ChartRevealClip } from "./chart-reveal-clip"; +import { useLineSegmentHighlight } from "./use-line-segment-highlight"; export interface LineProps { /** Key in data to use for y values */ @@ -54,120 +55,52 @@ export function Line({ const pathRef = useRef(null); const [pathLength, setPathLength] = useState(0); + // Cache the `d` attribute as state so the highlight `motion.path` reads a + // stable string ref instead of a DOM attribute on every render. + const [pathD, setPathD] = useState(null); - // Unique gradient ID for this line - const gradientId = useMemo( - () => `line-gradient-${dataKey}-${Math.random().toString(36).slice(2, 9)}`, - [dataKey] - ); + // `useId()` is SSR-stable and skips per-render `Math.random()` / base36 work. + const reactId = useId(); + const gradientId = `line-gradient-${dataKey}-${reactId}`; // biome-ignore lint/correctness/useExhaustiveDependencies: data, innerWidth useEffect(() => { - if (pathRef.current && animate) { - const len = pathRef.current.getTotalLength(); - if (len > 0) { - setPathLength(len); - } - } - }, [animate, data, innerWidth]); - - // Binary search to find path length at a given X coordinate - const findLengthAtX = useCallback( - (targetX: number): number => { - const path = pathRef.current; - 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; - }, - [pathLength] - ); - - // Calculate segment bounds for highlight from either selection or hover - const segmentBounds = useMemo(() => { - if (!pathRef.current || pathLength === 0) { - return { startLength: 0, segmentLength: 0, isActive: false }; - } - - // Selection takes priority over hover - if (selection?.active) { - const startLength = findLengthAtX(selection.startX); - const endLength = findLengthAtX(selection.endX); - return { - startLength, - segmentLength: 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 = findLengthAtX(startX); - const endLength = findLengthAtX(endX); - - return { - startLength, - segmentLength: endLength - startLength, - isActive: true, - }; - }, [ + const path = pathRef.current; + if (!path) return; + const len = path.getTotalLength(); + if (len > 0) setPathLength(len); + const d = path.getAttribute("d"); + if (d) setPathD(d); + }, [data, innerWidth, yScale]); + + // Chord-length based highlight bounds — no `getPointAtLength` binary search + // per hover. Memoized on (data, scales, dataKey); subsequent hovers are + // O(1) lookups instead of ~30-60 SVG DOM ops per Line. + const segmentBounds = useLineSegmentHighlight({ + pathLength, + data, tooltipData, selection, - data, xScale, - pathLength, + yScale, xAccessor, - findLengthAtX, - ]); + dataKey, + }); - // Springs for smooth highlight animation (both offset AND segment length) - const springConfig = { stiffness: 180, damping: 28 }; + // Springs for smooth highlight animation (both offset AND segment length). + // High stiffness so they snap near-instantly between data buckets. + const springConfig = { stiffness: 1000, damping: 60 }; const offsetSpring = useSpring(0, springConfig); const segmentLengthSpring = useSpring(0, springConfig); - // Update springs when segment bounds change - useEffect(() => { - offsetSpring.set(-segmentBounds.startLength); - segmentLengthSpring.set(segmentBounds.segmentLength); - }, [ - segmentBounds.startLength, - segmentBounds.segmentLength, - offsetSpring, - segmentLengthSpring, - ]); - - // Create animated strokeDasharray using motion template + // Set spring targets directly in render — motion schedules its own animation + // frame internally and doesn't trigger a React re-render. The effect-based + // version cost a double render per hover update. + offsetSpring.set(-segmentBounds.startLength); + segmentLengthSpring.set(segmentBounds.segmentLength); + const animatedDasharray = useMotionTemplate`${segmentLengthSpring} ${pathLength}`; - // Get y value for a data point const getY = useCallback( (d: Record) => { const value = d[dataKey]; @@ -228,11 +161,12 @@ export function Line({ - {/* Highlight segment on hover */} - {showHighlight && isHovering && isLoaded && pathRef.current && ( + {/* Highlight segment on hover — reads cached pathD instead of doing a + DOM attribute read on every render. */} + {showHighlight && isHovering && isLoaded && pathD && ( 1; + 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; @@ -166,8 +225,8 @@ export function MarkerGroup({ style={{ cursor: "pointer" }} > @@ -256,8 +318,11 @@ export function MarkerGroup({ > {shouldFan && - markers.map((marker, index) => { - const position = getCirclePosition(index, markers.length); + fannedMarkers.map((marker, index) => { + const position = getCirclePosition( + index, + fannedMarkers.length + ); return ( @@ -346,14 +428,14 @@ function MarkerCircle({ icon, size, color }: MarkerCircleProps) { cy={0} fill={color || chartCssVars.markerBackground} r={size / 2} - stroke={chartCssVars.markerBorder} - strokeWidth={1.5} + stroke={borderColor ?? chartCssVars.markerBorder} + strokeWidth={borderWidth} />
{icon} @@ -381,8 +465,12 @@ function MarkerCircleHTML({ 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(); @@ -407,9 +495,11 @@ function MarkerCircleHTML({ onClick={hasAction ? handleClick : undefined} style={{ backgroundColor: color || chartCssVars.markerBackground, - border: `1.5px solid ${chartCssVars.markerBorder}`, + border: `${borderWidth}px solid ${borderColor ?? chartCssVars.markerBorder}`, fontSize: size * 0.5, color: chartCssVars.markerForeground, + padding: inset, + overflow: "hidden", }} transition={{ type: "spring", stiffness: 400, damping: 17 }} whileHover={ diff --git a/apps/start/src/components/charts/pattern-area.tsx b/apps/start/src/components/charts/pattern-area.tsx index f15dc5257..99a317c81 100644 --- a/apps/start/src/components/charts/pattern-area.tsx +++ b/apps/start/src/components/charts/pattern-area.tsx @@ -2,7 +2,7 @@ import { curveMonotoneX } from "@visx/curve"; import { AreaClosed } from "@visx/shape"; -import { useChart } from "./chart-context"; +import { useChartStable } from "./chart-context"; // biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type type CurveFactory = any; @@ -27,7 +27,7 @@ export function PatternArea({ fill, curve = curveMonotoneX, }: PatternAreaProps) { - const { data, xScale, yScale, xAccessor } = useChart(); + const { data, xScale, yScale, xAccessor } = useChartStable(); return (