"use client" /** * ChartsOverview — Dashboard chart gallery * * ── ChartCard variants ─────────────────────────────────────────────────────── * normal — plain card with Ask Leo * tabs — "Chart" | "Trend (Line)" tabs + Ask Leo * selector — quick-filter Select + Ask Leo * metrics-tabs — metric cells ARE the tab triggers (label + value + trend) * * ── ASK LEO ICON GUIDELINE ─────────────────────────────────────────────────── * Always use: * Never use: fa-wand-magic-sparkles (retired, inconsistent) * Size: text-xs (11px via --text-xs) with aria-hidden="true" * Label: "Ask Leo" (never truncate or omit the text label) * Applies to: ALL Ask Leo buttons across the entire app — * ChartCard headers, KeyMetrics card, GreetingWidget, NavUser, etc. * * ── WCAG AA STANDARDS FOR GRAPHS ───────────────────────────────────────────── * 1. Container landmark * • Wrap each chart in a
(or div with role="figure") + * aria-label="" + aria-describedby="" * • Add a visually-hidden
with a plain-text * summary of the key trend (e.g. "Placements rose 12% in Q1 2026"). * * 2. Keyboard navigation * • The ChartContainer wrapper must have tabIndex={0} so it receives focus. * • On focus, announce title + summary via aria-label / aria-describedby. * • Arrow keys (←/→) cycle through data points; announce value via * a live region (role="status" aria-live="polite"). * • Esc clears the selection and returns focus to the container. * * 3. Accessible data table (hidden fallback) * • Immediately after the SVG/canvas, render a wrapped in * (visually hidden, in DOM). * • Columns mirror the chart axes; each data point is a
. * • Screen-reader users can navigate data with standard table shortcuts. * * 4. Colour & contrast * • Chart series colours must achieve ≥ 3:1 contrast against the card bg. * • Never use colour as the ONLY differentiator — pair with: * - Dashed vs solid line strokes * - Direct inline labels on lines/segments * - Shape markers on data points (circle vs square vs triangle) * • Text labels inside charts: ≥ 4.5:1 on their local background. * * 5. Focus ring on data points * • Active/focused data point: 3px outline, ≥ 3:1 contrast, distinct * from the hover state (use outline-offset to separate). * * 6. Tooltip accessibility * • Tooltips must appear on keyboard focus, not only on mouse hover. * • Tooltip content must be announced to the live region. * • Tooltip must remain visible while it has focus (no auto-dismiss). * ───────────────────────────────────────────────────────────────────────────── */ import * as React from "react" import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Funnel, FunnelChart, LabelList, Line, LineChart, Pie, PieChart, PolarAngleAxis, PolarGrid, PolarRadiusAxis, Radar, RadarChart, RadialBar, RadialBarChart, Scatter, ScatterChart, XAxis, YAxis, ZAxis, type DotItemDotProps, } from "recharts" import { QuotaLinearProgressCardBody, QuotaRadialChartInner, } from "@/components/dashboard-quota-progress-card" import { DASHBOARD_STUDENT_SCORES, formatBandScore, type StudentScoreRadial, } from "@/lib/mock/dashboard" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, chartTooltipKeyboardSyncProps, ChartTooltipContent, type ChartConfig, } from "@/components/ui/chart" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Button } from "@/components/ui/button" import { AskLeoButton } from "@/components/ask-leo-button" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { isEditableTarget } from "@/lib/editable-target" import { chartLineStrokeDash } from "@/lib/chart-line-dash" import { rafThrottle } from "@/lib/raf-throttle" import { cn } from "@/lib/utils" import { metricTrendTone, type MetricTrendPolarity } from "@/components/key-metrics" /** Recharts passes `index` into Line `dot` renderers; keep the callback typed against the v3 dot contract. */ type LineDotRenderProps = DotItemDotProps & { index?: number } const activeIndexProps = (activeIndex: number | null) => activeIndex == null ? {} : ({ activeIndex } as Record) type MiniMetric = { label: string value: string trend?: "up" | "down" | "neutral" /** Same semantics as `MetricItem.trendPolarity` on `KeyMetrics`. */ trendPolarity?: MetricTrendPolarity } /* ── Colour tokens ────────────────────────────────────────────────────────── */ const BRAND = "var(--brand-color)" const CHART_1 = "var(--color-chart-1)" const CHART_2 = "var(--color-chart-2)" const CHART_3 = "var(--color-chart-3)" const CHART_4 = "var(--color-chart-4)" const CHART_5 = "var(--color-chart-5)" const SUCCESS = "var(--chart-2)" const WARNING = "var(--chart-4)" const DESTRUCTIVE = "var(--destructive)" /* ── Period filter options (reused across selector cards) ─────────────────── */ const PERIOD_OPTIONS = [ { value: "7d", label: "Last 7 days" }, { value: "30d", label: "Last 30 days" }, { value: "90d", label: "Last quarter" }, { value: "1y", label: "Last year" }, ] const PROGRAM_OPTIONS = [ { value: "all", label: "All programs" }, { value: "nursing", label: "Nursing" }, { value: "pt", label: "PT" }, { value: "ot", label: "OT" }, { value: "pharmacy", label: "Pharmacy" }, ] /* ════════════════════════════════════════════════════════════════════════════ REUSABLE ChartCard — supports 3 variants ════════════════════════════════════════════════════════════════════════════ */ export type ChartCardVariant = "normal" | "tabs" | "selector" | "metrics-tabs" | "kpi-chart" /** ChartCard tabs no longer force `text-xs` — use default `text-sm` scale and ≥24px hit area. */ const chartCardTabTriggerClass = "min-h-9 px-3 py-2 text-sm gap-2" import { LeoInsightIndicator, LEO_TOKENS, type ChartLeoInsight, type ChartLeoInsightAnchor, type ChartLeoInsightKind, } from "@/components/leo-insight-indicator" export type { ChartLeoInsight, ChartLeoInsightAnchor, ChartLeoInsightKind } 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 } /** * Static brand-coloured dot drawn on the exact data point Leo is calling out. * A card-coloured knockout ring keeps it readable on top of grid lines and * area fills. No pulsing animation — the dashed connector line + chip do the * attention work, and this keeps the chart calm. */ function LeoPlotPointDot() { return ( ) } /** * Read the Recharts SVG rendered by a sibling `ChartContainer` and project * the insight anchor's `(xValue, yNum)` into pixel coordinates relative to * the overlay's wrapper. * * Strategy — chart-type-agnostic: * 1. Find the `` inside the parent (`.relative` wrapper). * 2. Plot rect = bounding box of `.recharts-cartesian-grid`. * Fallback = area between y-axis right edge and x-axis top edge. * 3. X position = matching x-axis tick's centre, matched by text content. * Fallback = `(idx + 0.5) / n * plotWidth` band formula. * 4. Y position = interpolated from y-axis tick values (handles non-zero * domain bases automatically — e.g. recharts auto-domains that start at * non-zero and charts with cropped y-ranges). Fallback = `1 - y/yMax`. * * Works on: Line/Area/Bar/StackedBar/Composed charts — anything with Cartesian * axes. Pie/Radar/Funnel charts don't expose axes, so the overlay skips with * a null return (anchor concept isn't meaningful there). */ 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 = (el: Element) => { const r = (el 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, } } // Plot rect — prefer cartesian-grid; fall back to axis bounds. 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, } })() // X position: find matching x-tick by text content (chart-agnostic). const xTicks = Array.from( xAxis.querySelectorAll(".recharts-cartesian-axis-tick"), ) as SVGGElement[] let xPx: number | null = null for (const t of xTicks) { if ((t.textContent ?? "").trim() === xValue) { xPx = toLocal(t).cx break } } if (xPx === null) { const idx = data.findIndex((d) => String(d[xDataKey]) === xValue) if (idx < 0) return xPx = plot.left + ((idx + 0.5) / Math.max(data.length, 1)) * plot.width } // Y position: interpolate from y-axis tick values (handles non-zero domains). 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], hi = sorted[sorted.length - 1] if (hi.v !== lo.v) { yPx = lo.y + ((yNum - lo.v) / (hi.v - lo.v)) * (hi.y - lo.y) } } if (yPx === null) { // Conservative fallback when y-axis ticks cannot be parsed. const yMax = chartLeoNumericDomainMax(data, xDataKey) yPx = plot.top + (1 - yNum / yMax) * plot.height } // Equality-guard: ResizeObserver + MutationObserver fire on every frame // of the sidebar's 200ms width transition (and on every Recharts attribute // mutation during its own enter animation). Without this guard, each // sub-pixel shift triggers a React re-render of the overlay subtree — // multiplied by every chart on the page that's worth dozens of renders // per frame on a wide dashboard. 0.5px tolerance is well below any // visible misalignment of the dashed-line overlay. 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 } }) } // Recharts mounts/animates after our first paint; measure a few times. compute() let raf1 = requestAnimationFrame(() => { compute() raf1 = requestAnimationFrame(compute) }) // rAF-coalesce: ResizeObserver + MutationObserver can each fire many times // per frame during the sidebar's `transition-[width]` (200ms ease-linear) // and during Recharts' own enter animation. Without throttling, `compute` // walks the SVG + parses tick text on every fire — which on the dashboard // (multiple chart cards × per-chart insight overlays) saturates the main // thread and makes the sidebar collapse/expand feel sluggish. One sample // per frame is plenty for sub-pixel anchor positioning. Same pattern as // `apps/web/hooks/use-sidebar-reflow-zoom.ts`. 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 } } /** * HTML overlay on the chart plot (sibling of `ChartContainer`, inside a `relative` wrapper). * * Visual structure, top → bottom: * 1. Chip (`LeoInsightIndicator` in plot-marker layout) — floats above * 2. Dashed connector line in the kind colour joining chip to dot * 3. Pulsing dot anchored on the real data point * * Positioning is measured from the Recharts SVG at runtime (see * `useChartAnchorPixelPosition`) so the dot lands on the actual data point * regardless of chart type, y-domain, or plot margin. */ export function ChartLeoPlotInsightOverlay({ data, xDataKey, markerLiftPx = 44, }: { data: ReadonlyArray> xDataKey: string /** @deprecated retained for call-site compatibility. */ insetPct?: { left: number; right: number; top: number; bottom: number } /** @deprecated retained for call-site compatibility. */ xAxisLabelReservePct?: number /** @deprecated retained for call-site compatibility. */ markerLiftPct?: number /** @deprecated retained for call-site compatibility. */ markerLiftExtraPx?: number /** Vertical distance from dot to the bottom of the floating chip, in px. */ markerLiftPx?: number }) { // Lift the chip well clear of the default Recharts tooltip so they never // fight for the same cursor area on hover. const effectiveLift = markerLiftPx ?? 56 const bundle = React.useContext(ChartLeoInsightContext) const anchor = bundle?.insight.anchor const idx = anchor ? data.findIndex((d) => String(d[xDataKey]) === anchor.xValue) : -1 const row = idx >= 0 ? (data[idx] as Record) : null const yNum = row && anchor ? resolveChartLeoAnchorY(row, xDataKey, anchor) : null // NOTE: Hook must always run (React rules). Pass safe defaults when not ready. const { ref, pos } = useChartAnchorPixelPosition({ xValue: anchor?.xValue ?? "", xDataKey, yNum: yNum ?? 0, data, }) if (!bundle || !anchor || idx < 0 || yNum === null || Number.isNaN(yNum)) return null // Clamp the chip so it never renders above the plot rect. const chipBottomY = pos ? Math.max((pos.plotTop ?? 0) + 28, pos.y - effectiveLift) : 0 return (
{pos && ( <> {/* Dashed connector — chip bottom → ~7px above the dot, brand-coloured. */}
{/* Static brand dot anchored on the real data point */}
{/* Chip trigger — bottom edge meets the top of the dashed connector */}
)}
) } /** Supplies Leo insight to chart bodies; optional corner control when there is no plot anchor. */ 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} const showCorner = !leoInsight.anchor return ( {showCorner ? (
{children}
) : ( children )}
) } /** Screen-reader data fallback for charts — shared with list-page dashboards. */ export function ChartDataTable({ caption, headers, rows, }: { caption: string headers: string[] rows: (string | number)[][] }) { return ( {headers.map((h) => )} {rows.map((row, i) => ( {row.map((cell, j) => )} ))}
{caption}
{h}
{cell}
) } /** * Keyboard-focusable chart region (arrow keys, Escape) + live announcement when a point is selected. * Shared by the `/dashboard` gallery and **Data** view dashboards (Placements / Team / Compliance): same * interaction model; visual differences come from `ChartCard` chrome and per-chart renderers (bar vs pie), * not from a separate chart implementation. */ export function ChartFigure({ label, summary, dataLength, leoInsight, children, }: { label: string summary: string dataLength: number /** Optional Ask-Leo insight context for chart bodies (same as `ChartCard`). */ leoInsight?: ChartLeoInsight | null children: (activeIndex: number | null) => React.ReactNode }) { const [activeIndex, setActiveIndex] = React.useState(null) const ref = React.useRef(null) const prevActiveIndexRef = React.useRef(null) React.useEffect(() => { const prev = prevActiveIndexRef.current prevActiveIndexRef.current = activeIndex if (prev === null || activeIndex !== null) return const wrapper = ref.current?.querySelector(".recharts-wrapper") if (!wrapper) return wrapper.dispatchEvent( new MouseEvent("mouseleave", { bubbles: true, cancelable: true }), ) }, [activeIndex]) const navigateKeys = React.useCallback( (e: React.KeyboardEvent) => { if (!dataLength) return if (isEditableTarget(e.target)) return switch (e.key) { case "ArrowRight": case "ArrowDown": e.preventDefault() e.stopPropagation() setActiveIndex((i) => (i === null ? 0 : Math.min(i + 1, dataLength - 1))) break case "ArrowLeft": case "ArrowUp": e.preventDefault() e.stopPropagation() setActiveIndex((i) => (i === null ? dataLength - 1 : Math.max(i - 1, 0))) break case "Escape": e.preventDefault() e.stopPropagation() setActiveIndex(null) ref.current?.blur() break default: break } }, [dataLength], ) /** Clicks on Recharts SVG do not focus this node — focus so Arrow keys work without extra Tab stops. */ function handlePointerDownCapture(e: React.PointerEvent) { if (!dataLength) return const root = ref.current if (!root?.contains(e.target as Node)) return const el = e.target as HTMLElement | null if (el?.closest?.("button, a, [role='tab'], [role='option'], input, select, textarea, [contenteditable='true']")) return queueMicrotask(() => root.focus()) } return (
{ if (!ref.current?.contains(e.target as Node)) return if (isEditableTarget(e.target)) return if ( e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === "ArrowLeft" || e.key === "ArrowUp" || e.key === "Escape" ) { navigateKeys(e) } }} onPointerDownCapture={handlePointerDownCapture} className="flex min-h-0 flex-1 flex-col outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded-sm" > {children(activeIndex)} {activeIndex !== null && (
Data point {activeIndex + 1} of {dataLength} selected
)}
) } function ChartCardHeader({ title, description, variant, filterOptions, filter, onFilter, }: { title: string description: string variant: ChartCardVariant filterOptions?: { value: string; label: string }[] filter?: string onFilter?: (v: string) => void }) { const isSelector = variant === "selector" && Array.isArray(filterOptions) && filterOptions.length > 0 return (
{title} {description}
{/* Reveal on card hover/focus — pointer-events guarded so the hidden button is not reachable */} {isSelector && filterOptions && onFilter && ( )}
) } export function ChartCard({ title, description, children, className = "", variant = "normal", trendContent, filterOptions, defaultFilter, onFilterChange, miniMetrics, tabOptions, leoInsight, }: { title: string description: string children: React.ReactNode | ((filter: string) => React.ReactNode) className?: string variant?: ChartCardVariant /** "tabs" / "metrics-tabs" variant: content shown in the "Trend" tab */ trendContent?: React.ReactNode /** "selector" variant: options for the filter dropdown */ filterOptions?: { value: string; label: string }[] defaultFilter?: string onFilterChange?: (value: string) => void /** "metrics-tabs" variant: compact KPI strip shown above the chart */ miniMetrics?: MiniMetric[] /** "tabs" variant: override the default Chart/Trend tabs with custom options. * The selected value is passed to the children function. */ tabOptions?: { value: string; label: string }[] /** * Smart Leo summary: opens a popover + Ask Leo CTA. * With `anchor`, mount `ChartLeoPlotInsightOverlay` beside `ChartContainer` for on-plot guide + marker; otherwise a corner Insight control is shown. */ leoInsight?: ChartLeoInsight | null }) { const [filter, setFilter] = React.useState( () => defaultFilter || filterOptions?.[0]?.value || miniMetrics?.[0]?.label || tabOptions?.[0]?.value || "" ) // Sync when defaultFilter or first miniMetric changes (React may reuse across ternary branches) React.useEffect(() => { const next = defaultFilter || filterOptions?.[0]?.value || miniMetrics?.[0]?.label if (next) setFilter(next) // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultFilter, miniMetrics?.[0]?.label]) const handleFilter = (v: string) => { setFilter(v); onFilterChange?.(v) } const resolvedChildren = typeof children === "function" ? children(filter) : children /* ── Default Chart / Trend tabs (no custom tabOptions) ───────────────────── */ const defaultTabsBlock = (