{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"lib-chart","type":"registry:lib","title":"chart helpers","description":"What AreaChart, LineChart and BarChart share: their props, the scales, the round ticks, the curves, stacking, the","dependencies":[],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json"],"files":[{"path":"registry/dashral/lib/chart.tsx","type":"registry:file","target":"components/dashral/lib/chart.tsx","content":"\"use client\"\n\n// What AreaChart, LineChart and BarChart share: their props, the scales, the round ticks, the curves, stacking, the\n// formatters, reading a point with the pointer or the keyboard, and the legend, axes and tooltip around the marks.\n// Each chart draws its own marks with this.\n\nimport * as React from \"react\"\n\nimport { useLocale } from \"./locale\"\nimport { cn } from \"./utils\"\n\n/** One line, area or set of bars: the data key it reads, its name, and its color. */\nexport type ChartSeries = {\n  /** The key in each data row that holds this series' value. */\n  key: string\n  /** Its name, in the legend and the tooltip. */\n  label: string\n  /** Any CSS color; by default the theme's chart colors in order (--chart-1 … --chart-5). */\n  color?: string\n  /** Drawn dashed and quieter: a comparison, like the same period last year. Lines and areas only. */\n  dashed?: boolean\n}\n\n/** One row of data: the index (a label, a timestamp or an ISO date) and a number for each series. */\nexport type ChartDatum = Record<string, string | number | null | undefined>\n\nexport type CartesianChartProps = {\n  /** The rows, in order along the x axis. */\n  data: ChartDatum[]\n  /** The key in each row for the x axis: a label (\"Jan\"), or a timestamp or ISO date when `timeFormat` is set. */\n  index: string\n  /** The key in each row for a fuller title in the tooltip (and read out), e.g. \"Oct 2026\" under an axis saying \"Oct\". */\n  labelKey?: string\n  /** What to draw, in order; the first is the main one. */\n  series: ChartSeries[]\n  /** The chart's height in pixels, axes included; the width follows its container. */\n  height?: number\n  /** BCP 47 locale for numbers and dates. */\n  locale?: string\n  /** How values are written, e.g. `{ style: \"currency\", currency: \"USD\" }`. The axis writes them compactly ($48K). */\n  formatOptions?: Intl.NumberFormatOptions\n  /** Reads the index as dates and writes them like this, e.g. `{ month: \"short\" }`. */\n  timeFormat?: Intl.DateTimeFormatOptions\n  /** A legend of the series. On by default when there's more than one. */\n  showLegend?: boolean\n  /** Dashed lines across the chart at the axis values. */\n  showGrid?: boolean\n  /** The values along the left. */\n  showYAxis?: boolean\n  /** What the chart shows, e.g. \"Revenue by month\". By default it says how the first series went. */\n  \"aria-label\"?: string\n}\n\nexport const seriesColor = (series: ChartSeries, i: number) => series.color ?? `var(--chart-${(i % 5) + 1})`\n\n/** Round values for about `count` lines between min and max: steps of 1, 2 or 5 times a power of ten. */\nexport function niceTicks(min: number, max: number, count = 4) {\n  const span = max - min || Math.abs(max) || 1\n  const raw = span / count\n  const magnitude = 10 ** Math.floor(Math.log10(raw))\n  const n = raw / magnitude\n  const step = (n < 1.5 ? 1 : n < 3 ? 2 : n < 7 ? 5 : 10) * magnitude\n  const ticks: number[] = []\n  for (let v = Math.floor(min / step) * step; v <= max + step * 1e-9; v += step) ticks.push(Number(v.toFixed(10)))\n  if (ticks.at(-1)! < max) ticks.push(Number((ticks.at(-1)! + step).toFixed(10)))\n  return ticks\n}\n\nexport type Point = { x: number; y: number }\nexport type Curve = \"smooth\" | \"linear\" | \"step\"\n\n/**\n * A path through the points: straight, in steps, or smooth. Smooth is a monotone curve: it bends through every point\n * without overshooting, so a line never dips below a value it didn't have.\n */\nexport function linePath(points: Point[], curve: Curve = \"smooth\", move = true) {\n  if (!points.length) return \"\"\n  const start = `${move ? \"M\" : \"L\"}${points[0]!.x.toFixed(2)},${points[0]!.y.toFixed(2)}`\n  if (points.length === 1) return start\n  if (curve === \"linear\") return start + points.slice(1).map((p) => `L${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(\"\")\n  if (curve === \"step\") {\n    return (\n      start +\n      points\n        .slice(1)\n        .map((p, i) => {\n          const mid = (points[i]!.x + p.x) / 2\n          return `H${mid.toFixed(2)}V${p.y.toFixed(2)}H${p.x.toFixed(2)}`\n        })\n        .join(\"\")\n    )\n  }\n  // Monotone cubic (Fritsch–Carlson): tangents from the neighbouring slopes, flattened where the line turns.\n  const n = points.length\n  const dx = points.slice(1).map((p, i) => p.x - points[i]!.x)\n  const slope = points.slice(1).map((p, i) => (p.y - points[i]!.y) / (dx[i] || 1))\n  const tangent = points.map((_, i) => {\n    if (i === 0) return slope[0]!\n    if (i === n - 1) return slope[n - 2]!\n    const a = slope[i - 1]!\n    const b = slope[i]!\n    return a * b <= 0 ? 0 : (3 * (dx[i - 1]! + dx[i]!)) / ((2 * dx[i]! + dx[i - 1]!) / a + (dx[i]! + 2 * dx[i - 1]!) / b)\n  })\n  let d = start\n  for (let i = 0; i < n - 1; i++) {\n    const p = points[i]!\n    const q = points[i + 1]!\n    const h = dx[i]! / 3\n    d += `C${(p.x + h).toFixed(2)},${(p.y + tangent[i]! * h).toFixed(2)} ${(q.x - h).toFixed(2)},${(q.y - tangent[i + 1]! * h).toFixed(2)} ${q.x.toFixed(2)},${q.y.toFixed(2)}`\n  }\n  return d\n}\n\n/** An area between a top line and a bottom one (the axis, or the series below when stacked). */\nexport function areaPath(top: Point[], bottom: Point[], curve: Curve = \"smooth\") {\n  if (!top.length) return \"\"\n  return `${linePath(top, curve)}${linePath([...bottom].reverse(), curve, false)}Z`\n}\n\nconst toNumber = (v: unknown) => (typeof v === \"number\" && Number.isFinite(v) ? v : 0)\n\nconst TOP = 8\nconst X_AXIS = 24\nconst Y_GAP = 10\n\n/**\n * Everything a cartesian chart needs from its props: its measured width, the plot's size, the scales, the ticks, each\n * series' values (stacked or not), the formatters, and the point being read (by pointer or keyboard, announced to\n * screen readers). `band` lays the rows out as slots (bars); otherwise as points from edge to edge (lines, areas).\n */\nexport function useCartesianChart(\n  props: CartesianChartProps & { band?: boolean; stacked?: boolean; zero?: boolean; roledescription: string }\n) {\n  const { data, index, labelKey, series, height = 260, locale: localeProp, formatOptions, timeFormat, showYAxis = true, band = false, stacked = false, zero = true } = props\n  const { t, locale: contextLocale, dir } = useLocale()\n  // Right to left (Arabic), the chart reads that way too: the first point on the right, the values on the right.\n  const rtl = dir === \"rtl\"\n  const locale = localeProp ?? contextLocale\n  const box = React.useRef<HTMLDivElement>(null)\n  const [width, setWidth] = React.useState<number | null>(null)\n  const [drawn, setDrawn] = React.useState(false)\n  const [active, setActive] = React.useState<number | null>(null)\n  const [announce, setAnnounce] = React.useState(\"\")\n\n  React.useLayoutEffect(() => {\n    const el = box.current\n    if (!el) return\n    const measure = () => setWidth(el.clientWidth)\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [])\n  // The marks only render once the width is known; draw them in a frame later, so the empty start is painted first.\n  const measured = width !== null\n  React.useEffect(() => {\n    if (!measured) return\n    const frame = requestAnimationFrame(() => setDrawn(true))\n    return () => cancelAnimationFrame(frame)\n  }, [measured])\n\n  const value = React.useMemo(() => new Intl.NumberFormat(locale, formatOptions), [locale, formatOptions])\n  const axisValue = React.useMemo(\n    () => new Intl.NumberFormat(locale, { ...formatOptions, notation: \"compact\", maximumFractionDigits: 1, minimumFractionDigits: 0 }),\n    [locale, formatOptions]\n  )\n  const date = React.useMemo(() => (timeFormat ? new Intl.DateTimeFormat(locale, timeFormat) : null), [locale, timeFormat])\n  const indexLabel = (row: ChartDatum | undefined) => {\n    const raw = row?.[index]\n    if (raw === undefined || raw === null) return \"\"\n    if (!date) return String(raw)\n    return date.format(new Date(typeof raw === \"number\" ? raw : Date.parse(raw)))\n  }\n\n  // Each series' values, and where each sits when stacked (its base and its top).\n  const columns = data.map((row) => {\n    let base = 0\n    return series.map((s) => {\n      const v = toNumber(row[s.key])\n      const cell = stacked ? { value: v, base, top: base + v } : { value: v, base: 0, top: v }\n      if (stacked) base += v\n      return cell\n    })\n  })\n  const tops = columns.flatMap((c) => c.map((cell) => cell.top))\n  const lows = columns.flatMap((c) => c.map((cell) => (stacked ? cell.base : cell.value)))\n  const hi = tops.length ? Math.max(...tops) : 1\n  const lo = tops.length ? Math.min(...lows) : 0\n  const ticks = niceTicks(zero ? Math.min(0, lo) : lo - (hi - lo) * 0.1, hi)\n  const min = ticks[0]!\n  const max = ticks.at(-1)!\n\n  // The axis is as wide as its longest label; the plot takes the rest.\n  const tickLabels = ticks.map((v) => axisValue.format(v))\n  const axisW = showYAxis ? Math.ceil(Math.max(...tickLabels.map((l) => l.length)) * 6.6) + Y_GAP : 0\n  const plotW = Math.max(0, (width ?? 0) - axisW)\n  const plotH = Math.max(0, height - TOP - X_AXIS)\n  const y = (v: number) => TOP + ((max - v) / (max - min || 1)) * plotH\n  const n = data.length\n  const slot = n ? plotW / n : 0\n  // Where the plot starts: after the axis, or at the left edge with the axis on the right.\n  const plotLeft = rtl ? 0 : axisW\n  const along = (i: number) => (band ? slot * (i + 0.5) : n > 1 ? (i / (n - 1)) * plotW : plotW / 2)\n  const x = (i: number) => plotLeft + (rtl ? plotW - along(i) : along(i))\n\n  // Labels along the bottom: as many as fit without touching, always ending on the last.\n  const labels = data.map((row) => indexLabel(row))\n  const labelW = Math.max(...labels.map((l) => l.length), 1) * 6.4 + 16\n  const every = Math.max(1, Math.ceil(n / Math.max(1, Math.floor(plotW / labelW))))\n  const xTicks = data.map((_, i) => i).filter((i) => (n - 1 - i) % every === 0)\n\n  // The tooltip's title: the row's own when it has one, else its label on the axis.\n  const titles = data.map((row, i) => (labelKey && row[labelKey] != null ? String(row[labelKey]) : labels[i]!))\n  const describe = (i: number) =>\n    `${titles[i]}: ${series.map((s, k) => `${s.label} ${value.format(columns[i]![k]!.value)}`).join(\", \")}`\n  const pick = (i: number | null, fromKeyboard = false) => {\n    setActive(i)\n    if (fromKeyboard && i !== null) setAnnounce(describe(i))\n  }\n\n  const first = series[0]\n  const label =\n    props[\"aria-label\"] ??\n    (n && first ? t(\"trend\", { from: value.format(toNumber(data[0]![first.key])), to: value.format(toNumber(data[n - 1]![first.key])) }) : t(\"noData\"))\n\n  return {\n    box,\n    width,\n    drawn,\n    height,\n    active,\n    announce,\n    pick,\n    columns,\n    ticks,\n    tickLabels,\n    axisW,\n    plotLeft,\n    rtl,\n    plotW,\n    plotH,\n    top: TOP,\n    bottom: height - X_AXIS,\n    x,\n    y,\n    slot,\n    labels,\n    titles,\n    xTicks,\n    value,\n    showLegend: props.showLegend ?? series.length > 1,\n    showGrid: props.showGrid ?? true,\n    showYAxis,\n    root: {\n      role: \"group\" as const,\n      \"aria-roledescription\": props.roledescription,\n      \"aria-label\": label,\n      tabIndex: 0,\n      onKeyDown(event: React.KeyboardEvent) {\n        if (!n) return\n        const current = active ?? n - 1\n        const next =\n          // The arrows move the way they point on screen: right to left, ← is the next point.\n          event.key === (rtl ? \"ArrowRight\" : \"ArrowLeft\") ? Math.max(0, current - 1)\n          : event.key === (rtl ? \"ArrowLeft\" : \"ArrowRight\") ? Math.min(n - 1, current + 1)\n          : event.key === \"Home\" ? 0\n          : event.key === \"End\" ? n - 1\n          : undefined\n        if (event.key === \"Escape\") return pick(null)\n        if (next === undefined) return\n        event.preventDefault()\n        pick(next, true)\n      },\n      onBlur: () => pick(null),\n    },\n    plot: {\n      onPointerMove(event: React.PointerEvent<HTMLDivElement>) {\n        if (!n || !plotW) return\n        const offset = event.clientX - event.currentTarget.getBoundingClientRect().left - plotLeft\n        const px = rtl ? plotW - offset : offset\n        const i = band ? Math.floor(px / slot) : Math.round((px / plotW) * (n - 1))\n        pick(Math.min(n - 1, Math.max(0, i)))\n      },\n      onPointerLeave: () => pick(null),\n    },\n  }\n}\n\n// ── Around the marks ─────────────────────────────────────────────────────────────────────────────────────────────\n\ntype Chart = ReturnType<typeof useCartesianChart>\n\n/** The series, each with its color (a dashed swatch for a dashed series). */\nexport function ChartLegend({ series, className }: { series: ChartSeries[]; className?: string }) {\n  return (\n    <ul className={cn(\"flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground\", className)}>\n      {series.map((s, k) => (\n        <li key={s.key} className=\"flex items-center gap-1.5\">\n          <span\n            aria-hidden\n            className=\"h-0.5 w-3 rounded-full\"\n            style={s.dashed ? { backgroundImage: `linear-gradient(90deg, ${seriesColor(s, k)} 50%, transparent 50%)`, backgroundSize: \"4px 2px\" } : { background: seriesColor(s, k) }}\n          />\n          {s.label}\n        </li>\n      ))}\n    </ul>\n  )\n}\n\n/** The grid, the values along the left and the labels along the bottom. */\nexport function ChartAxes({ chart: h, band }: { chart: Chart; band: boolean }) {\n  const last = h.labels.length - 1\n  return (\n    <svg aria-hidden width={h.width!} height={h.height} className=\"absolute inset-0 overflow-visible\">\n      {h.ticks.map((v, i) => (\n        <g key={v}>\n          {h.showGrid && (\n            <line x1={h.plotLeft} x2={h.plotLeft + h.plotW} y1={Math.round(h.y(v)) + 0.5} y2={Math.round(h.y(v)) + 0.5} stroke=\"var(--chart-grid)\" strokeDasharray={i === 0 ? undefined : \"2 4\"} />\n          )}\n          {h.showYAxis && (\n            <text\n              x={h.rtl ? h.plotW + 10 : h.axisW - 10}\n              y={h.y(v)}\n              dy=\"0.32em\"\n              fontSize={11}\n              textAnchor={h.rtl ? \"start\" : \"end\"}\n              fill=\"var(--chart-axis)\"\n              className=\"tabular-nums\"\n            >\n              {h.tickLabels[i]}\n            </text>\n          )}\n        </g>\n      ))}\n      {h.xTicks.map((i) => (\n        <text\n          key={i}\n          x={h.x(i)}\n          y={h.height - 6}\n          fontSize={11}\n          // Points sit on the edges: the first and last labels align inward so they aren't cut off.\n          textAnchor={band ? \"middle\" : i === 0 ? (h.rtl ? \"end\" : \"start\") : i === last ? (h.rtl ? \"start\" : \"end\") : \"middle\"}\n          fill=\"var(--chart-axis)\"\n        >\n          {h.labels[i]}\n        </text>\n      ))}\n    </svg>\n  )\n}\n\n/**\n * The point being read: a line through it and a dot on each series (lines, areas; a bar chart fades the other columns\n * itself), and a tooltip with every value.\n */\nexport function ChartReading({ chart: h, series, band, className }: { chart: Chart; series: ChartSeries[]; band: boolean; className?: string }) {\n  // The tooltip's own size, measured, so it can sit right by the point and stay inside the chart.\n  const tip = React.useRef<HTMLDivElement>(null)\n  const [size, setSize] = React.useState({ w: 160, h: 64 })\n  const reading = h.active !== null\n  React.useLayoutEffect(() => {\n    const el = tip.current\n    if (!el) return\n    const measure = () => setSize((was) => (was.w === el.offsetWidth && was.h === el.offsetHeight ? was : { w: el.offsetWidth, h: el.offsetHeight }))\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [reading])\n  if (h.active === null) return null\n  const i = h.active\n  const x = h.x(i)\n  // The highest value at this point: the tallest bar, or the topmost line.\n  const peak = Math.min(...series.map((_, k) => h.y(h.columns[i]![k]!.top)))\n  const gap = 10\n  const minLeft = h.plotLeft\n  const maxLeft = h.plotLeft + h.plotW - size.w\n  const clampLeft = (left: number) => Math.max(minLeft, Math.min(maxLeft, left))\n  // Beside the point: on its right, or on its left past the middle of the chart.\n  const beside = (offset: number) => (x > h.plotLeft + h.plotW * 0.55 ? x - offset - size.w : x + offset)\n  let left: number\n  let top: number\n  if (band && peak - size.h - gap >= 0) {\n    // A column: centred just above its tallest bar.\n    left = clampLeft(x - size.w / 2)\n    top = peak - size.h - gap\n  } else {\n    // A point, or a bar too tall to have room above it: beside it, level with its top.\n    left = clampLeft(beside(band ? h.slot / 2 + 4 : 14))\n    top = Math.max(0, Math.min(h.bottom - size.h, band ? peak : peak - size.h / 2))\n  }\n  return (\n    <>\n      {!band && (\n        <svg aria-hidden width={h.width!} height={h.height} className=\"pointer-events-none absolute inset-0 overflow-visible\">\n          <line x1={Math.round(x) + 0.5} x2={Math.round(x) + 0.5} y1={h.top} y2={h.bottom} stroke=\"var(--chart-axis)\" strokeDasharray=\"3 3\" opacity={0.6} />\n          {series.map((s, k) => (\n            <circle key={s.key} cx={x} cy={h.y(h.columns[i]![k]!.top)} r={4} fill=\"var(--background)\" stroke={seriesColor(s, k)} strokeWidth={2} />\n          ))}\n        </svg>\n      )}\n      <div\n        ref={tip}\n        aria-hidden\n        // The plot is laid out left to right; the tooltip's words follow the page.\n        dir={h.rtl ? \"rtl\" : \"ltr\"}\n        className={cn(\"pointer-events-none absolute z-10 min-w-36 rounded-lg border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md\", className)}\n        style={{ left, top }}\n      >\n        <p className=\"mb-1.5 font-medium\">{h.titles[i]}</p>\n        <ul className=\"grid gap-1\">\n          {series.map((s, k) => (\n            <li key={s.key} className=\"flex items-center gap-2\">\n              <span aria-hidden className=\"size-2 shrink-0 rounded-sm\" style={{ background: seriesColor(s, k) }} />\n              <span className=\"flex-1 text-muted-foreground\">{s.label}</span>\n              <span className=\"font-medium tabular-nums\">{h.value.format(h.columns[i]![k]!.value)}</span>\n            </li>\n          ))}\n        </ul>\n      </div>\n    </>\n  )\n}\n"}]}