"use client"
/**
* charts-core — ChartCard, ChartFigure, ChartDataTable shells + their minimal deps.
*
* This module deliberately carries ZERO gallery/demo code so that hub dashboards
* (library, data-view, placements, etc.) that only need the chart frame can import
* from here without pulling in the full gallery bundle.
*
* The gallery wiring lives in `charts-overview.tsx` which re-exports everything
* from this module for back-compat.
*/
import * as React from "react"
import {
Card, CardContent, CardDescription, CardHeader, CardTitle,
} from "@/components/ui/card"
import {
Tabs,
TabsList,
TabsTrigger,
TabsTriggerIcon,
TabsTriggerLabel,
TabsContent,
} from "@/components/ui/tabs"
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"
import { AskLeoButton } from "@/components/ask-leo-button"
import { isEditableTarget } from "@/lib/editable-target"
import { cn } from "@/lib/utils"
import { metricTrendTone, type MetricTrendPolarity } from "@/components/key-metrics"
import {
type ChartLeoInsight,
type ChartLeoInsightAnchor,
type ChartLeoInsightKind,
} from "@/components/leo-insight-indicator"
import {
ChartLeoInsightOverlay,
ChartLeoPlotInsightOverlay,
ChartLeoPixelPlotInsightOverlay,
} from "@/components/chart-leo-spotting"
/* ── Re-export Leo types + components so callers can import from one place ── */
export type { ChartLeoInsight, ChartLeoInsightAnchor, ChartLeoInsightKind }
export { ChartLeoPlotInsightOverlay, ChartLeoInsightOverlay, ChartLeoPixelPlotInsightOverlay }
/* ════════════════════════════════════════════════════════════════════════════
Types
════════════════════════════════════════════════════════════════════════════ */
export type ChartCardVariant = "normal" | "tabs" | "selector" | "metrics-tabs" | "kpi-chart"
export type MiniMetric = {
label: string
value: string
trend?: "up" | "down" | "neutral"
/** Same semantics as `MetricItem.trendPolarity` on `KeyMetrics`. */
trendPolarity?: MetricTrendPolarity
}
/* ════════════════════════════════════════════════════════════════════════════
ChartDataTable — Screen-reader data fallback for charts
════════════════════════════════════════════════════════════════════════════ */
/** 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 (
{caption}
{headers.map((h) =>
{h}
)}
{rows.map((row, i) => (
{row.map((cell, j) =>
{cell}
)}
))}
)
}
/* ════════════════════════════════════════════════════════════════════════════
ChartFigure — Keyboard-focusable chart region
════════════════════════════════════════════════════════════════════════════ */
/**
* 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 (
{/* Reveal on card hover/focus — pointer-events guarded so the hidden button is not reachable */}
{isSelector && filterOptions && onFilter && (
)}
)
}
function resolveChartCardFilter(
variant: ChartCardVariant,
defaultFilter: string | undefined,
filterOptions: { value: string; label: string }[] | undefined,
miniMetrics: MiniMetric[] | undefined,
tabOptions: { value: string; label: string; icon?: string }[] | undefined,
): string {
if (variant === "metrics-tabs" && miniMetrics && miniMetrics.length > 0) {
const labels = new Set(miniMetrics.map((m) => m.label))
if (defaultFilter && labels.has(defaultFilter)) return defaultFilter
return miniMetrics[0]!.label
}
if (variant === "kpi-chart" && miniMetrics && miniMetrics.length > 0) {
return miniMetrics[0]!.label
}
if (variant === "tabs" && tabOptions && tabOptions.length > 0) {
if (defaultFilter && tabOptions.some((t) => t.value === defaultFilter)) return defaultFilter
return tabOptions[0]!.value
}
if (variant === "selector" && filterOptions && filterOptions.length > 0) {
if (defaultFilter && filterOptions.some((o) => o.value === defaultFilter)) return defaultFilter
return filterOptions[0]!.value
}
return defaultFilter ?? ""
}
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.
*
* Pass a Font Awesome name (`fa-chart-line`) in `icon` wherever the option has
* one, and the row can drop inactive labels before it starts moving whole
* options into the overflow menu. Optional because these tabs are often a
* data slice rather than a destination, and no glyph tells "By status" apart
* from "By facility" — such a row is better shedding whole tabs to the menu,
* which is what it does when the icon is absent.
*/
tabOptions?: { value: string; label: string; icon?: string }[]
/**
* Smart Leo summary: opens a popover + Ask Leo CTA.
* With `anchor`, mount `ChartLeoPlotInsightOverlay` beside `ChartContainer` for on-plot guide + marker.
*/
leoInsight?: ChartLeoInsight | null
}) {
const [filter, setFilter] = React.useState(() =>
resolveChartCardFilter(variant, defaultFilter, filterOptions, miniMetrics, tabOptions),
)
// Reconcile when variant or option sets change (catalog variant switcher, prop updates).
React.useEffect(() => {
setFilter(resolveChartCardFilter(variant, defaultFilter, filterOptions, miniMetrics, tabOptions))
}, [variant, defaultFilter, filterOptions, miniMetrics, tabOptions])
const handleFilter = (v: string) => { setFilter(v); onFilterChange?.(v) }
const resolvedChildren =
typeof children === "function" ? children(filter) : children
const chartCardShellClass = cn("flex h-full min-h-0 flex-col", className)
/* ── Default Chart / Trend tabs (no custom tabOptions) ───────────────────── */
const defaultTabsBlock = (
{/* A chart card is often the narrowest thing on a dashboard, so the row
starts shedding inactive labels to the overflow menu very early.
Using TabsTriggerIcon + TabsTriggerLabel instead of plain text gives
the row the glyph it needs to represent a tab without a visible label. */}
ChartTrend
{resolvedChildren}
{trendContent ?? resolvedChildren}
)
if (variant === "tabs") {
/* Custom tab labels (e.g. period picker for key metrics) */
if (tabOptions && tabOptions.length > 0) {
const selectedTab = filter || tabOptions[0].value
return (
{/* Caller-supplied labels of unknown length in a card that is
often the narrowest thing on a dashboard. */}
{tabOptions.map((tab) => (
{tab.icon ? (
) : null}
{tab.label}
))}
{tabOptions.map((tab) => (
{typeof children === "function" ? children(tab.value) : children}
))}
)
}
return (
{defaultTabsBlock}
)
}
if (variant === "metrics-tabs") {
const metrics = miniMetrics && miniMetrics.length > 0 ? miniMetrics : null
const selectedMetric = filter || metrics?.[0]?.label || ""
return (
{metrics ? (
/* Metrics ARE the tabs — each metric cell is a clickable TabsTrigger */
{/* A metric cell is far wider than a label, so three of them clip
a card long before a normal tab row would. */}
{metrics.map((m) => {
const isUp = m.trend === "up"
const isDown = m.trend === "down"
const tone = metricTrendTone(m.trend ?? "neutral", m.trendPolarity)
const upClass =
tone === "positive"
? "text-emerald-600"
: tone === "negative"
? "text-destructive-ink"
: "text-muted-foreground"
const downClass =
tone === "positive"
? "text-emerald-600"
: tone === "negative"
? "text-destructive-ink"
: "text-muted-foreground"
return (
{m.label}
{m.value}
{isUp && }
{isDown && }
)
})}
{/* All metric tabs show the same chart — tab selection is a context indicator */}
{metrics.map((m) => (
{resolvedChildren}
))}
) : (
defaultTabsBlock
)}
)
}
/* ── kpi-chart: prominent metric on top, chart below ─────────────────────── */
if (variant === "kpi-chart") {
const kpi = miniMetrics?.[0]
const isUp = kpi?.trend === "up"
const isDown = kpi?.trend === "down"
const tone = metricTrendTone(kpi?.trend ?? "neutral", kpi?.trendPolarity)
const trendClass =
tone === "positive"
? "text-emerald-600"
: tone === "negative"
? "text-destructive-ink"
: "text-muted-foreground"
return (
{kpi && (
{kpi.value}
{isUp && (
trending up
)}
{isDown && (
trending down
)}