{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timeseries-chart",
  "title": "Timeseries Chart",
  "description": "A line (and optional area) chart on TanStack Charts, themed by the chart tokens: grid and tooltip follow light and dark, and the deploy marker is a dashed rule so a before and after read at a glance.",
  "dependencies": [
    "@tanstack/charts@^1.2.1"
  ],
  "registryDependencies": [
    "https://sproutboat.com/ikada/r/theme.json",
    "https://sproutboat.com/ikada/r/accessible-name.json",
    "https://sproutboat.com/ikada/r/chart-theme.json",
    "https://sproutboat.com/ikada/r/cn.json",
    "https://sproutboat.com/ikada/r/format.json",
    "https://sproutboat.com/ikada/r/locale.json",
    "https://sproutboat.com/ikada/r/tooltip-card.json"
  ],
  "files": [
    {
      "path": "registry/ikada/ui/timeseries-chart.tsx",
      "content": "\"use client\";\n\nimport { crosshair } from \"@tanstack/charts/crosshair\";\nimport { areaY, defineChart, dot, lineY, ruleX, text } from \"@tanstack/charts\";\nimport { Chart } from \"@tanstack/charts/react\";\nimport { scaleLinear } from \"@tanstack/charts/scales/linear\";\nimport { decorative } from \"@tanstack/charts/mark/decorative\";\nimport { tooltip } from \"@tanstack/charts/tooltip\";\nimport type { ChartTooltipPlacement } from \"@tanstack/charts/tooltip\";\nimport { portal } from \"@tanstack/charts/tooltip/portal\";\nimport { useMemo } from \"react\";\nimport { cn } from \"@/components/ikada/lib/cn\";\nimport { formatCompact, formatNumber } from \"@/components/ikada/lib/format\";\nimport { useLocale } from \"@/components/ikada/lib/locale\";\nimport { accessibleName } from \"@/components/ikada/lib/accessible-name\";\nimport type { AccessibleName } from \"@/components/ikada/lib/accessible-name\";\nimport { tooltipCard } from \"@/components/ikada/tooltip-card\";\nimport { chartClassName, chartColors } from \"@/components/ikada/chart-theme\";\nimport type { ChartColorName } from \"@/components/ikada/chart-theme\";\n\nexport interface TimeseriesPoint {\n  /** Any number on one shared axis: epoch milliseconds, or hours since midnight. */\n  time: number;\n  value: number;\n}\n\nexport interface TimeseriesSeries {\n  id: string;\n  /** Shown in the tooltip and read by screen readers. */\n  label: string;\n  data: readonly TimeseriesPoint[];\n  /** A chart color token name. Defaults to `requests`. */\n  color?: ChartColorName;\n  /** Fills under the line with the same color at 14% opacity. */\n  area?: boolean;\n  dashed?: boolean;\n}\n\nexport interface DeployMarker {\n  time: number;\n  /** Short label printed beside the rule, e.g. \"v48\". */\n  label: string;\n}\n\nexport interface TimeseriesChartBase {\n  /** The lines to draw: a label, a colour and the points of each. */\n  series: readonly TimeseriesSeries[];\n  /** A dashed vertical rule with a label, in the `chart-deploy` color. */\n  deploy?: DeployMarker | readonly DeployMarker[];\n  /** Height in pixels. Default 210; the width fills the container. */\n  height?: number;\n  /** [min, max] of the value axis. Defaults to 0 up to the largest value, rounded up. */\n  yDomain?: readonly [number, number];\n  /** Time range in ms [from, to]. Defaults to the span of the data. */\n  xDomain?: readonly [number, number];\n  /**\n   * Formats x ticks and the tooltip heading. Defaults to clock times (\"14:30\") for epoch\n   * milliseconds spanning up to two days, dates (\"Nov 5\") beyond that, and plain numbers otherwise.\n   */\n  formatTime?: (time: number) => string;\n  /** Number and date locale; defaults to the `LocaleProvider` value, else `en`. */\n  locale?: string;\n  /** Shown in place of the plot when no series has a point. */\n  emptyText?: string;\n  /** Formats y ticks and tooltip values. */\n  formatValue?: (value: number) => string;\n  /** Formats y axis ticks. Defaults to compact numbers (1.5K) with no unit, so labels stay narrow. */\n  formatTick?: (value: number) => string;\n  /** Approximate number of x ticks. Defaults to 5. */\n  xTicks?: number;\n  /** About how many value ticks to draw. Default 4. */\n  yTicks?: number;\n  className?: string;\n}\n\ninterface Row {\n  time: number;\n  value: number;\n  label: string;\n}\n\nconst compact = (value: number): string => formatCompact(value, { maximumFractionDigits: 1 });\n\nconst defaultValue = (value: number): string => formatNumber(value);\n\n/** Epoch milliseconds from 2001 on; smaller numbers are treated as plain values (hours, indexes). */\nconst EPOCH_MS_FLOOR = 978_307_200_000;\nconst TWO_DAYS = 2 * 24 * 60 * 60 * 1000;\n\n/** A deploy label is one line of SVG text with no wrapping; past this it would run off the plot. */\nconst MAX_LABEL = 28;\nfunction truncateLabel(label: string): string {\n  const chars = Array.from(label);\n  return chars.length > MAX_LABEL ? `${chars.slice(0, MAX_LABEL - 1).join(\"\")}…` : label;\n}\n\n/** The default x formatter for a domain: clock time, date, or a plain number. */\nexport function timeFormatter(domain: readonly [number, number], locale = \"en\"): (time: number) => string {\n  const [x0, x1] = domain;\n  if (!isEpoch(domain)) return (time) => formatNumber(time, { locale });\n  const format =\n    x1 - x0 <= TWO_DAYS\n      ? new Intl.DateTimeFormat(locale, { hour: \"2-digit\", minute: \"2-digit\", hourCycle: \"h23\" })\n      : new Intl.DateTimeFormat(locale, { month: \"short\", day: \"numeric\" });\n  return (time) => format.format(time);\n}\n\nconst MINUTE = 60_000;\nconst TIME_STEPS = [1, 5, 15, 30, 60, 120, 180, 360, 720, 1440, 2880, 10080].map((m) => m * MINUTE);\n\nconst isEpoch = ([x0, x1]: readonly [number, number]): boolean => x0 >= EPOCH_MS_FLOOR && x1 >= EPOCH_MS_FLOOR;\n\n/**\n * Tick positions on whole clock units (on the hour, at local midnight) for an epoch-millisecond\n * domain, at most about `count` of them. Returns undefined for plain numbers (the scale picks).\n */\nexport function timeTicks(domain: readonly [number, number], count = 5): number[] | undefined {\n  if (!isEpoch(domain)) return undefined;\n  const [x0, x1] = domain;\n  const step = TIME_STEPS.find((s) => (x1 - x0) / s <= count) ?? TIME_STEPS[TIME_STEPS.length - 1]!;\n  // Align to local wall-clock units, not UTC ones.\n  const tz = -new Date(x0).getTimezoneOffset() * MINUTE;\n  const first = Math.ceil((x0 + tz) / step) * step - tz;\n  const ticks: number[] = [];\n  for (let t = first; t <= x1; t += step) ticks.push(t);\n  return ticks;\n}\n\nconst markers = (deploy: TimeseriesChartProps[\"deploy\"]): readonly DeployMarker[] =>\n  deploy === undefined ? [] : \"time\" in deploy ? [deploy] : deploy;\n\n/** Props of {@link TimeseriesChart}. The accessible name is `aria-label`; `ariaLabel` is deprecated. */\nexport type TimeseriesChartProps = TimeseriesChartBase & AccessibleName;\n\n/**\n * A line (and optional area) chart on TanStack Charts, themed by the chart tokens:\n * grid and tooltip follow light and dark, and the deploy marker is a dashed rule\n * so a before and after read at a glance. Epoch-millisecond times read as clock times\n * or dates; with no points at all it keeps its height and says so instead of plotting.\n */\nexport function TimeseriesChart({\n  series: rawSeries,\n  deploy,\n  \"aria-label\": ariaLabelStd,\n  ariaLabel: ariaLabelLegacy,\n  height = 210,\n  yDomain,\n  xDomain,\n  formatTime,\n  formatValue = defaultValue,\n  formatTick = compact,\n  xTicks = 5,\n  yTicks = 4,\n  locale,\n  emptyText = \"No data for this range\",\n  className,\n}: TimeseriesChartProps) {\n  const ariaLabel = accessibleName({ \"aria-label\": ariaLabelStd, ariaLabel: ariaLabelLegacy });\n  const resolvedLocale = useLocale(locale);\n  // A NaN or Infinity point would make a scale throw and take the page with it: drop it.\n  const series = useMemo(\n    () =>\n      rawSeries.map((s) => ({\n        ...s,\n        data: s.data.filter((p) => Number.isFinite(p.time) && Number.isFinite(p.value)),\n      })),\n    [rawSeries],\n  );\n  const empty = !series.some((s) => s.data.length > 0);\n  const definition = useMemo(() => {\n    if (empty) return null;\n    const all = series.flatMap((s) => s.data);\n    let x0 = xDomain?.[0] ?? Math.min(...all.map((p) => p.time));\n    let x1 = xDomain?.[1] ?? Math.max(...all.map((p) => p.time));\n    // One point (or one instant) still needs a span to draw on.\n    if (x0 === x1) {\n      x0 -= 1;\n      x1 += 1;\n    }\n    const time = formatTime ?? timeFormatter([x0, x1], resolvedLocale);\n    const ticksAt = timeTicks([x0, x1], xTicks);\n    const yMax = yDomain?.[1] ?? Math.max(1, ...all.map((p) => p.value)) * 1.08;\n    const yMin = yDomain?.[0] ?? 0;\n    const marks = [\n      ...series.flatMap((s) => {\n        const color = chartColors[s.color ?? \"requests\"];\n        const rows: Row[] = s.data.map((p) => ({ ...p, label: s.label }));\n        return [\n          ...(s.area\n            ? [areaY(rows, { x: \"time\", y: \"value\", fill: color, fillOpacity: 0.14, id: `${s.id}-area` })]\n            : []),\n          lineY(rows, {\n            x: \"time\",\n            y: \"value\",\n            stroke: color,\n            strokeWidth: 2,\n            strokeDasharray: s.dashed ? \"6 4\" : undefined,\n            id: s.id,\n          }),\n          // One point draws no line: show it as a dot so a brand-new series is not mistaken for no data.\n          ...(rows.length === 1 ? [dot(rows, { x: \"time\", y: \"value\", r: 3, fill: color, id: `${s.id}-dot` })] : []),\n        ];\n      }),\n      // A vertical guide that the card sits beside. Not a mark in the tooltip, so deploy markers stay out of it.\n      crosshair({\n        x: { stroke: \"currentColor\", strokeOpacity: 0.35, strokeWidth: 1, strokeDasharray: \"2 3\" },\n        y: false,\n      }),\n      ...markers(deploy).flatMap((m, i) => [\n        decorative(\n          ruleX([{ time: m.time }], {\n            x: \"time\",\n            stroke: chartColors.deploy,\n            strokeOpacity: 0.8,\n            strokeDasharray: \"2 4\",\n            strokeWidth: 1.5,\n            id: `deploy-${i}`,\n          }),\n        ),\n        decorative(\n          text([{ time: m.time, y: yMax * 0.96, t: truncateLabel(m.label) }], {\n            x: \"time\",\n            y: \"y\",\n            text: \"t\",\n            anchor: \"start\",\n            dx: 8,\n            // Markers close together would print their labels over each other: step every other one down.\n            dy: i % 2 === 0 ? 0 : 14,\n            fill: chartColors.deploy,\n            fontSize: 11,\n            fontWeight: 600,\n            id: `deploy-label-${i}`,\n          }),\n        ),\n      ]),\n    ];\n    // Which side of the crosshair the card is on, set by `anchor` just before the library reads `placement`.\n    let side: \"left\" | \"right\" = \"right\";\n    return defineChart({\n      marks,\n      scales: {\n        x: {\n          scale: scaleLinear().domain([x0, x1]),\n          axis: {\n            line: false,\n            ticks: { ...(ticksAt ? { values: ticksAt } : { count: xTicks }), size: 0, format: time },\n          },\n        },\n        y: {\n          scale: scaleLinear().domain([yMin, yMax]),\n          axis: { line: false, ticks: { count: yTicks, size: 0, format: formatTick } },\n          grid: { stroke: chartColors.grid, strokeOpacity: 1 },\n        },\n      },\n      focus: \"nearest-x\",\n      maxFocusDistance: Number.POSITIVE_INFINITY,\n      tooltip: {\n        use: tooltip,\n        // In the top layer, so a tile or card with overflow hidden never clips it.\n        portal,\n        // Beside the crosshair at the top of the plot: right of the line, flipping to its left in the right half of\n        // the plot (not only at the viewport edge), so it never covers the hovered point or hangs outside the tile.\n        anchor: (points: readonly { x: number }[], context: { plot: { x: number; y: number; width: number } }) => {\n          const x = points[0]!.x;\n          side = x > context.plot.x + context.plot.width / 2 ? \"left\" : \"right\";\n          return { x, y: context.plot.y };\n        },\n        get placement(): ChartTooltipPlacement[] {\n          return side === \"left\" ? [\"bottom-left\", \"bottom-right\"] : [\"bottom-right\", \"bottom-left\"];\n        },\n        offset: 12,\n        content: (points: readonly { datum: unknown }[]) => {\n          const hovered = (points[0]!.datum as Row).time;\n          const rows = series.flatMap((s) => {\n            const p = s.data.find((d) => d.time === hovered);\n            return p\n              ? [\n                  {\n                    label: s.label,\n                    value: formatValue(p.value),\n                    color: chartColors[s.color ?? \"requests\"],\n                    active: true,\n                  },\n                ]\n              : [];\n          });\n          return tooltipCard(time(hovered), rows);\n        },\n      },\n    });\n  }, [empty, series, deploy, yDomain, xDomain, formatTime, formatValue, formatTick, xTicks, yTicks, resolvedLocale]);\n\n  if (!definition) {\n    return (\n      <div\n        role=\"img\"\n        aria-label={`${ariaLabel}: ${emptyText}`}\n        data-empty=\"\"\n        style={{ height }}\n        className={cn(\n          chartClassName,\n          \"grid w-full place-items-center rounded-lg bg-well text-sb-sm text-fg-muted shadow-tray\",\n          className,\n        )}\n      >\n        <span aria-hidden=\"true\">{emptyText}</span>\n      </div>\n    );\n  }\n\n  return (\n    <Chart\n      definition={definition}\n      height={height}\n      ariaLabel={ariaLabel}\n      className={cn(chartClassName, \"w-full text-fg-muted\", className)}\n    />\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/ikada/timeseries-chart.tsx"
    }
  ],
  "docs": "Uses @tanstack/charts (installed with this item). Load it lazily in routes that show charts, for example with React.lazy or next/dynamic.",
  "categories": [
    "charts"
  ],
  "type": "registry:ui"
}