{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "line-chart",
  "type": "registry:component",
  "title": "Line Chart",
  "description": "A series over time, drawn in from the left.",
  "dependencies": [
    "@base-ui/react",
    "@tanstack/charts",
    "d3-shape",
    "tailwind-variants"
  ],
  "devDependencies": [
    "@types/d3-shape"
  ],
  "registryDependencies": [],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/line-chart/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/line-chart/index.tsx",
      "content": "'use client';\n\n/**\n * LineChart — a series over time, drawn in from the left.\n *\n * ```tsx\n * <LineChart data={visits} xDataKey=\"date\">\n *   <LineChart.Grid />\n *   <LineChart.Area dataKey=\"visits\" />\n *   <LineChart.Line dataKey=\"visits\" />\n *   <LineChart.XAxis />\n *   <LineChart.Tooltip />\n * </LineChart>\n * ```\n *\n * ## The same parts as the app\n *\n * The parts and their props are the app library's LineChart, so a chart written\n * for one reads the same in the other. What differs is what the web changes: a\n * colour is named by the theme's role, a point is read by hovering, tapping or\n * dragging, a click pins the reading, and the arrow keys walk the points.\n *\n * ## The parts describe, the root draws\n *\n * `Grid`, `Line`, `Area`, the axes and `Tooltip` draw nothing themselves. Each one\n * tells the root what it wants, and the root builds one chart definition from\n * them. They are found by context and not by inspecting children, so a part can\n * be wrapped in a component of your own. It also means the chart draws in the\n * browser, in a box its `aspectRatio` has reserved.\n *\n * ## One reveal, then the scale moves\n *\n * A new chart uncovers its series from the left by a clip, so a line, its fill and\n * its markers arrive together while the grid and axes are already there. After\n * that a change of data moves the points and the axis to their new values, and\n * the reveal does not play again: repeating it would turn a refresh into an\n * animation.\n *\n * ## Web additions\n *\n * `ReferenceLine`, `Marker` and the `step` curve are not in the app library. They\n * are for reading a value against a plan without hovering.\n */\nimport { areaY, d3Curve, defineChart, dot, lineY, ruleX, ruleY, text, type ChartInteractionController, type ChartMotionContext, type ChartPoint } from '@tanstack/charts';\nimport { crosshair } from '@tanstack/charts/crosshair';\nimport { whenFocused } from '@tanstack/charts/focus/mark';\nimport { decorative } from '@tanstack/charts/mark/decorative';\nimport { motion } from '@tanstack/charts/motion';\nimport { Chart } from '@tanstack/charts/react/core';\nimport { scaleLinear } from '@tanstack/charts/scales/linear';\nimport { tooltip as chartTooltip } from '@tanstack/charts/tooltip';\nimport { curveLinear, curveMonotoneX, curveStepAfter } from 'd3-shape';\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\nimport { lineDomain, readValue, tickIndexes } from './line-scale';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\nexport type LineChartStatus = 'loading' | 'ready';\nexport type LineChartCurve = 'monotone' | 'linear' | 'step';\nexport type LineChartDatum = Record<string, string | number | null | undefined>;\n\n/** The theme's colour roles. */\ntype LineChartRole =\n  | 'primary'\n  | 'info'\n  | 'success'\n  | 'warning'\n  | 'destructive'\n  | 'chart-1'\n  | 'chart-2'\n  | 'chart-3'\n  | 'chart-4'\n  | 'chart-5'\n  | 'foreground'\n  | 'muted-foreground';\n\n/** A role in the theme, or any CSS colour. */\nexport type LineChartColor = LineChartRole | (string & {});\n\nconst COLOR_VAR: Record<LineChartRole, string> = {\n  primary: 'var(--primary)',\n  info: 'var(--info)',\n  success: 'var(--success)',\n  warning: 'var(--warning)',\n  destructive: 'var(--destructive)',\n  'chart-1': 'var(--chart-1)',\n  'chart-2': 'var(--chart-2)',\n  'chart-3': 'var(--chart-3)',\n  'chart-4': 'var(--chart-4)',\n  'chart-5': 'var(--chart-5)',\n  foreground: 'var(--foreground)',\n  'muted-foreground': 'var(--muted-foreground)',\n};\n\nconst paintOf = (color: LineChartColor) => COLOR_VAR[color as LineChartRole] ?? color;\n\nconst THEME = {\n  foreground: 'var(--foreground)',\n  muted: 'var(--muted-foreground)',\n  grid: 'var(--border)',\n  background: 'transparent',\n  palette: ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'],\n};\n\nconst CURVES = { monotone: d3Curve(curveMonotoneX), linear: d3Curve(curveLinear), step: d3Curve(curveStepAfter) };\n\n/** The size of a reference line's or a marker's label. */\nconst ANNOTATION_FONT_SIZE = 11;\n\nconst lineChartVariants = tv({\n  slots: {\n    root: 'relative grid w-full grid-cols-1 text-foreground',\n    plot: 'relative col-start-1 row-start-2 w-full',\n    // `pan-y` hands a vertical drag to the page and a sideways one to the chart.\n    chart:\n      'absolute! inset-0 touch-pan-y rounded-lg text-xs [--ts-chart-tooltip-background:var(--popover)] [--ts-chart-tooltip-border-radius:var(--radius-xl)] [--ts-chart-tooltip-border:1px_solid_var(--border)] [--ts-chart-tooltip-color:var(--popover-foreground)] [--ts-chart-tooltip-shadow:var(--shadow-lg)] [&_svg]:rounded-lg [&_svg]:outline-none [&_svg:focus-visible]:ring-2 [&_svg:focus-visible]:ring-ring',\n    skeleton: 'pointer-events-none absolute overflow-hidden rounded-full bg-muted',\n    sweep:\n      'absolute inset-y-0 left-0 w-2/5 bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--chart-1)_55%,transparent),transparent)] motion-reduce:hidden',\n    legend:\n      'pointer-events-none absolute right-2 top-1 col-start-1 row-start-2 flex flex-wrap justify-end gap-x-3 gap-y-1 text-xs text-muted-foreground',\n    legendItem: 'flex items-center gap-1.5',\n    legendSwatch: 'size-2 shrink-0 rounded-full',\n    header: 'col-start-1 row-start-1 flex items-start justify-between gap-3 pb-3',\n    headerText: 'flex min-w-0 flex-1 flex-col gap-0.5',\n    headerMuted: 'text-xs text-muted-foreground',\n    headerValue: 'text-xl font-bold tabular-nums tracking-tight',\n    headerTrailing: 'flex shrink flex-wrap items-center justify-end gap-x-3 gap-y-1 pt-1 text-xs text-muted-foreground',\n  },\n});\n\n/** The value-axis gutter held while loading, when there are no labels to size it from. */\nconst LOADING_GUTTER = 44;\n\n/** The stroke of the skeleton's rule, the default of a line. */\nconst SKELETON_THICKNESS = 2.5;\n\n/** Keeps a compact line's stroke from being cut at the edges of its box. */\nconst COMPACT_MARGIN = { top: 2, right: 1, bottom: 2, left: 1 };\n\n/*\n * Measured against the whole drawing, so a line that starts late is not revealed\n * late as well. Nothing but the series moves: the grid and axes are already there.\n */\nconst STYLES = `@keyframes panelui-line-sweep{from{transform:translateX(-100%)}to{transform:translateX(250%)}}\n@keyframes panelui-line-reveal{from{clip-path:inset(0 100% 0 0) view-box}to{clip-path:inset(0 0 0 0) view-box}}\n@media (prefers-reduced-motion:no-preference){[data-slot=line-chart-plot][data-revealing] :is(.ts-chart__line,.ts-chart__area){animation:panelui-line-reveal var(--line-reveal) cubic-bezier(.33,1,.68,1) both}}`;\n\nconst COMPACT = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });\nconst COMPACT_SMALL = new Intl.NumberFormat('en', { notation: 'compact', maximumSignificantDigits: 2 });\n\n/** Below one, a single decimal would round most values down to nothing. */\nconst compactNumber = (value: number) => (Math.abs(value) < 1 ? COMPACT_SMALL : COMPACT).format(value);\n\n/** One point of one series. `value` is null across a gap. */\ninterface LineRow {\n  index: number;\n  series: string;\n  value: number | null;\n}\n\ninterface LineEntry {\n  key: string;\n  stroke: string;\n  strokeWidth: number;\n  dashArray: string | undefined;\n  showMarkers: boolean;\n  curve: LineChartCurve | undefined;\n}\n\ninterface AreaEntry {\n  key: string;\n  fill: string;\n  opacity: number;\n}\n\n/** A rule from `ReferenceLine` (across, at a value) or `Marker` (down, at a row). */\ninterface Annotation {\n  id: string;\n  axis: 'x' | 'y';\n  at: number;\n  stroke: string;\n  dashArray: string;\n  label: string | undefined;\n}\n\n/** Where the drawing put the plot, in pixels inside the chart's box. */\ninterface PlotBox {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\n/**\n * What the parts have asked for. An axis formatter is part of the drawing, so a new one redraws it.\n * The readout's are read through `latest` when it opens, so a new one costs nothing.\n */\ninterface Slots {\n  grid?: { rows: number; dashArray: string | undefined; opacity: number; stroke: string };\n  xAxis?: { ticks: number; format: LineChartXAxisProps['format'] };\n  yAxis?: { ticks: number; format: LineChartYAxisProps['format'] };\n  tooltip?: {\n    stroke: string;\n    showLabel: boolean;\n    className: string | undefined;\n    latest: { current: Pick<LineChartTooltipProps, 'formatValue' | 'formatX' | 'labels'> };\n  };\n  skeleton?: { duration: number; base: string | undefined };\n}\n\ninterface LineChartContextValue {\n  data: readonly LineChartDatum[];\n  xDataKey: string;\n  lines: readonly LineEntry[];\n  setLine: (entry: LineEntry) => void;\n  removeLine: (key: string) => void;\n  setArea: (entry: AreaEntry) => void;\n  removeArea: (key: string) => void;\n  setAnnotation: (entry: Annotation) => void;\n  removeAnnotation: (id: string) => void;\n  setSlot: <K extends keyof Slots>(slot: K, value: Slots[K]) => void;\n  activeIndex: number;\n}\n\nconst LineChartContext = createContext<LineChartContextValue | null>(null);\n\nfunction useChart(component: string): LineChartContextValue {\n  const context = useContext(LineChartContext);\n  if (!context) {\n    throw new Error(`${component} must be used within a <LineChart>`);\n  }\n  return context;\n}\n\n/**\n * The point being read, for something rendered inside the chart. A readout\n * outside it takes `onActiveIndexChange` instead.\n */\nexport function useLineChart() {\n  const { data, activeIndex, xDataKey } = useChart('useLineChart');\n  return {\n    activeIndex,\n    activePoint: activeIndex >= 0 ? (data[activeIndex] ?? null) : null,\n    xDataKey,\n  };\n}\n\n/** Puts an entry in its place, or at the end if it is new, so a change keeps the order. */\nfunction upsert<Entry extends { key: string }>(current: readonly Entry[], entry: Entry): readonly Entry[] {\n  return current.some(({ key }) => key === entry.key)\n    ? current.map((existing) => (existing.key === entry.key ? entry : existing))\n    : [...current, entry];\n}\n\nexport interface LineChartProps extends Omit<ComponentProps<'div'>, 'ref'> {\n  /** The rows. Each one is a point along the x-axis. */\n  data: readonly LineChartDatum[];\n  /** Key holding the x label. Used by the axis and the readout. */\n  xDataKey?: string;\n  /**\n   * `loading` holds the series back and draws them in when it turns `ready`. Add\n   * a `LineChart.Skeleton` for something to stand in the plot meanwhile.\n   */\n  status?: LineChartStatus;\n  /** The plot's width ÷ height. `2` is the wide card shape; `1.6` suits a narrow column. */\n  aspectRatio?: number;\n  /** Milliseconds for the reveal. */\n  animationDuration?: number;\n  /** Milliseconds for the points and the axis to settle after the data changes. */\n  domainDuration?: number;\n  /** Fix the value axis instead of deriving it from the data. */\n  yDomain?: [number, number];\n  /** `monotone` never overshoots between points; `linear` joins them straight; `step` holds each value until the next, for a balance that moves on the day money does. */\n  curve?: LineChartCurve;\n  /**\n   * The point being read, and `-1`/`null` when there is none. Fires when the\n   * index changes, not per frame.\n   */\n  onActiveIndexChange?: (index: number, datum: LineChartDatum | null) => void;\n  /** Drop the axis padding so the line reaches the edges, for a sparkline. */\n  compact?: boolean;\n  /** What a screen reader calls the chart. */\n  label?: string;\n  /** What a screen reader says for one row. Defaults to its keys and values. */\n  labelForDatum?: (datum: LineChartDatum, index: number) => string;\n}\n\n/** Imperative handle: re-run the reveal, for a \"replay\" control. */\nexport interface LineChartHandle {\n  replay: () => void;\n}\n\nconst LineChartRoot = forwardRef<LineChartHandle, LineChartProps & RenderProp<'div'>>(function LineChartRoot(\n  {\n    data,\n    xDataKey = 'date',\n    status = 'ready',\n    aspectRatio = 2,\n    animationDuration = 700,\n    domainDuration = 500,\n    yDomain,\n    curve = 'monotone',\n    onActiveIndexChange,\n    compact = false,\n    label,\n    labelForDatum,\n    className,\n    children,\n    render,\n    ...props\n  },\n  ref\n) {\n  const slotClasses = lineChartVariants();\n  const plotRef = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  const [lines, setLines] = useState<readonly LineEntry[]>([]);\n  const [areas, setAreas] = useState<readonly AreaEntry[]>([]);\n  const [annotations, setAnnotations] = useState<readonly Annotation[]>([]);\n  const [slots, setSlots] = useState<Slots>({});\n  const [activeIndex, setActiveIndex] = useState(-1);\n  const [revision, setRevision] = useState(0);\n  const [revealedKey, setRevealedKey] = useState('');\n  const [box, setBox] = useState<PlotBox | null>(null);\n  const interactionRef = useRef<ChartInteractionController<LineRow, number, number> | null>(null);\n  const dragging = useRef(false);\n  // Unique per chart: gradients are found by id across the whole page.\n  const gradientPrefix = `panelui-line-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;\n\n  /*\n   * The chart mounts once its width is known. Mounted at a guess, the first\n   * measurement is a resize, and a resize ends the entrance before it is seen.\n   */\n  useEffect(() => {\n    const node = plotRef.current;\n    if (!node) return;\n    const read = () => setWidth(node.clientWidth);\n    read();\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const setLine = useCallback((entry: LineEntry) => setLines((current) => upsert(current, entry)), []);\n  const removeLine = useCallback(\n    (key: string) => setLines((current) => current.filter((entry) => entry.key !== key)),\n    []\n  );\n  const setArea = useCallback((entry: AreaEntry) => setAreas((current) => upsert(current, entry)), []);\n  const removeArea = useCallback(\n    (key: string) => setAreas((current) => current.filter((entry) => entry.key !== key)),\n    []\n  );\n  const setAnnotation = useCallback(\n    (entry: Annotation) =>\n      setAnnotations((current) =>\n        current.some(({ id }) => id === entry.id)\n          ? current.map((existing) => (existing.id === entry.id ? entry : existing))\n          : [...current, entry]\n      ),\n    []\n  );\n  const removeAnnotation = useCallback(\n    (id: string) => setAnnotations((current) => current.filter((entry) => entry.id !== id)),\n    []\n  );\n  const setSlot = useCallback<LineChartContextValue['setSlot']>(\n    (slot, value) => setSlots((current) => ({ ...current, [slot]: value })),\n    []\n  );\n\n  useImperativeHandle(ref, () => ({ replay: () => setRevision((current) => current + 1) }), []);\n\n  const loading = status === 'loading';\n  const chartKey = `${revision}:${status}`;\n  // A finished reveal counts for this load only, so the next one plays again.\n  useEffect(() => {\n    if (loading) setRevealedKey('');\n  }, [loading]);\n  const { grid, xAxis, yAxis, tooltip, skeleton } = slots;\n\n  const definition = useMemo(() => {\n    const span = Math.max(1, data.length - 1);\n    const rowsOf = (key: string): LineRow[] =>\n      data.map((datum, index) => ({ index, series: key, value: readValue(datum[key]) }));\n\n    const valueTicks = yAxis?.ticks ?? grid?.rows ?? 4;\n    const domain =\n      yDomain ??\n      lineDomain(\n        data,\n        [...lines, ...areas].map(({ key }) => key),\n        valueTicks,\n        annotations.filter(({ axis }) => axis === 'y').map(({ at }) => at)\n      );\n\n    const gradientId = (key: string) => `${gradientPrefix}-${areas.findIndex((entry) => entry.key === key)}`;\n    // Closed against the bottom of the plot, not zero, which may be far outside it.\n    const areaMarks = areas.map(({ key }) =>\n      decorative(\n        areaY(rowsOf(key), {\n          id: `line-chart-area-${key}`,\n          x: 'index',\n          y1: () => domain[0],\n          y2: 'value',\n          key: 'index',\n          fill: `url(#${gradientId(key)})`,\n          fillOpacity: 1,\n          // Follows its line, so a stepped balance is not filled under a smooth edge.\n          curve: CURVES[lines.find((line) => line.key === key)?.curve ?? curve],\n        })\n      )\n    );\n    // One mark per series, because a mark has one stroke width and one dash.\n    const lineMarks = lines.map(({ key, stroke, strokeWidth, dashArray, showMarkers, curve: own }) =>\n      lineY(rowsOf(key), {\n        id: `line-chart-line-${key}`,\n        x: 'index',\n        y: 'value',\n        key: 'index',\n        stroke,\n        strokeWidth,\n        strokeDasharray: dashArray,\n        points: showMarkers,\n        lineCap: 'round',\n        lineJoin: 'round',\n        curve: CURVES[own ?? curve],\n      })\n    );\n    // A computed value can be infinite, and a row can be gone once the data shrinks.\n    const drawable = annotations.filter(({ axis, at }) =>\n      axis === 'y' ? Number.isFinite(at) : Number.isInteger(at) && at >= 0 && at < data.length\n    );\n    const annotationMarks = drawable.flatMap(({ id, axis, at, stroke, dashArray, label: name }) => {\n      const nearEnd = axis === 'x' && at > span * 0.75;\n      return [\n        decorative(\n          axis === 'y'\n            ? ruleY([at], { id: `line-chart-rule-${id}`, stroke, strokeDasharray: dashArray, strokeWidth: 1 })\n            : ruleX([at], { id: `line-chart-rule-${id}`, stroke, strokeDasharray: dashArray, strokeWidth: 1 })\n        ),\n        ...(name\n          ? [\n              decorative(\n                text([name], {\n                  id: `line-chart-rule-label-${id}`,\n                  x: () => (axis === 'y' ? 0 : at),\n                  y: () => (axis === 'y' ? at : domain[1]),\n                  text: () => name,\n                  fill: stroke,\n                  fontSize: ANNOTATION_FONT_SIZE,\n                  anchor: nearEnd ? 'end' : 'start',\n                  dx: nearEnd ? -6 : axis === 'y' ? 8 : 6,\n                  dy: axis === 'y' ? -8 : 8,\n                })\n              ),\n            ]\n          : []),\n      ];\n    });\n    // A dot riding each series at the point being read.\n    const dots = lines.map(({ key, stroke }) =>\n      whenFocused(\n        dot(\n          rowsOf(key).filter((row) => row.value !== null),\n          { id: `line-chart-dot-${key}`, x: 'index', y: 'value', key: 'index', r: 4.5, fill: stroke }\n        ),\n        { match: 'x' }\n      )\n    );\n\n    const labelOf = (index: number) => {\n      const datum = data[index];\n      return datum ? String(datum[xDataKey] ?? '') : '';\n    };\n    const pointLabel = (index: number) => {\n      const datum = data[index];\n      const format = xAxis?.format;\n      return datum && format ? format(datum, index) : labelOf(index);\n    };\n\n    const xOptions = {\n      scale: scaleLinear([0, span], [0, 1]),\n      axis: xAxis\n        ? {\n            line: false as const,\n            // The two ends lean inward, clear of the bottom value label and the plot's edge.\n            tickLabels: {\n              opacity: 1,\n              anchor: ({ value }: { value: number }) =>\n                value === 0 ? ('start' as const) : value === data.length - 1 ? ('end' as const) : undefined,\n            },\n            ticks: { size: 0, format: pointLabel, values: tickIndexes(data.length, xAxis.ticks) },\n          }\n        : (false as const),\n    };\n\n    const valueFormat = (value: number) => {\n      if (loading) return '';\n      const format = yAxis?.format;\n      return format ? format(value) : compactNumber(value);\n    };\n    const yOptions = {\n      scale: scaleLinear(loading ? [0, 1] : domain, [0, 1]),\n      grid: grid ? { stroke: grid.stroke, strokeOpacity: grid.opacity, strokeDasharray: grid.dashArray } : false,\n      axis: yAxis\n        ? {\n            line: false as const,\n            tickLabels: { opacity: 1 },\n            ticks: { size: 0, format: valueFormat, count: valueTicks },\n          }\n        : grid\n          ? { line: false as const, ticks: { size: 0, count: valueTicks, format: () => '' } }\n          : (false as const),\n    };\n\n    const drawn = lines.some(({ key }) => data.some((datum) => readValue(datum[key]) !== null));\n    const interactive = tooltip !== undefined && !loading && drawn;\n    const readout =\n      tooltip?.showLabel === true\n        ? {\n            use: chartTooltip,\n            className: tooltip.className,\n            // Beside the crosshair and along the top, clear of the dots it is reading.\n            anchor: { x: 'value', y: 'plot-top' } as const,\n            placement: ['bottom-right', 'bottom-left'] as const,\n            offset: 12,\n            // From the row, not the points: focus across separate marks hands over only one.\n            content(points: readonly ChartPoint<LineRow, number, number>[]) {\n              const { formatValue, formatX, labels } = tooltip.latest.current;\n              const index = points[0]?.datum.index ?? -1;\n              const datum = data[index];\n              if (!datum) return { title: '', rows: [] };\n              return {\n                title: formatX ? formatX(datum) : labelOf(index),\n                rows: lines.flatMap(({ key, stroke }) => {\n                  const value = readValue(datum[key]);\n                  if (value === null) return [];\n                  return [\n                    {\n                      label: labels?.[key] ?? key,\n                      value: formatValue ? formatValue(value, key) : compactNumber(value),\n                      color: stroke,\n                    },\n                  ];\n                }),\n              };\n            },\n          }\n        : undefined;\n\n    return defineChart({\n      theme: THEME,\n      marks: loading\n        ? []\n        : [\n            ...areaMarks,\n            ...lineMarks,\n            ...annotationMarks,\n            ...(interactive\n              ? [crosshair<number, number>({ y: false, stroke: tooltip.stroke, strokeOpacity: 0.45, strokeWidth: 1 }), ...dots]\n              : []),\n          ],\n      scales: { x: xOptions, y: yOptions },\n      gradients: areas.map(({ key, fill, opacity }) => ({\n        id: gradientId(key),\n        x1: 0,\n        y1: 0,\n        x2: 0,\n        y2: 1,\n        stops: [\n          { offset: 0, color: fill, opacity },\n          { offset: 1, color: fill, opacity: 0 },\n        ],\n      })),\n      ...(compact ? { guides: false as const, margin: COMPACT_MARGIN } : {}),\n      ...(loading && yAxis ? { margin: { left: LOADING_GUTTER } } : {}),\n      focusRing: false,\n      // The series arrive by the clip in `STYLES`, and everything else is simply there.\n      motion: (context: ChartMotionContext<LineRow>) =>\n        context.phase === 'enter'\n          ? (false as const)\n          : { transition: { type: 'tween' as const, duration: domainDuration, easing: 'ease-out' as const } },\n      keyboard: interactive,\n      pointer: interactive,\n      ...(interactive\n        ? {\n            focus: 'group-x' as const,\n            maxFocusDistance: Number.POSITIVE_INFINITY,\n            ...(readout ? { tooltip: readout } : {}),\n          }\n        : {}),\n    });\n  }, [\n    data,\n    xDataKey,\n    lines,\n    areas,\n    annotations,\n    loading,\n    curve,\n    yDomain?.[0],\n    yDomain?.[1],\n    compact,\n    domainDuration,\n    gradientPrefix,\n    grid,\n    xAxis,\n    yAxis,\n    tooltip,\n  ]);\n\n  // Marks take their timing from the definition. This one is what the readout\n  // moves with, and it should keep up with the pointer.\n  const renderer = useMemo(() => motion({ transition: { type: 'tween', duration: 160, easing: 'ease-out' } }), []);\n\n  const summary = label ?? `Line chart, ${data.length} ${data.length === 1 ? 'row' : 'rows'}`;\n\n  const context = useMemo<LineChartContextValue>(\n    () => ({ data, xDataKey, lines, setLine, removeLine, setArea, removeArea, setAnnotation, removeAnnotation, setSlot, activeIndex }),\n    [data, xDataKey, lines, setLine, removeLine, setArea, removeArea, setAnnotation, removeAnnotation, setSlot, activeIndex]\n  );\n\n  const spokenRow = (datum: LineChartDatum, index: number) =>\n    labelForDatum?.(datum, index) ??\n    [...new Set([xDataKey, ...lines.map(({ key }) => key), ...areas.map(({ key }) => key)])]\n      .filter((key) => datum[key] !== null && datum[key] !== undefined)\n      .map((key) => `${key}, ${datum[key]}`)\n      .join('. ');\n\n  // The chart also reports a point again when the data is rebuilt under it. Only a\n  // change of point is news, and repeating it can loop a caller that keeps the datum.\n  const reportedIndex = useRef(-1);\n  const reportActive = (index: number) => {\n    if (reportedIndex.current === index) return;\n    reportedIndex.current = index;\n    setActiveIndex(index);\n    onActiveIndexChange?.(index, index >= 0 ? (data[index] ?? null) : null);\n  };\n  const onFocusChange = (point: ChartPoint<LineRow, number, number> | null) =>\n    reportActive(point ? point.datum.index : -1);\n\n  // A remounted chart says nothing about the focus it lost.\n  useEffect(() => reportActive(-1), [revision, status]);\n\n  /*\n   * The chart follows a mouse on its own but not a finger, which it treats as a\n   * tap. A sideways drag reads the points it crosses, and the last one stays until\n   * the next press, the same as a tap's. A vertical drag never gets here: `pan-y`\n   * gives it to the page, and the browser cancels the pointer.\n   */\n  const readDrag = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== 'touch' || !tooltip || loading) return;\n    const interaction = interactionRef.current;\n    const target = interaction?.resolvePointer(event.clientX, event.clientY);\n    if (!interaction || !target) return;\n    interaction.setControlledFocus(target);\n    if (dragging.current) return;\n    dragging.current = true;\n    document.addEventListener(\n      'pointerdown',\n      () => {\n        dragging.current = false;\n        interactionRef.current?.setControlledFocus(null);\n      },\n      { capture: true, once: true }\n    );\n  };\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'line-chart',\n        className: slotClasses.root({ className }),\n        children: (\n          <>\n            <style>{STYLES}</style>\n            {loading ? null : (\n              <ul aria-label={summary} className=\"sr-only\">\n                {data.map((datum, index) => (\n                  <li key={index}>{spokenRow(datum, index)}</li>\n                ))}\n              </ul>\n            )}\n            <div\n              ref={plotRef}\n              data-slot=\"line-chart-plot\"\n              data-revealing={!loading && revealedKey !== chartKey ? '' : undefined}\n              className={slotClasses.plot()}\n              style={{ aspectRatio, ['--line-reveal' as string]: `${animationDuration}ms` }}\n              onAnimationEnd={(event) => {\n                if (event.animationName === 'panelui-line-reveal') setRevealedKey(chartKey);\n              }}\n              onKeyDown={(event) => {\n                if (event.key === 'Escape') interactionRef.current?.setControlledFocus(null);\n              }}\n              onPointerMove={readDrag}\n              onPointerCancel={() => interactionRef.current?.setControlledFocus(null)}\n            >\n              {width > 0 ? (\n                <Chart\n                  // The reveal belongs to a new chart: a load finishing, or a replay.\n                  key={chartKey}\n                  definition={definition}\n                  renderer={renderer}\n                  ariaLabel={summary}\n                  aspectRatio={aspectRatio}\n                  initialWidth={width}\n                  className={slotClasses.chart()}\n                  onFocusChange={onFocusChange}\n                  onRender={({ scene, interaction }) => {\n                    interactionRef.current = interaction;\n                    const { x, y, width: plotWidth, height } = scene.chart;\n                    setBox((current) =>\n                      current &&\n                      current.x === x &&\n                      current.y === y &&\n                      current.width === plotWidth &&\n                      current.height === height\n                        ? current\n                        : { x, y, width: plotWidth, height }\n                    );\n                  }}\n                />\n              ) : null}\n              {loading && skeleton && box ? (\n                <div\n                  data-slot=\"line-chart-skeleton\"\n                  aria-hidden\n                  className={slotClasses.skeleton()}\n                  style={{\n                    left: box.x,\n                    width: box.width,\n                    top: box.y + box.height / 2 - SKELETON_THICKNESS / 2,\n                    height: SKELETON_THICKNESS,\n                    background: skeleton.base,\n                  }}\n                >\n                  <div\n                    className={slotClasses.sweep()}\n                    style={{ animation: `panelui-line-sweep ${skeleton.duration}ms linear infinite` }}\n                  />\n                </div>\n              ) : null}\n            </div>\n            {children}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n\n  return <LineChartContext.Provider value={context}>{element}</LineChartContext.Provider>;\n});\nLineChartRoot.displayName = 'LineChart';\n\n/** Hands a part's formatters to the root without a new function rebuilding the chart. */\nfunction useLatest<T>(value: T) {\n  const [latest] = useState(() => ({ current: value }));\n  useEffect(() => {\n    latest.current = value;\n  });\n  return latest;\n}\n\nexport interface LineChartGridProps {\n  /** Roughly how many lines to draw across the value axis. */\n  rows?: number;\n  /** The stroke's dash pattern. Omit for the default dash, `\"none\"` for a solid rule. */\n  dashArray?: string;\n  /** Opacity of the lines. */\n  opacity?: number;\n  /** A role in the theme, or any CSS colour, for the lines. Defaults to the border colour. */\n  color?: LineChartColor;\n}\n\n/** Lines across the value axis, so a point can be read against a number. */\nfunction LineChartGrid({\n  rows = 4,\n  dashArray = '4 6',\n  opacity = 1,\n  color,\n}: LineChartGridProps) {\n  const { setSlot } = useChart('LineChart.Grid');\n  const stroke = color ? paintOf(color) : 'var(--border)';\n  useEffect(() => {\n    setSlot('grid', { rows, dashArray, opacity, stroke });\n    return () => setSlot('grid', undefined);\n  }, [rows, dashArray, opacity, stroke, setSlot]);\n  return null;\n}\nLineChartGrid.displayName = 'LineChart.Grid';\n\nexport interface LineChartLineProps {\n  /** Column in the data holding this series' values. A missing value leaves a gap. */\n  dataKey: string;\n  /** A role in the theme, or any CSS colour, for the series. */\n  color?: LineChartColor;\n  /** The stroke's width. */\n  strokeWidth?: number;\n  /** The stroke's dash pattern, such as `\"6 4\"`, for a projection or a secondary series. */\n  dashArray?: string;\n  /** A dot at every point. Best kept for short series. */\n  showMarkers?: boolean;\n  /** Defaults to the chart's `curve`. `step` beside a smooth series draws a balance against its plan. */\n  curve?: LineChartCurve;\n}\n\n/** One series. */\nfunction LineChartLine({\n  dataKey,\n  color = 'chart-1',\n  strokeWidth = 2.5,\n  dashArray,\n  showMarkers = false,\n  curve,\n}: LineChartLineProps) {\n  const { setLine, removeLine } = useChart('LineChart.Line');\n  const stroke = paintOf(color);\n  useEffect(() => {\n    setLine({ key: dataKey, stroke, strokeWidth, dashArray, showMarkers, curve });\n  }, [dataKey, stroke, strokeWidth, dashArray, showMarkers, curve, setLine]);\n  // Apart from the update above, so a new colour keeps the series' place in the order.\n  useEffect(() => () => removeLine(dataKey), [dataKey, removeLine]);\n  return null;\n}\nLineChartLine.displayName = 'LineChart.Line';\n\nexport interface LineChartAreaProps {\n  /** Column in the data holding this series' values. */\n  dataKey: string;\n  /** A role in the theme, or any CSS colour, for the fill. */\n  color?: LineChartColor;\n  /** Opacity at the line. It fades to nothing at the bottom of the plot. */\n  opacity?: number;\n}\n\n/**\n * The fill under a series, under every line. A separate part from the line,\n * because a chart with two series usually wants it on only one of them: two\n * translucent fills over each other make a third colour that means nothing.\n */\nfunction LineChartArea({\n  dataKey,\n  color = 'chart-1',\n  opacity = 0.18,\n}: LineChartAreaProps) {\n  const { setArea, removeArea } = useChart('LineChart.Area');\n  const fill = paintOf(color);\n  useEffect(() => {\n    setArea({ key: dataKey, fill, opacity });\n  }, [dataKey, fill, opacity, setArea]);\n  useEffect(() => () => removeArea(dataKey), [dataKey, removeArea]);\n  return null;\n}\nLineChartArea.displayName = 'LineChart.Area';\n\n/** Registers one rule with the root, keeping its place in the order when its props change. */\nfunction useAnnotation(component: string, entry: Omit<Annotation, 'id'>) {\n  const { setAnnotation, removeAnnotation } = useChart(component);\n  const id = useId();\n  const { axis, at, stroke, dashArray, label } = entry;\n  useEffect(() => {\n    setAnnotation({ id, axis, at, stroke, dashArray, label });\n  }, [id, axis, at, stroke, dashArray, label, setAnnotation]);\n  useEffect(() => () => removeAnnotation(id), [id, removeAnnotation]);\n}\n\nexport interface LineChartReferenceLineProps {\n  /** The value to draw the rule at. It counts toward the value axis. */\n  y: number;\n  /** A role in the theme, or any CSS colour, for the rule and its label. */\n  color?: LineChartColor;\n  /** The stroke's dash pattern. */\n  dashArray?: string;\n  /** Text above the rule's left end. */\n  label?: string;\n}\n\n/** A rule across the plot at a fixed value: a budget, an average, a target. */\nfunction LineChartReferenceLine({\n  y,\n  color = 'foreground',\n  dashArray = '3 4',\n  label,\n}: LineChartReferenceLineProps) {\n  useAnnotation('LineChart.ReferenceLine', { axis: 'y', at: y, stroke: paintOf(color), dashArray, label });\n  return null;\n}\nLineChartReferenceLine.displayName = 'LineChart.ReferenceLine';\n\nexport interface LineChartMarkerProps {\n  /** The row to draw the rule at, counted from `0`. */\n  index: number;\n  /** A role in the theme, or any CSS colour, for the rule and its label. */\n  color?: LineChartColor;\n  /** The stroke's dash pattern. */\n  dashArray?: string;\n  /** Text beside the rule's top. It moves to the left of a rule near the right edge. */\n  label?: string;\n}\n\n/** A rule down the plot at one row: a release, the day something ran out. */\nfunction LineChartMarker({\n  index,\n  color = 'muted-foreground',\n  dashArray = '2 3',\n  label,\n}: LineChartMarkerProps) {\n  useAnnotation('LineChart.Marker', { axis: 'x', at: index, stroke: paintOf(color), dashArray, label });\n  return null;\n}\nLineChartMarker.displayName = 'LineChart.Marker';\n\nexport interface LineChartSkeletonProps {\n  /** Milliseconds for one pass of the sweep. */\n  duration?: number;\n  /** A role in the theme, or any CSS colour, for the rule under the highlight. Defaults to the muted colour. */\n  color?: LineChartColor;\n}\n\n/**\n * The loading state: a flat rule where the series will be, with a highlight\n * travelling along it. The highlight is a translated child inside the rule's\n * clip, so the loop runs on the compositor and repaints nothing.\n */\nfunction LineChartSkeleton({\n  duration = 1400,\n  color,\n}: LineChartSkeletonProps) {\n  const { setSlot } = useChart('LineChart.Skeleton');\n  const base = color && paintOf(color);\n  useEffect(() => {\n    setSlot('skeleton', { duration, base });\n    return () => setSlot('skeleton', undefined);\n  }, [duration, base, setSlot]);\n  return null;\n}\nLineChartSkeleton.displayName = 'LineChart.Skeleton';\n\nexport interface LineChartXAxisProps {\n  /** How many labels to show, evenly spaced with both ends kept. */\n  ticks?: number;\n  /** Turn a row into its label. Defaults to the value at `xDataKey`. */\n  format?: (datum: LineChartDatum, index: number) => string;\n}\n\n/** The x labels, each on its own point. */\nfunction LineChartXAxis({\n  ticks = 4,\n  format,\n}: LineChartXAxisProps) {\n  const { setSlot } = useChart('LineChart.XAxis');\n  useEffect(() => {\n    setSlot('xAxis', { ticks, format });\n    return () => setSlot('xAxis', undefined);\n  }, [ticks, format, setSlot]);\n  return null;\n}\nLineChartXAxis.displayName = 'LineChart.XAxis';\n\nexport interface LineChartYAxisProps {\n  /** Roughly how many labels to show. Give it the same number as `Grid`'s `rows`. */\n  ticks?: number;\n  /** Format a value for its label. Defaults to a compact number. */\n  format?: (value: number) => string;\n}\n\n/** Value labels down the side, with room made for them. */\nfunction LineChartYAxis({\n  ticks = 4,\n  format,\n}: LineChartYAxisProps) {\n  const { setSlot } = useChart('LineChart.YAxis');\n  useEffect(() => {\n    setSlot('yAxis', { ticks, format });\n    return () => setSlot('yAxis', undefined);\n  }, [ticks, format, setSlot]);\n  return null;\n}\nLineChartYAxis.displayName = 'LineChart.YAxis';\n\nexport interface LineChartTooltipProps {\n  /** The crosshair's colour: a role in the theme, or any CSS colour. */\n  color?: LineChartColor;\n  /** Draw the popover. Without it the crosshair and dots still follow the reading, for a readout in the header. */\n  showLabel?: boolean;\n  /** Format one series' value. Defaults to a compact number. */\n  formatValue?: (value: number, key: string) => string;\n  /** Format the readout's heading from the row. Defaults to the value at xDataKey. */\n  formatX?: (datum: LineChartDatum) => string;\n  /** Prettier names for the series keys, as the legend takes. */\n  labels?: Record<string, string>;\n  /** Added to the readout's surface. */\n  className?: string;\n}\n\n/**\n * The crosshair, the dots that ride the lines, the popover, and what drives them:\n * hovering, pressing or dragging across the plot, or the arrow keys. A click or a\n * tap pins the reading until the next press elsewhere or Escape. The whole plot is\n * the hit area, because a readout you have to land on the line to summon is one\n * nobody finds.\n */\nfunction LineChartTooltip({\n  color = 'foreground',\n  showLabel = true,\n  formatValue,\n  formatX,\n  labels,\n  className,\n}: LineChartTooltipProps) {\n  const { setSlot } = useChart('LineChart.Tooltip');\n  const latest = useLatest({ formatValue, formatX, labels });\n  const stroke = paintOf(color);\n  useEffect(() => {\n    setSlot('tooltip', { stroke, showLabel, className, latest });\n    return () => setSlot('tooltip', undefined);\n  }, [stroke, showLabel, className, latest, setSlot]);\n  return null;\n}\nLineChartTooltip.displayName = 'LineChart.Tooltip';\n\n/** A swatch and a name per line, in the order the lines were declared. */\nfunction seriesKey(lines: readonly LineEntry[], labels: Record<string, string> | undefined, slots: ReturnType<typeof lineChartVariants>) {\n  return lines.map(({ key, stroke }) => (\n    <span key={key} className={slots.legendItem()}>\n      <span className={slots.legendSwatch()} style={{ background: stroke }} />\n      {labels?.[key] ?? key}\n    </span>\n  ));\n}\n\nexport interface LineChartLegendProps extends ComponentProps<'div'> {\n  /** Prettier names for the series keys. */\n  labels?: Record<string, string>;\n}\n\n/** The series key, over the top corner of the plot. On a chart with a header, use its `legend` prop. */\nfunction LineChartLegend({\n  labels,\n  className,\n  render,\n  ...props\n}: LineChartLegendProps & RenderProp<'div'>) {\n  const { lines } = useChart('LineChart.Legend');\n  const slots = lineChartVariants();\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'line-chart-legend',\n        'aria-hidden': true,\n        className: slots.legend({ className }),\n        children: seriesKey(lines, labels, slots),\n      },\n      props\n    ),\n  });\n\n  return lines.length ? element : null;\n}\nLineChartLegend.displayName = 'LineChart.Legend';\n\nexport interface LineChartHeaderProps extends Omit<ComponentProps<'div'>, 'title'> {\n  /** Small line above the value: what the chart is of. */\n  title?: ReactNode;\n  /** The readout. The largest thing on the card, and the first thing read. */\n  value?: ReactNode;\n  /** One muted line under the value: a period, a comparison, a total. */\n  caption?: ReactNode;\n  /** Prettier names for the series keys, as the legend takes. */\n  labels?: Record<string, string>;\n  /**\n   * Draw a swatch and a name per series along the trailing edge. Prefer this to\n   * `LineChart.Legend` on a chart that has a header: the legend sits over the\n   * plot, in a corner the lines also want.\n   */\n  legend?: boolean;\n  /** Trailing slot: a control, a badge, a range picker. Wins over `legend`. */\n  children?: ReactNode;\n}\n\n/**\n * The strip above the plot: what the chart is of, what it reads and what the\n * colours mean. The value is not derived here. A readout that follows the\n * pointer takes it from `onActiveIndexChange` and passes the string down, so one\n * header can show a total at rest and a point's value while one is read.\n */\nfunction LineChartHeader({\n  title,\n  value,\n  caption,\n  labels,\n  legend = false,\n  className,\n  children,\n  render,\n  ...props\n}: LineChartHeaderProps & RenderProp<'div'>) {\n  const { lines } = useChart('LineChart.Header');\n  const slots = lineChartVariants();\n  const trailing = children ?? (legend && lines.length ? seriesKey(lines, labels, slots) : null);\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'line-chart-header',\n        className: slots.header({ className }),\n        children: (\n          <>\n            <div className={slots.headerText()}>\n              {title ? <div className={slots.headerMuted()}>{title}</div> : null}\n              {value ? <div className={slots.headerValue()}>{value}</div> : null}\n              {caption ? <div className={slots.headerMuted()}>{caption}</div> : null}\n            </div>\n            {trailing ? <div className={slots.headerTrailing()}>{trailing}</div> : null}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nLineChartHeader.displayName = 'LineChart.Header';\n\nexport const LineChart = Object.assign(LineChartRoot, {\n  Header: LineChartHeader,\n  Grid: LineChartGrid,\n  Area: LineChartArea,\n  Line: LineChartLine,\n  ReferenceLine: LineChartReferenceLine,\n  Marker: LineChartMarker,\n  Skeleton: LineChartSkeleton,\n  XAxis: LineChartXAxis,\n  YAxis: LineChartYAxis,\n  Tooltip: LineChartTooltip,\n  Legend: LineChartLegend,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport {\n  LineChartHeader,\n  LineChartGrid,\n  LineChartArea,\n  LineChartLine,\n  LineChartReferenceLine,\n  LineChartMarker,\n  LineChartSkeleton,\n  LineChartXAxis,\n  LineChartYAxis,\n  LineChartTooltip,\n  LineChartLegend,\n};\n"
    },
    {
      "path": "components/panelui/line-chart/line-scale.ts",
      "type": "registry:component",
      "target": "@components/panelui/line-chart/line-scale.ts",
      "content": "/**\n * The numbers behind a line chart's axes, apart from the drawing so they can be\n * checked without one.\n */\nimport { scaleLinear } from '@tanstack/charts/scales/linear';\n\ntype Cell = string | number | null | undefined;\n\n/** A cell's value, or null for anything that cannot be drawn. */\nexport function readValue(cell: Cell): number | null {\n  return typeof cell === 'number' && Number.isFinite(cell) ? cell : null;\n}\n\n/**\n * The value axis for these rows, with one domain across every series so two\n * lines stay comparable. A little headroom keeps a peak off the top edge, a flat\n * series lands mid-plot, and the ends round to numbers worth labelling. Unlike a\n * bar, a line is read by its shape, so zero is not forced in. `extras` are values\n * the axis must also reach, such as a reference line above every point.\n */\nexport function lineDomain(\n  data: readonly Record<string, Cell>[],\n  keys: readonly string[],\n  ticks: number,\n  extras: readonly number[] = []\n): [number, number] {\n  let min = Infinity;\n  let max = -Infinity;\n  for (const row of data) {\n    for (const key of keys) {\n      const value = readValue(row[key]);\n      if (value === null) continue;\n      if (value < min) min = value;\n      if (value > max) max = value;\n    }\n  }\n  for (const value of extras) {\n    if (!Number.isFinite(value)) continue;\n    if (value < min) min = value;\n    if (value > max) max = value;\n  }\n  if (min === Infinity) return [0, 1];\n  const pad = (min === max ? Math.abs(min) || 1 : max - min) * 0.1;\n  const [low, high] = scaleLinear([min - pad, max + pad], [0, 1]).nice(ticks).domain();\n  return [low, high];\n}\n\n/** Which rows get an x label: evenly spaced, with both ends kept. */\nexport function tickIndexes(rows: number, ticks: number): number[] {\n  const count = Math.min(Math.max(1, Math.floor(ticks)), rows);\n  const step = count > 1 ? (rows - 1) / (count - 1) : 0;\n  return Array.from({ length: count }, (_unused, index) => Math.round(index * step));\n}\n"
    }
  ]
}
