"use client" /** * Centralized Leo plot spotting — pill + dashed connector + dot on chart data. * * Use with `ChartLeoInsightOverlay` (context) + `ChartLeoPlotInsightOverlay` (plot). * Anchor strategies live in `@/lib/chart-leo-spotting`. */ import * as React from "react" import { LeoInsightIndicator, LEO_TOKENS, type ChartLeoInsight, type ChartLeoInsightAnchor, } from "@/components/leo-insight-indicator" import { chartLeoMarkerLiftPx, type ChartLeoSpottingFamily, } from "@/lib/chart-leo-spotting" import { rafThrottle } from "@/lib/raf-throttle" import { cn } from "@/lib/utils" type ChartLeoInsightBundle = { insight: ChartLeoInsight; chartTitle: string } const ChartLeoInsightContext = React.createContext(null) function resolveChartLeoAnchorY( row: Record, xDataKey: string, anchor: ChartLeoInsightAnchor, ): number | null { if (typeof anchor.yValue === "number" && !Number.isNaN(anchor.yValue)) { return anchor.yValue } const keys = anchor.yDataKeys?.filter((k) => k !== xDataKey) ?? Object.keys(row).filter((k) => k !== xDataKey) const nums = keys .map((k) => row[k]) .filter((v): v is number => typeof v === "number" && !Number.isNaN(v)) if (nums.length === 0) return null const combine = anchor.yCombine ?? "max" return combine === "sum" ? nums.reduce((a, b) => a + b, 0) : Math.max(...nums) } function chartLeoNumericDomainMax( data: ReadonlyArray>, xDataKey: string, ): number { let m = 0 for (const row of data) { for (const [k, v] of Object.entries(row)) { if (k === xDataKey) continue if (typeof v === "number" && !Number.isNaN(v) && v > m) m = v } } return m > 0 ? m : 1 } function LeoPlotPointDot() { return ( ) } function useChartAnchorPixelPosition({ xValue, xDataKey, yNum, data, }: { xValue: string xDataKey: string yNum: number data: ReadonlyArray> }) { const ref = React.useRef(null) const [pos, setPos] = React.useState<{ x: number; y: number; plotTop: number } | null>(null) React.useEffect(() => { const el = ref.current if (!el) return const parent = el.parentElement if (!parent) return const compute = () => { const svg = parent.querySelector("svg") as SVGSVGElement | null if (!svg) return const parentRect = parent.getBoundingClientRect() const toLocal = (node: Element) => { const r = (node as SVGGraphicsElement).getBoundingClientRect() return { left: r.left - parentRect.left, right: r.right - parentRect.left, top: r.top - parentRect.top, bottom: r.bottom - parentRect.top, width: r.width, height: r.height, cx: r.left + r.width / 2 - parentRect.left, cy: r.top + r.height / 2 - parentRect.top, } } const grid = svg.querySelector(".recharts-cartesian-grid") const xAxis = svg.querySelector(".recharts-xAxis") const yAxis = svg.querySelector(".recharts-yAxis") if (!xAxis || !yAxis) return const plot = grid ? toLocal(grid) : (() => { const y = toLocal(yAxis) const x = toLocal(xAxis) return { left: y.right, right: x.right, top: y.top, bottom: x.top, width: x.right - y.right, height: x.top - y.top, cx: 0, cy: 0, } })() const xTicks = Array.from( xAxis.querySelectorAll(".recharts-cartesian-axis-tick"), ) as SVGGElement[] const xTickPairs: Array<{ v: number; x: number }> = [] for (const t of xTicks) { const raw = (t.textContent ?? "").trim() if (!raw) continue const v = parseFloat(raw.replace(/[^0-9.\-]/g, "")) if (!Number.isNaN(v)) xTickPairs.push({ v, x: toLocal(t).cx }) } const anchorRow = data.find((d) => String(d[xDataKey]) === xValue) ?? data.find((d) => Object.entries(d).some(([k, v]) => k !== xDataKey && String(v) === xValue)) let xPx: number | null = null for (const t of xTicks) { if ((t.textContent ?? "").trim() === xValue) { xPx = toLocal(t).cx break } } if (xPx === null && anchorRow) { const xNum = anchorRow[xDataKey] if (typeof xNum === "number" && xTickPairs.length >= 2) { const sorted = [...xTickPairs].toSorted((a, b) => a.v - b.v) const lo = sorted[0]! const hi = sorted[sorted.length - 1]! if (hi.v !== lo.v) { xPx = lo.x + ((xNum - lo.v) / (hi.v - lo.v)) * (hi.x - lo.x) } } if (xPx === null) { const idx = data.indexOf(anchorRow) if (idx < 0) return xPx = plot.left + ((idx + 0.5) / Math.max(data.length, 1)) * plot.width } } if (xPx === null) return const yTickEls = Array.from( yAxis.querySelectorAll(".recharts-cartesian-axis-tick"), ) as SVGGElement[] const yTickPairs: Array<{ v: number; y: number }> = [] for (const t of yTickEls) { const raw = (t.textContent ?? "").trim() if (!raw) continue const v = parseFloat(raw.replace(/[^0-9.\-]/g, "")) if (Number.isNaN(v)) continue yTickPairs.push({ v, y: toLocal(t).cy }) } let yPx: number | null = null if (yTickPairs.length >= 2) { const sorted = [...yTickPairs].toSorted((a, b) => a.v - b.v) const lo = sorted[0] const hi = sorted[sorted.length - 1] if (lo && hi && hi.v !== lo.v) { yPx = lo.y + ((yNum - lo.v) / (hi.v - lo.v)) * (hi.y - lo.y) } } if (yPx === null) { const yMax = chartLeoNumericDomainMax(data, xDataKey) yPx = plot.top + (1 - yNum / yMax) * plot.height } setPos((prev) => { if ( prev !== null && Math.abs(prev.x - xPx) < 0.5 && Math.abs(prev.y - (yPx as number)) < 0.5 && Math.abs(prev.plotTop - plot.top) < 0.5 ) { return prev } return { x: xPx, y: yPx as number, plotTop: plot.top } }) } compute() let raf1 = requestAnimationFrame(() => { compute() raf1 = requestAnimationFrame(compute) }) const scheduled = rafThrottle(compute) const ro = new ResizeObserver(scheduled) ro.observe(parent) const mo = new MutationObserver(scheduled) mo.observe(parent, { childList: true, subtree: true, attributes: true, attributeFilter: ["width", "height", "transform", "d", "x", "y"], }) return () => { cancelAnimationFrame(raf1) scheduled.cancel() ro.disconnect() mo.disconnect() } }, [xValue, xDataKey, yNum, data]) return { ref, pos } } function useChartLeoSelectorPosition(plotSelector: string | undefined) { const ref = React.useRef(null) const [pos, setPos] = React.useState<{ x: number; y: number; plotTop: number } | null>(null) React.useEffect(() => { if (!plotSelector) return const el = ref.current if (!el) return const parent = el.parentElement if (!parent) return const compute = () => { const target = parent.querySelector(plotSelector) if (!target) return const parentRect = parent.getBoundingClientRect() const r = target.getBoundingClientRect() const x = r.left + r.width / 2 - parentRect.left const y = r.top + r.height / 2 - parentRect.top setPos((prev) => { if ( prev !== null && Math.abs(prev.x - x) < 0.5 && Math.abs(prev.y - y) < 0.5 && Math.abs(prev.plotTop) < 0.5 ) { return prev } return { x, y, plotTop: 0 } }) } compute() let raf1 = requestAnimationFrame(() => { compute() raf1 = requestAnimationFrame(compute) }) const scheduled = rafThrottle(compute) const ro = new ResizeObserver(scheduled) ro.observe(parent) const mo = new MutationObserver(scheduled) mo.observe(parent, { childList: true, subtree: true, attributes: true, attributeFilter: ["width", "height", "transform", "d", "x", "y", "data-chart-leo-anchor"], }) return () => { cancelAnimationFrame(raf1) scheduled.cancel() ro.disconnect() mo.disconnect() } }, [plotSelector]) return { ref, pos } } function ChartLeoPlotMarkerLayer({ pos, insight, chartTitle, markerLiftPx, }: { pos: { x: number; y: number; plotTop: number } insight: ChartLeoInsight chartTitle: string markerLiftPx: number }) { const chipBottomY = Math.max((pos.plotTop ?? 0) + 28, pos.y - markerLiftPx) return ( <>
) } export function ChartLeoPlotInsightOverlay({ data, xDataKey, chartFamily = "default", markerLiftPx, }: { data?: ReadonlyArray> xDataKey?: string chartFamily?: ChartLeoSpottingFamily insetPct?: { left: number; right: number; top: number; bottom: number } xAxisLabelReservePct?: number markerLiftPct?: number markerLiftExtraPx?: number markerLiftPx?: number }) { const effectiveLift = markerLiftPx ?? chartLeoMarkerLiftPx(chartFamily) const bundle = React.useContext(ChartLeoInsightContext) const anchor = bundle?.insight.anchor const plotSelector = anchor?.plotSelector const cartesianRows = data ?? [] const cartesianXKey = xDataKey ?? "x" const idx = anchor?.xValue && data && xDataKey ? data.findIndex((d) => String(d[xDataKey]) === anchor.xValue) : -1 const row = idx >= 0 ? (data![idx] as Record) : null const yNum = row && anchor ? resolveChartLeoAnchorY(row, cartesianXKey, anchor) : null const selectorPos = useChartLeoSelectorPosition(plotSelector) const cartesianPos = useChartAnchorPixelPosition({ xValue: anchor?.xValue ?? "", xDataKey: cartesianXKey, yNum: yNum ?? 0, data: cartesianRows, }) if (!bundle || !anchor) return null if (plotSelector) { const { ref, pos } = selectorPos if (!pos) { return (
) } return (
) } if (!data || !xDataKey || idx < 0 || yNum === null || Number.isNaN(yNum)) return null const { ref, pos } = cartesianPos if (!pos) { return (
) } return (
) } export function ChartLeoInsightOverlay({ leoInsight, chartTitle, children, }: { leoInsight?: ChartLeoInsight | null chartTitle: string children: React.ReactNode }) { const contextValue = React.useMemo( () => (leoInsight ? { insight: leoInsight, chartTitle } : null), [leoInsight, chartTitle], ) if (!leoInsight || !contextValue) return <>{children} return ( {children} ) } /** Plot Leo on canvas charts (ECharts heatmap) using pixel coordinates from the chart API. */ export function ChartLeoPixelPlotInsightOverlay({ position, chartFamily = "heatmap", }: { position: { x: number; y: number } | null chartFamily?: ChartLeoSpottingFamily }) { const bundle = React.useContext(ChartLeoInsightContext) if (!bundle?.insight.anchor || !position) return null return (
) }