plotcn
componentarea-comparisonsource: ok · hit
Comparison Area
Two-series area visualization for comparing a primary magnitude against a reference magnitude across an ordered domain, using restrained overlap, deterministic semantic identity, and shared Y-scale.
npx shadcn add "https://plotcn.vercel.app/r/area-comparison.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumchartsrecharts
Registry dependencies
https://plotcn.vercel.app/r/chart-container.jsonhttps://plotcn.vercel.app/r/chart-state.jsonhttps://plotcn.vercel.app/r/chart-tooltip.jsonhttps://plotcn.vercel.app/r/chart-motion.json
Files1 · source cached 10/8/2026, 11:21:32 PM
components/charts/recharts/area-comparison.tsx
"use client"
import * as React from "react"
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Legend,
type TooltipProps,
} from "recharts"
import { useChartReducedMotion } from "../shared/use-chart-reduced-motion"
import { ChartContainer } from "../shared/chart-container"
import {
ChartLoadingState,
ChartEmptyState,
ChartErrorState,
ChartUnavailableState,
} from "../shared/chart-state"
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { LockKeyIcon } from "@hugeicons/core-free-icons"
/* -------------------------------------------------------------------------- */
/* Type Definitions */
/* -------------------------------------------------------------------------- */
export type CurveType = "linear" | "monotone" | "step"
export type DeltaType = "absolute" | "percentage" | "both"
export type NumericKeyOf<T> = {
[K in keyof T]: T[K] extends number | null | undefined ? K : never
}[keyof T] & string
export interface ComparisonAreaSeriesItem<TData = Record<string, unknown>> {
/** Property key on datum representing this role's numeric metric */
key: keyof TData & string
/** Human-readable display label */
label: string
/** Optional role-specific color override */
color?: string
/** Optional custom value formatter */
valueFormatter?: (value: number) => string
}
export interface ComparisonAreaSeries<TData = Record<string, unknown>> {
/** The primary (current / dominant) series */
primary: ComparisonAreaSeriesItem<TData>
/** The reference (baseline / previous period) series */
reference: ComparisonAreaSeriesItem<TData>
}
export interface ComparisonAreaProps<TData extends Record<string, unknown> = Record<string, unknown>> {
/**
* The array of observation records to visualize.
* Readonly array; caller data is never mutated.
*/
data: readonly TData[]
/**
* Property name for horizontal domain coordinates (time, date, category).
*/
xKey: keyof TData & string
/**
* Explicit primary and reference series configuration.
*/
series: ComparisonAreaSeries<TData>
/**
* Container height in pixels or CSS dimension string.
* Default: 320
*/
height?: number | string
/**
* Curve interpolation algorithm.
* Default: "monotone"
*/
curve?: CurveType
/**
* Shared vertical domain [min, max] or "auto".
* Automatically computed across both roles to guarantee honest comparison.
*/
domain?: [number, number] | "auto"
/**
* Primary series color.
* Default: "var(--chart-1)"
*/
primaryColor?: string
/**
* Reference series color.
* Default: "var(--chart-2)"
*/
referenceColor?: string
/**
* Selection / locked indicator color.
* Default: "var(--chart-selection)"
*/
selectionColor?: string
/**
* Primary area fill opacity (0.05 to 1.0).
* Default: 0.28
*/
fillOpacity?: number
/**
* Explicit primary fill opacity override.
*/
primaryFillOpacity?: number
/**
* Explicit reference fill opacity override.
* Defaults to a restrained attenuation of primary opacity (~0.14).
*/
referenceFillOpacity?: number
/**
* Handling of missing / null values.
* "gap" produces a truthful visual break; "connect" interpolates across missing coordinates.
* Default: "gap"
*/
missingValuePolicy?: "gap" | "connect"
/**
* Whether to display comparison delta (primary − reference) in the tooltip.
* Default: true
*/
showDelta?: boolean
/**
* Format for delta readout: "absolute", "percentage", or "both".
* Default: "both"
*/
deltaType?: DeltaType
/**
* Whether to invert delta semantics (e.g. for latency or error counts where lower is better).
* Default: false
*/
invertDelta?: boolean
/**
* Custom formatter for metric values.
*/
valueFormatter?: (value: number) => string
/**
* Custom formatter for horizontal domain ticks.
*/
xFormatter?: (value: any) => string
/**
* Whether to display Cartesian background grid lines.
* Default: true
*/
showGrid?: boolean
/**
* Whether to display horizontal X axis ticks.
* Default: true
*/
showXAxis?: boolean
/**
* Whether to display vertical Y axis ticks.
* Default: true
*/
showYAxis?: boolean
/**
* Whether to render the series legend.
* Default: true
*/
showLegend?: boolean
/**
* Whether legend items can be clicked to toggle role visibility.
* Default: true
*/
interactiveLegend?: boolean
/**
* Enables persistent tooltip pinning on click, tap, or Enter/Space.
* Default: true
*/
lockableTooltip?: boolean
/**
* Controls entry transitions. Automatically bypassed under reduced motion.
* Default: true
*/
motion?: boolean | { duration?: number }
/**
* Accessible name announced by screen readers for the `<figure>` container.
* Default: "Comparison Area Chart"
*/
title?: string
/**
* Extended description for assistive technologies.
*/
description?: string
/**
* Displays loading placeholder skeleton while preserving dimensions.
*/
loading?: boolean
/**
* Error state indicator or Error instance.
*/
error?: Error | string | null
/**
* Unavailable notice (e.g. tier restrictions or retention limits).
*/
unavailable?: boolean | string | null
/**
* Callback invoked when user clicks error retry.
*/
onRetry?: () => void
/**
* Callback fired when active hovered/locked observation changes.
*/
onActiveDatumChange?: (datum: TData | null) => void
/**
* Additional CSS classes for root figure container.
*/
className?: string
}
/* -------------------------------------------------------------------------- */
/* Pure Math & Domain Safety Helpers */
/* -------------------------------------------------------------------------- */
function isFiniteNumber(val: unknown): val is number {
return typeof val === "number" && Number.isFinite(val)
}
/**
* Calculates a single honest shared Y-domain covering both Primary and Reference.
* Guarantees zero-division safety and truthful scaling.
*/
export function calculateSharedDomain(
data: readonly Record<string, unknown>[],
primaryKey: string,
referenceKey: string,
explicitDomain?: [number, number] | "auto",
visibleSeries: Set<"primary" | "reference"> = new Set(["primary", "reference"])
): [number, number] {
if (explicitDomain && explicitDomain !== "auto") {
return explicitDomain
}
const values: number[] = []
for (const row of data) {
if (visibleSeries.has("primary")) {
const p = row[primaryKey]
if (isFiniteNumber(p)) values.push(p)
}
if (visibleSeries.has("reference")) {
const r = row[referenceKey]
if (isFiniteNumber(r)) values.push(r)
}
}
if (values.length === 0) {
return [0, 100]
}
const min = Math.min(...values)
const max = Math.max(...values)
// Single-value scale protection
if (min === max) {
if (min === 0) return [-1, 1]
if (min > 0) return [0, Math.ceil(min * 1.25)]
return [Math.floor(min * 1.25), 0]
}
// Cross-zero or positive-only padding
const span = max - min
const pad = span * 0.08
const safeMin = min >= 0 ? 0 : Math.floor(min - pad)
const safeMax = Math.ceil(max + pad)
return [safeMin, safeMax]
}
/**
* Normalizes dataset without mutating original caller objects.
* Missing/non-finite observations are converted to null for truthful gap rendering.
*/
export function normalizeComparisonData<TData extends Record<string, unknown>>(
data: readonly TData[],
xKey: string,
primaryKey: string,
referenceKey: string
): Record<string, unknown>[] {
return data.map((item, idx) => {
const rawPrimary = item[primaryKey]
const rawReference = item[referenceKey]
const xVal = item[xKey] ?? `Point ${idx + 1}`
return {
...item,
[xKey]: xVal,
[primaryKey]: isFiniteNumber(rawPrimary) ? rawPrimary : null,
[referenceKey]: isFiniteNumber(rawReference) ? rawReference : null,
}
})
}
/* -------------------------------------------------------------------------- */
/* Synchronized Comparison Tooltip Component */
/* -------------------------------------------------------------------------- */
interface ComparisonAreaTooltipContentProps {
active?: boolean
payload?: readonly { dataKey?: string | number; value?: any; [key: string]: any }[]
label?: React.ReactNode
primaryKey: string
referenceKey: string
primaryLabel: string
referenceLabel: string
primaryColor: string
referenceColor: string
valueFormatter: (value: number) => string
showDelta?: boolean
deltaType?: DeltaType
invertDelta?: boolean
isCompact?: boolean
isLocked?: boolean
onUnlock?: () => void
}
function ComparisonAreaTooltipContent({
active,
payload,
label,
primaryKey,
referenceKey,
primaryLabel,
referenceLabel,
primaryColor,
referenceColor,
valueFormatter,
showDelta = true,
deltaType = "both",
invertDelta = false,
isCompact = false,
isLocked = false,
onUnlock,
}: ComparisonAreaTooltipContentProps) {
if (!active || !payload || payload.length === 0) {
return null
}
const primaryItem = payload.find((p) => p.dataKey === primaryKey)
const referenceItem = payload.find((p) => p.dataKey === referenceKey)
const primaryVal = primaryItem?.value
const referenceVal = referenceItem?.value
const hasPrimary = isFiniteNumber(primaryVal)
const hasReference = isFiniteNumber(referenceVal)
// Delta calculation: primary − reference
let absoluteDelta: number | null = null
let percentageDelta: number | null = null
if (hasPrimary && hasReference) {
absoluteDelta = primaryVal - referenceVal
if (referenceVal !== 0) {
percentageDelta = ((primaryVal - referenceVal) / Math.abs(referenceVal)) * 100
}
}
const formatDeltaString = () => {
if (absoluteDelta === null) return "—"
const sign = absoluteDelta > 0 ? "+" : absoluteDelta < 0 ? "−" : ""
const absVal = Math.abs(absoluteDelta)
if (deltaType === "absolute") {
return `${sign}${valueFormatter(absVal)}`
}
if (deltaType === "percentage") {
if (percentageDelta === null) return `${sign}${valueFormatter(absVal)}`
const pctSign = percentageDelta > 0 ? "+" : percentageDelta < 0 ? "−" : ""
return `${pctSign}${Math.abs(percentageDelta).toFixed(1)}%`
}
// "both"
if (percentageDelta !== null) {
const pctSign = percentageDelta > 0 ? "+" : percentageDelta < 0 ? "−" : ""
return `${sign}${valueFormatter(absVal)} (${pctSign}${Math.abs(percentageDelta).toFixed(1)}%)`
}
return `${sign}${valueFormatter(absVal)}`
}
return (
<div
className={cn(
"plotcn-interactive-tooltip z-50 rounded-lg border border-[var(--chart-tooltip-border)] bg-[var(--chart-tooltip-background)] shadow-lg backdrop-blur-md select-none",
isCompact
? "min-w-[120px] max-w-[190px] p-2 text-[10px]"
: "min-w-[190px] max-w-[270px] p-2.5 text-xs"
)}
>
{/* Header with coordinate & optional pin badge */}
<div className={cn("flex items-center justify-between gap-2 border-b border-white/[0.08] pb-1.5", isCompact ? "mb-1.5" : "mb-2")}>
<span className={cn("font-mono font-medium text-[var(--chart-tooltip-muted)] truncate", isCompact ? "text-[10px]" : "text-[11px]")}>
{label}
</span>
{isLocked && (
<button
type="button"
onClick={onUnlock}
aria-label="Unlock tooltip"
className={cn(
"flex items-center gap-1 rounded font-mono font-semibold transition-colors",
"bg-primary/20 text-primary hover:bg-primary/30",
isCompact ? "px-1 py-0.2 text-[8px]" : "px-1.5 py-0.5 text-[9px]"
)}
>
<HugeiconsIcon icon={LockKeyIcon} size={isCompact ? 9 : 11} />
<span>Locked</span>
</button>
)}
</div>
<div className={isCompact ? "space-y-1" : "space-y-1.5"}>
{/* Primary Series Row (Solid identity) */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={cn("rounded-xs border border-white/20 shrink-0", isCompact ? "size-2" : "size-2.5")}
style={{ backgroundColor: primaryColor }}
/>
<span className="font-medium text-[var(--chart-tooltip-foreground)] truncate">
{primaryLabel}
</span>
</div>
<span className="font-mono font-semibold text-[var(--chart-tooltip-foreground)] shrink-0">
{hasPrimary ? valueFormatter(primaryVal) : "—"}
</span>
</div>
{/* Reference Series Row (Quieter dashed identity) */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={cn("rounded-xs border border-dashed border-white/40 shrink-0 opacity-80", isCompact ? "size-2" : "size-2.5")}
style={{ backgroundColor: referenceColor }}
/>
<span className="font-medium text-[var(--chart-tooltip-foreground)] truncate">
{referenceLabel}
</span>
</div>
<span className="font-mono font-semibold text-[var(--chart-tooltip-foreground)] shrink-0">
{hasReference ? valueFormatter(referenceVal) : "—"}
</span>
</div>
{/* Delta Row (Neutral factual arithmetic) */}
{showDelta && (
<div className={cn("mt-2 flex items-center justify-between border-t border-white/[0.08] pt-1.5", isCompact ? "text-[9px]" : "text-[11px]")}>
<span className="text-[var(--chart-tooltip-muted)] font-medium">Difference</span>
<span className="font-mono font-medium text-[var(--chart-tooltip-foreground)]">
{formatDeltaString()}
</span>
</div>
)}
</div>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Custom Legend Component with Non-Color Structural Samples */
/* -------------------------------------------------------------------------- */
interface ComparisonAreaLegendProps {
primaryLabel: string
referenceLabel: string
primaryColor: string
referenceColor: string
visibleSeries: Set<"primary" | "reference">
onToggle: (role: "primary" | "reference") => void
interactive?: boolean
isCompact?: boolean
}
function ComparisonAreaLegend({
primaryLabel,
referenceLabel,
primaryColor,
referenceColor,
visibleSeries,
onToggle,
interactive = true,
isCompact = false,
}: ComparisonAreaLegendProps) {
const isPrimaryVisible = visibleSeries.has("primary")
const isReferenceVisible = visibleSeries.has("reference")
return (
<div className={cn("flex flex-wrap items-center justify-center gap-4 pt-3 select-none", isCompact ? "text-[10px]" : "text-xs")}>
{/* Primary Legend Item (Solid boundary & fill) */}
<button
type="button"
disabled={!interactive}
onClick={() => onToggle("primary")}
aria-pressed={isPrimaryVisible}
className={cn(
"flex items-center gap-1.5 transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--chart-focus)] rounded px-1.5 py-0.5",
!isPrimaryVisible && "opacity-40 line-through text-muted-foreground",
interactive ? "cursor-pointer hover:opacity-80" : "cursor-default"
)}
>
<span
className="relative inline-flex items-center justify-center size-3 rounded-xs border border-white/20 shrink-0"
style={{ backgroundColor: primaryColor }}
>
<span className="w-2 h-0.5 bg-white/70 rounded-full" />
</span>
<span className="font-medium text-foreground">{primaryLabel}</span>
</button>
{/* Reference Legend Item (Dashed boundary & quieter fill) */}
<button
type="button"
disabled={!interactive}
onClick={() => onToggle("reference")}
aria-pressed={isReferenceVisible}
className={cn(
"flex items-center gap-1.5 transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--chart-focus)] rounded px-1.5 py-0.5",
!isReferenceVisible && "opacity-40 line-through text-muted-foreground",
interactive ? "cursor-pointer hover:opacity-80" : "cursor-default"
)}
>
<span
className="relative inline-flex items-center justify-center size-3 rounded-xs border border-dashed border-white/50 shrink-0 opacity-80"
style={{ backgroundColor: referenceColor }}
>
<span className="w-2 h-0.5 border-t border-dashed border-white/90" />
</span>
<span className="font-medium text-foreground">{referenceLabel}</span>
</button>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Main Component: ComparisonArea */
/* -------------------------------------------------------------------------- */
export function ComparisonArea<TData extends Record<string, unknown> = Record<string, unknown>>({
data = [],
xKey,
series,
height = 320,
curve = "monotone",
domain,
primaryColor = "var(--chart-1)",
referenceColor = "var(--chart-2)",
selectionColor = "var(--chart-selection)",
fillOpacity = 0.28,
primaryFillOpacity,
referenceFillOpacity,
missingValuePolicy = "gap",
showDelta = true,
deltaType = "both",
invertDelta = false,
valueFormatter = (v: number) => v.toLocaleString(),
xFormatter,
showGrid = true,
showXAxis = true,
showYAxis = true,
showLegend = true,
interactiveLegend = true,
lockableTooltip = true,
motion = true,
title = "Comparison Area Chart",
description,
loading = false,
error = null,
unavailable = false,
onRetry,
onActiveDatumChange,
className,
}: ComparisonAreaProps<TData>) {
const isReducedMotion = useChartReducedMotion()
// Diagnostic warning for duplicate keys
React.useEffect(() => {
if (process.env.NODE_ENV !== "production") {
if (series.primary.key === series.reference.key) {
console.warn(
`[Plotcn ComparisonArea]: Duplicate role key detected: primary and reference both use "${series.primary.key}". Comparison requires two distinct series keys.`
)
}
}
}, [series.primary.key, series.reference.key])
// Resolved colors: explicit prop > series config > default token
const resolvedPrimaryColor = primaryColor || series.primary.color || "var(--chart-1)"
const resolvedReferenceColor = referenceColor || series.reference.color || "var(--chart-2)"
const resolvedPrimaryOpacity = typeof primaryFillOpacity === "number" ? primaryFillOpacity : fillOpacity
const resolvedReferenceOpacity =
typeof referenceFillOpacity === "number" ? referenceFillOpacity : Math.max(0.08, resolvedPrimaryOpacity * 0.5)
// Interactive legend visibility state
const [visibleSeries, setVisibleSeries] = React.useState<Set<"primary" | "reference">>(
new Set(["primary", "reference"])
)
const handleToggleRole = React.useCallback((role: "primary" | "reference") => {
setVisibleSeries((prev) => {
const next = new Set(prev)
if (next.has(role)) {
next.delete(role)
} else {
next.add(role)
}
return next
})
}, [])
const handleRestoreRoles = React.useCallback(() => {
setVisibleSeries(new Set(["primary", "reference"]))
}, [])
// Normalized dataset
const normalizedData = React.useMemo(
() => normalizeComparisonData(data, xKey, series.primary.key, series.reference.key),
[data, xKey, series.primary.key, series.reference.key]
)
// Shared honest Y-domain
const sharedDomain = React.useMemo(
() => calculateSharedDomain(normalizedData, series.primary.key, series.reference.key, domain, visibleSeries),
[normalizedData, series.primary.key, series.reference.key, domain, visibleSeries]
)
// Locked inspection state
const [lockedIndex, setLockedIndex] = React.useState<number | null>(null)
const [hoverIndex, setHoverIndex] = React.useState<number | null>(null)
const activeIndex = lockedIndex ?? hoverIndex
const activeDatum = React.useMemo(() => {
if (activeIndex === null || activeIndex < 0 || activeIndex >= normalizedData.length) {
return null
}
return (data[activeIndex] as TData) || null
}, [activeIndex, normalizedData.length, data])
React.useEffect(() => {
onActiveDatumChange?.(activeDatum)
}, [activeDatum, onActiveDatumChange])
// Keyboard navigation
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (!normalizedData.length) return
const maxIdx = normalizedData.length - 1
const current = activeIndex ?? 0
switch (e.key) {
case "ArrowRight":
case "ArrowDown": {
e.preventDefault()
const next = Math.min(maxIdx, current + 1)
if (lockedIndex !== null) setLockedIndex(next)
else setHoverIndex(next)
break
}
case "ArrowLeft":
case "ArrowUp": {
e.preventDefault()
const prev = Math.max(0, current - 1)
if (lockedIndex !== null) setLockedIndex(prev)
else setHoverIndex(prev)
break
}
case "Home": {
e.preventDefault()
if (lockedIndex !== null) setLockedIndex(0)
else setHoverIndex(0)
break
}
case "End": {
e.preventDefault()
if (lockedIndex !== null) setLockedIndex(maxIdx)
else setHoverIndex(maxIdx)
break
}
case "Enter":
case " ": {
if (!lockableTooltip) return
e.preventDefault()
if (lockedIndex !== null) setLockedIndex(null)
else setLockedIndex(current)
break
}
case "Escape": {
if (lockedIndex !== null) {
e.preventDefault()
setLockedIndex(null)
}
break
}
}
},
[normalizedData.length, activeIndex, lockedIndex, lockableTooltip]
)
const handleChartClick = React.useCallback(
(state: any) => {
if (!lockableTooltip) return
if (state && state.activeTooltipIndex !== undefined) {
const clicked = Number(state.activeTooltipIndex)
setLockedIndex((prev) => (prev === clicked ? null : clicked))
} else if (lockedIndex !== null) {
setLockedIndex(null)
}
},
[lockableTooltip, lockedIndex]
)
/* ------------------------------------------------------------------------ */
/* Early Return States */
/* ------------------------------------------------------------------------ */
if (loading) {
return (
<figure
role="region"
aria-label={title || "Comparison area loading"}
className={cn("plotcn-comparison-area relative w-full overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-4", className)}
style={{ height, minHeight: typeof height === "number" ? height : 320 }}
>
<ChartLoadingState description="Loading comparison data..." />
</figure>
)
}
if (unavailable) {
return (
<figure
role="region"
aria-label={title || "Comparison area unavailable"}
className={cn("plotcn-comparison-area relative w-full overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-4", className)}
style={{ height, minHeight: typeof height === "number" ? height : 320 }}
>
<ChartUnavailableState description={typeof unavailable === "string" ? unavailable : "Comparison data is currently unavailable."} />
</figure>
)
}
if (error) {
const errorDescription = error instanceof Error ? error.message : typeof error === "string" ? error : "An error occurred."
return (
<figure
role="region"
aria-label={title || "Comparison area error"}
className={cn("plotcn-comparison-area relative w-full overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-4", className)}
style={{ height, minHeight: typeof height === "number" ? height : 320 }}
>
<ChartErrorState
title="Comparison Area Configuration Error"
description={errorDescription}
onRetry={onRetry}
/>
</figure>
)
}
if (!normalizedData || normalizedData.length === 0) {
return (
<figure
role="region"
aria-label={title || "Comparison area empty"}
className={cn("plotcn-comparison-area relative w-full overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-4", className)}
style={{ height, minHeight: typeof height === "number" ? height : 320 }}
>
<ChartEmptyState description="No comparison observations recorded." />
</figure>
)
}
const isCompact = typeof height === "number" ? height <= 260 : false
const allHidden = visibleSeries.size === 0
return (
<figure
role="region"
aria-label={title}
tabIndex={0}
onKeyDown={handleKeyDown}
className={cn(
"plotcn-comparison-area relative flex flex-col w-full overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-4 outline-none focus-visible:ring-2 focus-visible:ring-[var(--chart-focus)]",
className
)}
style={{ height, minHeight: typeof height === "number" ? height : 320 }}
>
{/* Live Screen Reader Announcement */}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{activeDatum
? `${activeDatum[xKey]}. ${series.primary.label}: ${
activeDatum[series.primary.key] !== null ? valueFormatter(activeDatum[series.primary.key] as number) : "Unavailable"
}, ${series.reference.label}: ${
activeDatum[series.reference.key] !== null ? valueFormatter(activeDatum[series.reference.key] as number) : "Unavailable"
}.`
: `${title}. Comparing ${series.primary.label} against ${series.reference.label} across ${normalizedData.length} observations.`}
</div>
{/* Semantic off-screen data table for accessibility (100% canvas height preserved for SVG) */}
<div className="sr-only">
<table>
<caption>{title} - Data Table</caption>
<thead>
<tr>
<th scope="col">{xKey}</th>
<th scope="col">{series.primary.label}</th>
<th scope="col">{series.reference.label}</th>
{showDelta && <th scope="col">Difference</th>}
</tr>
</thead>
<tbody>
{normalizedData.map((row, idx) => {
const pVal = row[series.primary.key]
const rVal = row[series.reference.key]
const hasP = isFiniteNumber(pVal)
const hasR = isFiniteNumber(rVal)
const deltaVal = hasP && hasR ? pVal - rVal : null
return (
<tr key={idx}>
<td>{String(row[xKey] ?? "")}</td>
<td>{hasP ? valueFormatter(pVal) : "Unavailable"}</td>
<td>{hasR ? valueFormatter(rVal) : "Unavailable"}</td>
{showDelta && (
<td>
{deltaVal !== null
? `${deltaVal > 0 ? "+" : deltaVal < 0 ? "−" : ""}${valueFormatter(Math.abs(deltaVal))}`
: "Unavailable"}
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
{/* All-series-hidden recovery state */}
{allHidden ? (
<div className="flex flex-1 flex-col items-center justify-center text-center p-6 gap-3">
<p className="text-sm font-medium text-muted-foreground">All comparison series are hidden.</p>
<button
type="button"
onClick={handleRestoreRoles}
className="rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground hover:bg-muted transition-colors"
>
Restore comparison series
</button>
</div>
) : (
<div className="flex-1 w-full min-h-0">
<ChartContainer className="h-full w-full">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={0}
minHeight={0}
initialDimension={{ width: 600, height: typeof height === "number" ? height : 320 }}
>
<AreaChart
data={normalizedData}
onClick={handleChartClick}
onMouseMove={(e: any) => {
if (lockedIndex === null && e?.activeTooltipIndex !== undefined) {
setHoverIndex(Number(e.activeTooltipIndex))
}
}}
onMouseLeave={() => {
if (lockedIndex === null) setHoverIndex(null)
}}
margin={{ top: 12, right: 16, bottom: 8, left: 4 }}
>
{showGrid && (
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--chart-grid)"
vertical={false}
strokeOpacity={0.7}
/>
)}
{showXAxis && (
<XAxis
dataKey={xKey as any}
stroke="var(--chart-axis, #a1a1aa)"
tick={{ fill: "var(--chart-axis, #a1a1aa)", fontSize: isCompact ? 10 : 11 }}
tickLine={false}
axisLine={{ stroke: "var(--chart-axis-line, rgba(255,255,255,0.12))", strokeOpacity: 0.5 }}
tickFormatter={xFormatter}
/>
)}
{showYAxis && (
<YAxis
domain={sharedDomain as any}
stroke="var(--chart-axis, #a1a1aa)"
tick={{ fill: "var(--chart-axis, #a1a1aa)", fontSize: isCompact ? 10 : 11 }}
tickLine={false}
axisLine={false}
tickFormatter={valueFormatter}
width={isCompact ? 40 : 50}
/>
)}
<Tooltip
isAnimationActive={false}
cursor={{
stroke: lockedIndex !== null ? selectionColor : "var(--chart-crosshair)",
strokeWidth: 1.5,
strokeDasharray: "4 4",
}}
content={
<ComparisonAreaTooltipContent
primaryKey={series.primary.key}
referenceKey={series.reference.key}
primaryLabel={series.primary.label}
referenceLabel={series.reference.label}
primaryColor={resolvedPrimaryColor}
referenceColor={resolvedReferenceColor}
valueFormatter={valueFormatter}
showDelta={showDelta}
deltaType={deltaType}
invertDelta={invertDelta}
isCompact={isCompact}
isLocked={lockedIndex !== null}
onUnlock={() => setLockedIndex(null)}
/>
}
/>
{/* 1. Reference Area (rendered FIRST, sits underneath with dashed stroke and quieter fill) */}
<Area
type={curve}
dataKey={series.reference.key as any}
name={series.reference.label}
stroke={resolvedReferenceColor}
strokeWidth={1.5}
strokeDasharray="4 3"
fill={resolvedReferenceColor}
fillOpacity={resolvedReferenceOpacity}
connectNulls={missingValuePolicy === "connect"}
isAnimationActive={motion !== false && !isReducedMotion}
animationDuration={typeof motion === "object" && motion.duration ? motion.duration : 350}
hide={!visibleSeries.has("reference")}
dot={false}
activeDot={{
r: 4.5,
fill: resolvedReferenceColor,
stroke: "var(--background, #09090b)",
strokeWidth: 2,
}}
/>
{/* 2. Primary Area (rendered SECOND, sits on top with solid boundary and stronger fill) */}
<Area
type={curve}
dataKey={series.primary.key as any}
name={series.primary.label}
stroke={resolvedPrimaryColor}
strokeWidth={2}
fill={resolvedPrimaryColor}
fillOpacity={resolvedPrimaryOpacity}
connectNulls={missingValuePolicy === "connect"}
isAnimationActive={motion !== false && !isReducedMotion}
animationDuration={typeof motion === "object" && motion.duration ? motion.duration : 350}
hide={!visibleSeries.has("primary")}
dot={false}
activeDot={{
r: 5.5,
fill: resolvedPrimaryColor,
stroke: "var(--background, #09090b)",
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>
</ChartContainer>
</div>
)}
{/* Series Legend */}
{showLegend && (
<ComparisonAreaLegend
primaryLabel={series.primary.label}
referenceLabel={series.reference.label}
primaryColor={resolvedPrimaryColor}
referenceColor={resolvedReferenceColor}
visibleSeries={visibleSeries}
onToggle={handleToggleRole}
interactive={interactiveLegend}
isCompact={isCompact}
/>
)}
</figure>
)
}