{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chart","title":"Chart","description":"Shared scales, curves, interaction, tooltip, legend and axes behind the Siteplane UI charts. Search terms: chart core, axis, d3, data visualization.","dependencies":["d3-scale@4.0.2","d3-array@3.2.4"],"devDependencies":["@types/d3-scale@4.0.9","@types/d3-array@3.2.2"],"registryDependencies":["@siteplane/base","@siteplane/badge","@siteplane/motion","@siteplane/tooltip"],"files":[{"path":"packages/ui/src/components/chart/chart-types.ts","content":"import type { CartesianChartLabels } from \"../../system/locale/messages\";\n\nexport type {\n  CartesianChartLabels,\n  ChartLabels,\n} from \"../../system/locale/messages\";\n\n/** An x value: category text, or a date for a time axis. */\nexport type ChartXValue = string | Date;\n\n/** One row of chart data: the x value and one number per series. */\nexport type ChartDatum = Record<\n  string,\n  string | number | Date | null | undefined\n>;\n\n/**\n * Where a chart takes the colour of a series that names none. `brand` shades\n * the brand colour from strong to light, spread over the number of series (up\n * to eight); `categorical` gives each series its own chart colour, `--chart-1`\n * to `--chart-8` in order. Both start over from the ninth series.\n */\nexport type ChartPalette = \"brand\" | \"categorical\";\n\n/** A series of a Bar, Line or Area chart: the key of its numbers in the data, its name, and optionally its colour. */\nexport type ChartSeries = {\n  key: string;\n  label: string;\n  /** A CSS colour or token. The chart's `palette` gives one for its place when omitted. */\n  color?: string;\n  /**\n   * Only the Bar Chart uses it: the colour of the bars of this series below 0,\n   * which also colours their tooltip row. The bars above 0 keep `color`. Without\n   * it a series has one colour whatever its sign.\n   */\n  negativeColor?: string;\n  /**\n   * Only the Bar Chart uses it: `line` draws the series as a line through the\n   * middle of its categories, over the bars and on the same value axis, and\n   * keeps it out of a stack. `bar`, the default, draws it as bars.\n   */\n  type?: \"bar\" | \"line\";\n};\n\n/**\n * How a line or an area connects its points. `monotone`, the default of the\n * Line and the Area Chart, bends through every point and never swings past the\n * data; `smooth` is a natural spline, which bends evenly and can swing a little\n * past the data between two points.\n */\nexport type ChartCurve = \"smooth\" | \"monotone\" | \"linear\" | \"step\";\n\n/** Shared by BarChart, LineChart and AreaChart. */\nexport type CartesianChartProps = {\n  data: readonly ChartDatum[];\n  /** Key of the x value in the data: category text, or a Date for a time axis. */\n  x: string;\n  series: readonly ChartSeries[];\n  /** The accessible name of the chart. */\n  label: string;\n  /**\n   * The id of an element that describes the chart, read after the summary of\n   * its data that the chart gives assistive technology itself.\n   */\n  \"aria-describedby\"?: string;\n  /**\n   * The height of the chart in px, a legend included, or `fill` to take the\n   * height of its box: the chart is as tall as the room its container gives it,\n   * as a flex item that grows, a grid cell or a box with a height, and never\n   * lower than `minHeight`. The width always follows the container.\n   */\n  height?: number | \"fill\";\n  /** With `height=\"fill\"`, the least height of the chart in px, a legend included. */\n  minHeight?: number;\n  /** Formats a value in the tooltip, and on the value axis when given. */\n  formatValue?: (value: number) => string;\n  /**\n   * Rolls the values in the tooltip and the label on the axis to the next\n   * item's, digit by digit (MotionNumber). Off by default: they change at\n   * once, so a pointer that moves across the plot costs the page little work.\n   */\n  rollingNumbers?: boolean;\n  /** Formats an x value on the axis and in the tooltip heading. */\n  formatX?: (value: ChartXValue) => string;\n  /** The locale of the numbers and dates: the SiteplaneLocaleProvider's unless set, en-US without one. */\n  locale?: string;\n  /**\n   * The colours of the series that name none. `brand` (the default) shades the\n   * brand colour from strong to light, spread over the number of series (up to\n   * eight); `categorical` gives each series its own chart colour, `--chart-1`\n   * to `--chart-8` in order. Both start over from the ninth series. A series\n   * that names its `color` keeps it either way.\n   */\n  palette?: ChartPalette;\n  /** Whether to list the series below the plot. On when there is more than one series. */\n  legend?: boolean;\n  /** Horizontal grid lines. */\n  grid?: boolean;\n  /** Labels along the x axis. */\n  xAxis?: boolean;\n  /** Labels along the value axis. */\n  yAxis?: boolean;\n  /** Shown in place of the marks when there is nothing to draw. */\n  emptyLabel?: string;\n  /**\n   * Replaces words that the chart gives screen readers, merged with the\n   * messages of SiteplaneLocaleProvider (English by default): its role\n   * description, the keyboard hint and the words of the summary of its data.\n   */\n  labels?: Partial<CartesianChartLabels> | undefined;\n  className?: string;\n};\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-types.ts"},{"path":"packages/ui/src/components/chart/chart-data.ts","content":"import type { ChartDatum } from \"./chart-types\";\n\n/** The finite number at `key`, or null for a missing, empty or non-finite value. */\nexport function chartValue(datum: ChartDatum, key: string): number | null {\n  const value = datum[key];\n  return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\nconst isValidDate = (value: unknown): value is Date =>\n  value instanceof Date && !Number.isNaN(value.getTime());\n\n/** A stable key for an x value: the text itself, or a Date's ISO string. Nothing and an invalid date have no key. */\nexport function chartXKey(value: unknown): string {\n  if (value instanceof Date)\n    return isValidDate(value) ? value.toISOString() : \"\";\n  return value === null || value === undefined ? \"\" : String(value);\n}\n\n/** True when every non-empty x value of the data is a valid Date, and there is at least one. */\nexport function isTimeData(data: readonly ChartDatum[], x: string): boolean {\n  let dates = 0;\n  for (const datum of data) {\n    const value = datum[x];\n    if (value === null || value === undefined) continue;\n    if (!isValidDate(value)) return false;\n    dates++;\n  }\n  return dates > 0;\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-data.ts"},{"path":"packages/ui/src/components/chart/chart-scales.ts","content":"import { extent, tickIncrement } from \"d3-array\";\nimport {\n  scaleBand,\n  scaleLinear,\n  scalePoint,\n  scaleUtc,\n  type ScaleBand,\n  type ScaleLinear,\n  type ScalePoint,\n  type ScaleTime,\n} from \"d3-scale\";\n\n// One tick for about every 48 px of an axis, never fewer than two intervals and\n// never more than six (seven tick values), so that a tall chart does not read like\n// ruled paper. Two ticks are never closer than 28 px: a label is 14 px tall.\nconst tickSpacing = 48;\nconst leastTickSpacing = 28;\nconst mostIntervals = 6;\nconst tickCount = (length: number) =>\n  Math.min(\n    mostIntervals,\n    Math.max(2, Math.round(Math.abs(length) / tickSpacing)),\n  );\n\n/**\n * [min, max] of the finite values. It includes 0 unless `includeZero` is false,\n * and never has no span: nothing to span gives [0, 1], one equal value on its\n * own gives one unit on either side.\n */\nexport function valueExtent(\n  values: Iterable<number | null>,\n  { includeZero = true }: { includeZero?: boolean } = {},\n): [number, number] {\n  const finite: number[] = [];\n  for (const value of values)\n    if (typeof value === \"number\" && Number.isFinite(value)) finite.push(value);\n  const [min, max] = extent(finite);\n  if (min === undefined || max === undefined) return [0, 1];\n  const low = includeZero ? Math.min(min, 0) : min;\n  const high = includeZero ? Math.max(max, 0) : max;\n  if (low !== high) return [low, high];\n  return includeZero ? [0, 1] : [low - 1, high + 1];\n}\n\n// A domain with no span cannot be niced or ticked.\nfunction spanning([low, high]: [number, number]): [number, number] {\n  if (!Number.isFinite(low) || !Number.isFinite(high)) return [0, 1];\n  return low === high ? [low, low + 1] : [low, high];\n}\n\n// The ticks that `valueScale` niced a domain for, so that `valueTicks` gives the\n// ticks that the domain ends on instead of working them out again.\nconst niceTicks = new WeakMap<ScaleLinear<number, number>, number[]>();\n\ntype Nice = { domain: [number, number]; ticks: number[] };\n\n/**\n * The domain that ends on ticks of the step that `hint` intervals ask for, found\n * in one pass: each end of the data moves out to the next multiple of the step.\n * d3's own nice() repeats this until the step stops changing, and each round can\n * widen the domain: -209 to 214 grows to -400 to 400.\n */\nfunction niceFor(low: number, high: number, hint: number): Nice | null {\n  const increment = tickIncrement(low, high, hint);\n  // A step below 1 is one over -increment; counting whole steps keeps the sums exact.\n  const whole = Math.abs(increment);\n  if (!Number.isFinite(whole) || whole === 0) return null;\n  const inSteps = (value: number) =>\n    increment > 0 ? value / whole : value * whole;\n  const inValues = (steps: number) =>\n    increment > 0 ? steps * whole : steps / whole;\n  const first = Math.floor(inSteps(low) + 1e-9);\n  const last = Math.max(first + 1, Math.ceil(inSteps(high) - 1e-9));\n  return {\n    domain: [inValues(first), inValues(last)],\n    ticks: Array.from({ length: last - first + 1 }, (_, index) =>\n      inValues(first + index),\n    ),\n  };\n}\n\n/**\n * The nice domain and ticks of a value axis `length` px long: of the steps that\n * ask for one to six intervals, the one that leaves the least of the axis empty,\n * as long as its ticks are at least 28 px apart and at most seven. Among equal\n * ones, the one with about one tick for every 48 px. Null when no step fits.\n */\nfunction niceAxis(low: number, high: number, length: number): Nice | null {\n  const ideal = tickCount(length);\n  const room = Math.min(\n    mostIntervals,\n    Math.max(2, Math.floor(Math.abs(length) / leastTickSpacing)),\n  );\n  let best: { empty: number; far: number; nice: Nice } | null = null;\n  for (let hint = 1; hint <= mostIntervals; hint++) {\n    const nice = niceFor(low, high, hint);\n    if (!nice) continue;\n    const intervals = nice.ticks.length - 1;\n    if (intervals > room) continue;\n    const span = nice.domain[1] - nice.domain[0];\n    const empty = (span - (high - low)) / span;\n    const far = Math.abs(intervals - ideal);\n    const better =\n      !best ||\n      empty < best.empty - 1e-9 ||\n      (Math.abs(empty - best.empty) <= 1e-9 &&\n        (far < best.far ||\n          (far === best.far && intervals > best.nice.ticks.length - 1)));\n    if (better) best = { empty, far, nice };\n  }\n  return best ? best.nice : null;\n}\n\n/**\n * A linear scale onto the pixel range over a nice domain of the values: its ends\n * are ticks, there are at most seven of them, and the data fills most of the\n * axis. The step is picked for the length of the range, from the data itself, so\n * that -209 to 214 on a short axis gives -300 to 300, not -400 to 400.\n */\nexport function valueScale(\n  domain: [number, number],\n  range: [number, number],\n): ScaleLinear<number, number> {\n  const [first, second] = spanning(domain);\n  const low = Math.min(first, second);\n  const high = Math.max(first, second);\n  const nice = niceAxis(low, high, range[1] - range[0]);\n  const scale = scaleLinear()\n    .domain(nice ? nice.domain : [low, high])\n    .range(range);\n  if (nice) niceTicks.set(scale, nice.ticks);\n  else scale.nice(tickCount(range[1] - range[0]));\n  return scale;\n}\n\n/**\n * A linear scale over exactly this domain, for a domain that is moving: a\n * domain that glides to a new one is not a nice one on the way.\n */\nexport function valueScaleAt(\n  domain: readonly [number, number],\n  range: [number, number],\n): ScaleLinear<number, number> {\n  return scaleLinear()\n    .domain(spanning([domain[0], domain[1]]))\n    .range(range);\n}\n\n/**\n * The tick values of a value axis `length` px long. For a scale from\n * `valueScale` they are the ticks it was niced for: its two ends and the steps\n * between them, seven values at most. For any other scale, about one tick for\n * every 48 px, at least two intervals and at most six.\n */\nexport function valueTicks(\n  scale: ScaleLinear<number, number>,\n  length: number,\n): number[] {\n  const own = niceTicks.get(scale);\n  return own\n    ? [...own]\n    : scale.ticks(tickCount(length)).filter(Number.isFinite);\n}\n\nexport function bandScale(\n  keys: readonly string[],\n  range: [number, number],\n  { inner = 0.2, outer = 0.1 }: { inner?: number; outer?: number } = {},\n): ScaleBand<string> {\n  return scaleBand<string>()\n    .domain(keys)\n    .range(range)\n    .paddingInner(inner)\n    .paddingOuter(outer);\n}\n\nexport function pointScale(\n  keys: readonly string[],\n  range: [number, number],\n): ScalePoint<string> {\n  return scalePoint<string>().domain(keys).range(range);\n}\n\n/**\n * A time scale from the first to the last date onto the range. One date sits in\n * the middle. It counts in UTC, as the charts write their dates in UTC, so its\n * calendar ticks fall on the same days on the server and in every time zone.\n */\nexport function timeScale(\n  dates: readonly Date[],\n  range: [number, number],\n): ScaleTime<number, number> {\n  const [first, last] = extent(dates);\n  return scaleUtc<number, number>()\n    .domain([first ?? new Date(0), last ?? new Date(1)])\n    .range(range);\n}\n\n/** The space kept between the labels of an axis, in px. */\nexport const labelGap = 12;\n\n// Two labels clear each other when the space between their edges is the gap.\nconst clear = (\n  distance: number,\n  width: number,\n  neighbour: number,\n  gap: number,\n) => distance >= (width + neighbour) / 2 + gap;\n\n/**\n * The step of an axis that shows every k-th label: one k for the whole axis, the\n * smallest for which no two labels that are shown overlap, so the labels keep an\n * even rhythm however many there are. `centres` are in px, ascending. When even\n * the widest step leaves no room, the step is the number of labels and only the\n * first is shown.\n */\nexport function labelStep(\n  centres: readonly number[],\n  widths: readonly number[],\n  gap = labelGap,\n): number {\n  const count = centres.length;\n  for (let step = 1; step < count; step++) {\n    let previous = -1;\n    let fits = true;\n    for (let index = 0; index < count && fits; index += step) {\n      fits =\n        previous < 0 ||\n        clear(\n          centres[index]! - centres[previous]!,\n          widths[index] ?? 0,\n          widths[previous] ?? 0,\n          gap,\n        );\n      previous = index;\n    }\n    if (fits) return step;\n  }\n  return Math.max(1, count);\n}\n\n/**\n * The labels of a time axis, on the time scale's own calendar ticks (days,\n * weeks, months, ...): as many as fit without any two overlapping, which the\n * calendar spaces evenly. They need not sit on data points. `widthOf` gives the\n * width of the label a date would have. When no tick fits, or the domain has no\n * span, the first date of the domain is the one label. The scale may run\n * through several places (`rowTimeScale`); the axis is its first to its last.\n */\nexport function calendarTicks(\n  scale: ScaleTime<number, number>,\n  widthOf: (date: Date) => number,\n  gap = labelGap,\n): Date[] {\n  const range = scale.range();\n  const from = range[0] ?? 0;\n  const to = range[range.length - 1] ?? 0;\n  const fits = (ticks: readonly Date[]) =>\n    ticks.every(\n      (tick, index) =>\n        index === 0 ||\n        clear(\n          scale(tick) - scale(ticks[index - 1]!),\n          widthOf(tick),\n          widthOf(ticks[index - 1]!),\n          gap,\n        ),\n    );\n  // From the most that could be drawn, one for every 24 px, down to one.\n  for (\n    let count = Math.max(1, Math.floor(Math.abs(to - from) / 24));\n    count >= 1;\n    count--\n  ) {\n    const ticks = scale.ticks(count);\n    if (ticks.length > 0 && fits(ticks)) return ticks;\n  }\n  const [first] = scale.domain();\n  return first ? [first] : [];\n}\n\n/**\n * A time scale through the places of dated rows, such as the bands of a Bar\n * Chart: each date at the place of its row, and linear between two rows, so a\n * calendar tick that falls between two rows stands between them, as far along\n * as its date is. The dates are in order; a date that repeats keeps the place\n * of its first row. Null for fewer than two dates, which have no span.\n */\nexport function rowTimeScale(\n  dates: readonly Date[],\n  places: readonly number[],\n): ScaleTime<number, number> | null {\n  const domain: Date[] = [];\n  const range: number[] = [];\n  dates.forEach((date, index) => {\n    const place = places[index];\n    const time = date.getTime();\n    const last = domain[domain.length - 1];\n    if (place === undefined || !Number.isFinite(time)) return;\n    if (last !== undefined && time <= last.getTime()) return;\n    domain.push(date);\n    range.push(place);\n  });\n  if (domain.length < 2) return null;\n  return scaleUtc<number, number>().domain(domain).range(range);\n}\n\n/** A label of a date axis: where it sits, what it says, and its date as its key. */\nexport type CalendarLabel = { id: string; position: number; text: string };\n\n/**\n * The labels of a date axis, one rule for the Line, the Area and the Bar Chart\n * (Lukas, 3 November 2026): the calendar ticks of `scale` (days, weeks,\n * months, ...), as many as fit with `gap` between them (`calendarTicks`),\n * written by `format` and keyed by their date in ISO form, the key of a row\n * on that date. `size` gives a label's extent along the axis: the width of its\n * text by default, the height of a line down a side.\n */\nexport function calendarLabels(\n  scale: ScaleTime<number, number>,\n  format: (date: Date) => string,\n  size: (text: string) => number = estimateLabelWidth,\n  gap = labelGap,\n): CalendarLabel[] {\n  const labels = calendarTicks(scale, (date) => size(format(date)), gap).map(\n    (tick) => ({\n      id: tick.toISOString(),\n      position: Math.round(scale(tick) * 100) / 100,\n      text: format(tick),\n    }),\n  );\n  // A calendar finer than the text, such as hours written as days, would repeat it.\n  return labels.filter(\n    (label, index) => index === 0 || label.text !== labels[index - 1]!.text,\n  );\n}\n\n/**\n * Which labels of a moving axis to draw, so that no two that can be read\n * together overlap. A label at rest is always drawn. One that is leaving, and one\n * that is entering, is drawn only while no label it is read with is closer than\n * `gap` plus half the sizes of both: the labels at rest, and the others that go\n * or come with it. The old labels are out before the new ones are in, so a\n * leaving and an entering label are never read together. A set of labels that\n * glides into itself, as the labels of a value axis do while new data stretches\n * it, is therefore left out for as long as it would touch, and is never partly\n * there. `sizes` are the extents of the labels along the axis, in px.\n */\nexport function readableLabels(\n  centres: readonly number[],\n  sizes: readonly number[],\n  presences: readonly (\"entering\" | \"leaving\" | undefined)[],\n  gap = labelGap,\n): boolean[] {\n  return centres.map((centre, index) => {\n    const presence = presences[index];\n    if (!presence) return true;\n    return centres.every((other, neighbour) => {\n      const group = presences[neighbour];\n      return (\n        neighbour === index ||\n        (group !== undefined && group !== presence) ||\n        clear(\n          Math.abs(other - centre),\n          sizes[index] ?? 0,\n          sizes[neighbour] ?? 0,\n          gap,\n        )\n      );\n    });\n  });\n}\n\n// Measured once in Chromium against Wix Madefor Text at 12 px, the kit font\n// before Inter: digits are 7.1 to 7.7 px wide, letters 6.2 to 7.5 px, a space\n// or punctuation about 3.4 px, and m, w and % are wider. Inter, at the kit's\n// weight and tracking, sets the same labels within 5 % of those widths. The\n// estimate still errs on the wide side for numbers, so a label never lands\n// closer to its neighbour than planned.\nconst narrowCharacter = /[ .,:;'!|il]/;\nconst wideCharacter = /[mwMW%@]/;\nconst digitCharacter = /[0-9]/;\n\n/**\n * A width estimate for a label at 12 px in the kit font, so that the server and\n * the client lay out alike. Within about 15 % of the measured width.\n */\nexport function estimateLabelWidth(text: string): number {\n  let width = 0;\n  for (const character of text) {\n    width += characterWidth(character);\n  }\n  return Math.round(width * 10) / 10;\n}\n\n// The ellipsis that ends a cut label, reckoned on the wide side.\nconst ellipsis = \"…\";\nconst ellipsisWidth = 10;\n\nfunction characterWidth(character: string): number {\n  if (character === ellipsis) return ellipsisWidth;\n  return narrowCharacter.test(character)\n    ? 3.4\n    : wideCharacter.test(character)\n      ? 9.5\n      : digitCharacter.test(character)\n        ? 7.3\n        : 7;\n}\n\n/**\n * A label cut to `width` px as `estimateLabelWidth` reckons it, ending in an\n * ellipsis, or the label itself when it fits. An SVG label cannot truncate\n * itself, so a chart cuts the text before it draws it. The full text stays in\n * the tooltip and in what the chart reads to screen readers.\n */\nexport function truncateLabel(text: string, width: number): string {\n  if (estimateLabelWidth(text) <= width) return text;\n  let room = width - ellipsisWidth;\n  let kept = \"\";\n  for (const character of text) {\n    room -= characterWidth(character);\n    if (room < 0) break;\n    kept += character;\n  }\n  return `${kept.trimEnd()}${ellipsis}`;\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-scales.ts"},{"path":"packages/ui/src/components/chart/chart-stack.ts","content":"import { chartValue, chartXKey } from \"./chart-data\";\nimport type { ChartDatum } from \"./chart-types\";\n\n/** One series' share of one category: its value and the two ends it spans on the value axis. */\nexport type StackedSegment = {\n  /** The key of the category or date, as `chartXKey` reads it. */\n  x: string;\n  series: string;\n  value: number;\n  y0: number;\n  y1: number;\n};\n\n/**\n * A diverging stack: positive values stack up from 0 in series order, negative\n * values stack down from 0, and a missing value adds nothing, with no segment\n * of its own. Each segment spans `y0` to `y1`, so `y1 - y0` is its value.\n */\nexport function stackSeries(\n  data: readonly ChartDatum[],\n  x: string,\n  seriesKeys: readonly string[],\n): StackedSegment[] {\n  const segments: StackedSegment[] = [];\n  for (const datum of data) {\n    const key = chartXKey(datum[x]);\n    let up = 0;\n    let down = 0;\n    for (const series of seriesKeys) {\n      const value = chartValue(datum, series);\n      if (value === null) continue;\n      if (value >= 0) {\n        const y0 = up;\n        up += value;\n        segments.push({ x: key, series, value, y0, y1: up });\n      } else {\n        const y0 = down;\n        down += value;\n        segments.push({ x: key, series, value, y0, y1: down });\n      }\n    }\n  }\n  return segments;\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-stack.ts"},{"path":"packages/ui/src/components/chart/chart-model.ts","content":"/*\n * What the charts with a category or time axis read from their data before any\n * layout, and the small rules their axes share while new data glides in. The\n * Line, Area and Bar Charts all build their rows, values and spans here, so a\n * row, a missing value and a stack mean the same in each of them.\n */\n\nimport { chartValue, chartXKey, isTimeData } from \"./chart-data\";\nimport { CHART_DEFAULT_LABELS } from \"./chart-format\";\nimport { valueExtent } from \"./chart-scales\";\nimport { stackSeries } from \"./chart-stack\";\nimport type {\n  CartesianChartLabels,\n  ChartDatum,\n  ChartSeries,\n  ChartXValue,\n} from \"./chart-types\";\n\n/**\n * Where a part of an axis stands when new data replaces it: `entering` has come\n * in and shows at once, `leaving` is on its way out and is hidden at once (the\n * kit's content switch, rulebook M8). A part at rest has none.\n */\nexport type ChartPresence = \"entering\" | \"leaving\";\n\n/** One place along the x axis: a category or a date, with the data that belongs to it. */\nexport type CartesianRow = {\n  /** The key of the row: its text or ISO date, made unique when the data repeats it. */\n  id: string;\n  x: ChartXValue;\n  datum: ChartDatum;\n};\n\nexport type CartesianModel = {\n  /** The rows in the order of the axis: as given for categories, by date for dates. A row with no x is left out. */\n  rows: CartesianRow[];\n  time: boolean;\n  /** Per series, per row: the series' own number, or null. */\n  values: (number | null)[][];\n  /** Per series, per row: where its mark ends on the value axis: the value, or the top of its band when stacked. */\n  tops: (number | null)[][];\n  /** Per series, per row: where its mark starts on the value axis: 0, or the bottom of its band when stacked. */\n  bases: (number | null)[][];\n  /** The span of the value axis the marks need, 0 included. */\n  extent: [number, number];\n  /** Nothing to draw: no number in any series. */\n  empty: boolean;\n};\n\n/** What a chart draws, read from the data without any layout. */\nexport function buildCartesianModel({\n  data,\n  x,\n  series,\n  stacked,\n  unstacked = [],\n}: {\n  data: readonly ChartDatum[];\n  x: string;\n  series: readonly ChartSeries[];\n  stacked: boolean;\n  /**\n   * The keys of series that stay out of a stack, such as the line series of a\n   * Bar Chart: their marks end at their own value and start at 0.\n   */\n  unstacked?: readonly string[];\n}): CartesianModel {\n  const time = isTimeData(data, x);\n  const seen = new Map<string, number>();\n  const rows: CartesianRow[] = [];\n  for (const datum of data) {\n    const raw = datum[x];\n    const key = chartXKey(raw);\n    if (key === \"\" || (time && !(raw instanceof Date))) continue;\n    const count = seen.get(key) ?? 0;\n    seen.set(key, count + 1);\n    rows.push({\n      datum,\n      id: count === 0 ? key : `${key}#${count}`,\n      x: raw instanceof Date ? raw : String(raw),\n    });\n  }\n  if (time)\n    rows.sort((a, b) => (a.x as Date).getTime() - (b.x as Date).getTime());\n\n  const keys = series.map((item) => item.key);\n  const values = keys.map((key) =>\n    rows.map((row) => chartValue(row.datum, key)),\n  );\n  let tops: (number | null)[][] = values;\n  let bases: (number | null)[][] = values.map((own) =>\n    own.map((value) => (value === null ? null : 0)),\n  );\n  if (stacked) {\n    const apart = new Set(unstacked);\n    const stackKeys = keys.filter((key) => !apart.has(key));\n    tops = keys.map((key, place) =>\n      apart.has(key) ? values[place]! : rows.map((): number | null => null),\n    );\n    bases = keys.map((key, place) =>\n      apart.has(key)\n        ? values[place]!.map((value) => (value === null ? null : 0))\n        : rows.map((): number | null => null),\n    );\n    rows.forEach((row, index) => {\n      for (const segment of stackSeries([row.datum], x, stackKeys)) {\n        const place = keys.indexOf(segment.series);\n        tops[place]![index] = segment.y1;\n        bases[place]![index] = segment.y0;\n      }\n    });\n  }\n  return {\n    bases,\n    empty: values.every((own) => own.every((value) => value === null)),\n    extent: valueExtent([...tops.flat(), ...bases.flat()]),\n    rows,\n    time,\n    tops,\n    values,\n  };\n}\n\n/**\n * The data of a chart in words, for the description of its plot: where the axis\n * starts and ends, then the lowest and the highest number of each series that\n * has any, such as \"Jan to Jun. Desktop: lowest 73, highest 305.\" A series\n * with a single number says it alone. A chart with no numbers says its empty\n * label. The chart's own formats write the x values and the numbers, and its\n * `labels` the words, which are English by default.\n */\nexport function cartesianSummary({\n  emptyLabel,\n  formatValue,\n  formatX,\n  labels = CHART_DEFAULT_LABELS,\n  model,\n  series,\n}: {\n  emptyLabel: string;\n  formatValue: (value: number) => string;\n  formatX: (value: ChartXValue) => string;\n  labels?: Pick<CartesianChartLabels, \"highest\" | \"lowest\" | \"to\">;\n  model: CartesianModel;\n  series: readonly ChartSeries[];\n}): string {\n  const first = model.rows[0];\n  const last = model.rows[model.rows.length - 1];\n  if (model.empty || !first || !last) return emptyLabel;\n  const span =\n    first === last\n      ? formatX(first.x)\n      : [formatX(first.x), labels.to, formatX(last.x)]\n          .filter(Boolean)\n          .join(\" \");\n  const ranges = series.flatMap((item, place) => {\n    const numbers = (model.values[place] ?? []).filter(\n      (value): value is number => value !== null,\n    );\n    if (numbers.length === 0) return [];\n    const lowest = numbers.reduce((low, value) => Math.min(low, value));\n    const highest = numbers.reduce((high, value) => Math.max(high, value));\n    const word = (text: string, value: number) =>\n      [text, formatValue(value)].filter(Boolean).join(\" \");\n    return [\n      lowest === highest\n        ? `${item.label}: ${formatValue(lowest)}`\n        : `${item.label}: ${word(labels.lowest, lowest)}, ${word(labels.highest, highest)}`,\n    ];\n  });\n  // A full stop ends the summary, so that what is read after it, such as the\n  // keyboard hint, is heard as a sentence of its own.\n  return `${[span, ...ranges].join(\". \")}.`;\n}\n\n/**\n * Where a keyed row stands in a change, from its tween: a row on its way\n * out is `leaving`, one that has not yet arrived `entering`, one at rest neither.\n */\nexport function presenceOf(entry: {\n  leaving: boolean;\n  value: number;\n}): ChartPresence | undefined {\n  return entry.leaving ? \"leaving\" : entry.value < 1 ? \"entering\" : undefined;\n}\n\n/**\n * Where a tick of the value axis is drawn. One that stays or comes in sits where\n * the axis puts it, and moves with the axis while it glides. One that goes out\n * is hidden where it stood: the axis may have moved far since, and carried\n * along it would crowd its labels into each other.\n */\nexport function tickPosition(\n  entry: { leaving: boolean; row: { position: number; value: number } },\n  scale: (value: number) => number,\n): number {\n  return entry.leaving ? entry.row.position : scale(entry.row.value);\n}\n\n/** The generation of something that is replaced as a whole when its signature changes. */\nexport type Generation = { count: number; signature: string };\n\n/** The generation after a render with this signature: the same while it stays, the next once it changes. */\nexport function advanceGeneration(\n  state: Generation,\n  signature: string,\n): Generation {\n  return state.signature === signature\n    ? state\n    : { count: state.count + 1, signature };\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-model.ts"},{"path":"packages/ui/src/components/chart/chart-format.ts","content":"import { normalizeFormattedText } from \"../../system/locale/formatted-text\";\nimport { englishMessages } from \"../../system/locale/messages\";\nimport type { CartesianChartLabels, ChartXValue } from \"./chart-types\";\n\n/** Charts format numbers and dates in this locale unless they are given another or a SiteplaneLocaleProvider sets one, so the server and the client agree. */\nexport const CHART_DEFAULT_LOCALE = \"en-US\";\n\n/**\n * The English words that charts give screen readers (the `chart` messages of\n * the kit), which the provider's messages and a chart's `labels` replace one\n * by one.\n */\nexport const CHART_DEFAULT_LABELS: Readonly<CartesianChartLabels> = {\n  highest: englishMessages.chart.highest,\n  keyboardHint: englishMessages.chart.keyboardHint,\n  lowest: englishMessages.chart.lowest,\n  roleDescription: englishMessages.chart.roleDescription,\n  to: englishMessages.chart.to,\n};\n\n/**\n * The words of a chart: those of `base`, the chart messages of the provider\n * (the English defaults without one), with each one that `labels` gives in\n * its place. A word given as undefined keeps the one of `base`.\n */\nexport function chartLabels(\n  labels?: Partial<CartesianChartLabels> | undefined,\n  base: CartesianChartLabels = CHART_DEFAULT_LABELS,\n): CartesianChartLabels {\n  const words = { ...CHART_DEFAULT_LABELS };\n  for (const key of Object.keys(words) as (keyof CartesianChartLabels)[]) {\n    const given = labels?.[key];\n    words[key] = typeof given === \"string\" ? given : base[key];\n  }\n  return words;\n}\n\n// Intl formatters are costly to build and charts ask for them on every render.\nconst formatters = new Map<string, Intl.NumberFormat | Intl.DateTimeFormat>();\nfunction cached<T extends Intl.NumberFormat | Intl.DateTimeFormat>(\n  key: string,\n  create: () => T,\n): T {\n  let formatter = formatters.get(key);\n  if (!formatter) {\n    formatter = create();\n    formatters.set(key, formatter);\n  }\n  return formatter as T;\n}\n\n/** A value as the locale writes a number: 1,234.5 */\nexport function chartNumberFormatter(\n  locale: string = CHART_DEFAULT_LOCALE,\n): (value: number) => string {\n  const format = cached(\n    `number|${locale}`,\n    () => new Intl.NumberFormat(locale),\n  );\n  return (value) => normalizeFormattedText(format.format(value));\n}\n\n/**\n * A figure in the format of `options` (Intl.NumberFormat options, such as a\n * currency), or the same figure in compact notation (1.2M), which keeps one\n * fraction digit at most and pins its fraction digits, so that the server and\n * the client write it alike.\n */\nexport function chartFigureFormatter(\n  locale: string = CHART_DEFAULT_LOCALE,\n  options: Intl.NumberFormatOptions = {},\n  compact = false,\n): (value: number) => string {\n  const own = compact\n    ? {\n        ...options,\n        maximumFractionDigits: 1,\n        minimumFractionDigits: 0,\n        notation: \"compact\" as const,\n      }\n    : options;\n  const format = cached(\n    `figure|${locale}|${JSON.stringify(own)}`,\n    () => new Intl.NumberFormat(locale, own),\n  );\n  // A currency or a unit in another locale has spaces that ICU versions write\n  // differently, such as 1.234,50 € (normalizeFormattedText).\n  return (value) => normalizeFormattedText(format.format(value));\n}\n\n/** A value as a compact tick label: 1.2K */\nexport function chartTickFormatter(\n  locale: string = CHART_DEFAULT_LOCALE,\n): (value: number) => string {\n  const format = cached(\n    `tick|${locale}`,\n    () =>\n      new Intl.NumberFormat(locale, {\n        maximumFractionDigits: 1,\n        notation: \"compact\",\n      }),\n  );\n  return (value) => normalizeFormattedText(format.format(value));\n}\n\n// The compact form keeps one fraction digit of the thousands, millions, ... a\n// value reaches. A value that needs more than that would be rounded.\nconst compactUnits = [1e12, 1e9, 1e6, 1e3];\nfunction fitsCompact(value: number): boolean {\n  const unit = compactUnits.find((candidate) => Math.abs(value) >= candidate);\n  const tenths = (value / (unit ?? 1)) * 10;\n  return (\n    Math.abs(tenths - Math.round(tenths)) < 1e-9 * Math.max(1, Math.abs(tenths))\n  );\n}\n\n/**\n * The labels of a value axis. A formatter that is given writes them. Otherwise\n * they are compact (1.2K), unless that would round a tick, as 1,005 would read\n * 1K: then they are written out.\n */\nexport function chartTickLabels(\n  ticks: readonly number[],\n  locale: string = CHART_DEFAULT_LOCALE,\n  format?: (value: number) => string,\n): string[] {\n  if (format) return ticks.map((tick) => format(tick));\n  const write = ticks.every(fitsCompact)\n    ? chartTickFormatter(locale)\n    : chartNumberFormatter(locale);\n  return ticks.map((tick) => write(tick));\n}\n\n/**\n * An x value as text: a category as it is, a date short (Jan 5) or long (Jan 5,\n * 2026). Dates are written in UTC, so that the server and the client agree\n * whatever time zone each runs in; a date at midnight UTC, as `new\n * Date(\"2026-01-05\")` gives, reads as that day everywhere.\n */\nexport function chartXFormatter(\n  locale: string = CHART_DEFAULT_LOCALE,\n  length: \"short\" | \"long\" = \"short\",\n): (value: ChartXValue) => string {\n  const format = cached(\n    `date|${length}|${locale}`,\n    () =>\n      new Intl.DateTimeFormat(locale, {\n        day: \"numeric\",\n        month: \"short\",\n        timeZone: \"UTC\",\n        ...(length === \"long\" ? { year: \"numeric\" } : {}),\n      }),\n  );\n  return (value) =>\n    value instanceof Date\n      ? Number.isNaN(value.getTime())\n        ? \"\"\n        : normalizeFormattedText(format.format(value))\n      : value;\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-format.ts"},{"path":"packages/ui/src/components/chart/chart-palette.ts","content":"import type { ChartPalette, ChartSeries } from \"./chart-types\";\n\n// How many colours each palette has before it starts over: `--chart-1` to\n// `--chart-8`, and the eight brand shades.\nconst paletteSize = 8;\n\n/** The colour of the series at `index`: `--chart-1` to `--chart-8` in order, then round again. */\nexport function chartSeriesColor(index: number): string {\n  return `var(--chart-${(index % paletteSize) + 1})`;\n}\n\n/**\n * A brand shade: `share` percent of the chart's brand colour (`--chart-brand`,\n * the lifted brand in Dark), the rest the colour the shades fade towards:\n * `--chart-brand-fade` where a theme sets one, otherwise the card surface.\n */\nfunction brandShade(share: number): string {\n  return `color-mix(in oklab, var(--chart-brand) ${share}%, var(--chart-brand-fade, var(--sp-rw-bg-secondary)))`;\n}\n\n/**\n * The shares of the brand colour in the shades of a chart of 1 to 8 series,\n * from strong to light. Every count starts at the brand colour itself and\n * spreads its other shades over the range, so that two series stand clearly\n * apart and five take the five steps. Six to eight series share the range of\n * five, 100 down to 24, and keep its curve: their shares are the five steps\n * read at their own places along it (linear between two steps, rounded), so\n * the strong shades stay further apart than the light ones, as with five.\n */\nconst brandShares = [\n  [100],\n  [100, 55],\n  [100, 70, 45],\n  [100, 76, 56, 38],\n  [100, 76, 56, 38, 24],\n  [100, 81, 64, 49, 35, 24],\n  [100, 84, 69, 56, 44, 33, 24],\n  [100, 86, 73, 62, 51, 41, 32, 24],\n] as const;\n\n/** The brand shades of five series, from strong to light, faded into the card (towards a deeper brand in Dark). The Bar List draws its bars in the lightest. */\nexport const chartBrandShades: readonly string[] =\n  brandShares[4].map(brandShade);\n\n/**\n * The brand shades of `count` series, from strong to light: the first series is\n * the brand colour itself. Up to eight series are spread over the range of the\n * shades; more than eight repeat the eight-step order.\n */\nexport function chartBrandPalette(count: number): string[] {\n  const places = Math.max(0, Math.floor(count));\n  if (places <= brandShares.length)\n    return places === 0 ? [] : brandShares[places - 1]!.map(brandShade);\n  const steps = brandShares[brandShares.length - 1]!.map(brandShade);\n  return Array.from(\n    { length: places },\n    (_, index) => steps[index % steps.length]!,\n  );\n}\n\n/** The colours of the `count` series of `palette`, in the order of their places. */\nexport function chartPaletteColors(\n  palette: ChartPalette,\n  count: number,\n): string[] {\n  return palette === \"brand\"\n    ? chartBrandPalette(count)\n    : Array.from({ length: Math.max(0, Math.floor(count)) }, (_, index) =>\n        chartSeriesColor(index),\n      );\n}\n\n// The step that the last share of a ring takes when the steps would close the\n// ring on the first share's colour: the third, which is neither the step\n// before it (the last of the round) nor the first.\nconst ringClosingStep = 2;\n\n/**\n * The colours of the `count` filled shares of a ring, in order: the palette's,\n * except that the last share never takes the colour of the first, which it\n * meets where the ring closes. From the ninth share the eight steps repeat, so\n * a ring of 9, 17, 25, ... shares would close on two shares of one colour; its\n * last share takes the third step instead, which differs from both of its\n * neighbours.\n */\nexport function chartRingColors(\n  palette: ChartPalette,\n  count: number,\n): string[] {\n  const colors = chartPaletteColors(palette, count);\n  const last = colors.length - 1;\n  if (last > ringClosingStep && colors[last] === colors[0])\n    colors[last] = colors[ringClosingStep]!;\n  return colors;\n}\n\n/** An empty share reads as the unfilled rest of the ring. */\nexport const chartEmptyColor =\n  \"color-mix(in oklab, var(--sp-rw-fg-quaternary) 26%, var(--sp-rw-bg-secondary))\";\n\n/** The colour of every series: the one it names, otherwise the one the palette has for its place. */\nexport function seriesColors(\n  series: readonly ChartSeries[],\n  palette: ChartPalette = \"brand\",\n): string[] {\n  const colors = chartPaletteColors(palette, series.length);\n  return series.map((item, index) => item.color ?? colors[index]!);\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-palette.ts"},{"path":"packages/ui/src/components/chart/chart-curve.ts","content":"import type { ChartCurve } from \"./chart-types\";\n\n/**\n * The curves of the charts, kept apart from the components so that they can be\n * checked without a DOM: the path through the data (smooth, monotone, straight\n * or stepped), and the same curve as heights at fixed places, which is what a\n * chart morphs when new data arrives.\n */\nexport type ChartPoint = readonly [x: number, y: number];\n\nconst round = (value: number) => Math.round(value * 100) / 100;\nconst pair = ([x, y]: ChartPoint) => `${round(x)},${round(y)}`;\n\n// Monotone cubic interpolation (Steffen's method, as in d3's curveMonotoneX):\n// a smooth path through every point. Each tangent is limited by the\n// neighbouring slopes, so the curve never overshoots the data and flattens on\n// a local extreme instead of showing a plateau at every point.\nfunction monotoneTangents(points: readonly ChartPoint[]): number[] {\n  const last = points.length - 1;\n  const width = (i: number) => points[i + 1]![0] - points[i]![0];\n  const slope = (i: number) => (points[i + 1]![1] - points[i]![1]) / width(i);\n  const tangents: number[] = [];\n  for (let i = 1; i < last; i++) {\n    const before = slope(i - 1);\n    const after = slope(i);\n    const weighted =\n      (before * width(i) + after * width(i - 1)) / (width(i - 1) + width(i));\n    tangents[i] =\n      (Math.sign(before) + Math.sign(after)) *\n        Math.min(Math.abs(before), Math.abs(after), Math.abs(weighted) / 2) ||\n      0;\n  }\n  tangents[0] = (3 * slope(0) - tangents[1]!) / 2;\n  tangents[last] = (3 * slope(last - 1) - tangents[last - 1]!) / 2;\n  return tangents;\n}\n\n// Natural cubic spline (d3's curveNatural for evenly spaced points): the\n// smooth path through every point whose bend runs on across each point, so\n// that it never changes abruptly, and is straight at both ends. A monotone\n// curve bends at each point on its own, which shows as a knee wherever the\n// data turns from steep to flat. The price is that the spline can swing a\n// little past the data between two points. Its tangents come from one\n// tridiagonal system over the whole run (Thomas's algorithm), for points at\n// any distance apart.\nfunction naturalTangents(points: readonly ChartPoint[]): number[] {\n  const last = points.length - 1;\n  const width = (i: number) => points[i + 1]![0] - points[i]![0];\n  const slope = (i: number) => (points[i + 1]![1] - points[i]![1]) / width(i);\n  // Row i: below[i] * t[i - 1] + middle[i] * t[i] + above[i] * t[i + 1] = right[i].\n  const below: number[] = [0];\n  const middle: number[] = [2];\n  const above: number[] = [1];\n  const right: number[] = [3 * slope(0)];\n  for (let i = 1; i < last; i++) {\n    below[i] = width(i);\n    middle[i] = 2 * (width(i - 1) + width(i));\n    above[i] = width(i - 1);\n    right[i] = 3 * (width(i) * slope(i - 1) + width(i - 1) * slope(i));\n  }\n  below[last] = 1;\n  middle[last] = 2;\n  above[last] = 0;\n  right[last] = 3 * slope(last - 1);\n  for (let i = 1; i <= last; i++) {\n    const factor = below[i]! / middle[i - 1]!;\n    middle[i] = middle[i]! - factor * above[i - 1]!;\n    right[i] = right[i]! - factor * right[i - 1]!;\n  }\n  const tangents: number[] = [];\n  tangents[last] = right[last]! / middle[last]!;\n  for (let i = last - 1; i >= 0; i--)\n    tangents[i] = (right[i]! - above[i]! * tangents[i + 1]!) / middle[i]!;\n  return tangents;\n}\n\n// The tangents of a curve that bends: the natural spline for `smooth`, the\n// monotone curve for `monotone`. Two points at the same x have no slope between\n// them: a plot too narrow to part its points puts them there, and so do two\n// rows on one date. The curve breaks at such a place into pieces that bend on\n// their own, joined by a straight step from one point to the other, so no\n// tangent divides by a width of 0. A piece of two points is a straight line,\n// and a point on its own is level. A tangent that is still not a number is\n// level as well, so that the curve stays drawable whatever the points are.\nfunction tangentsOf(\n  points: readonly ChartPoint[],\n  curve: \"smooth\" | \"monotone\",\n): number[] {\n  const tangents: number[] = [];\n  let from = 0;\n  for (let i = 1; i <= points.length; i++) {\n    if (i < points.length && points[i]![0] !== points[i - 1]![0]) continue;\n    const piece = points.slice(from, i);\n    if (piece.length > 2)\n      tangents.push(\n        ...(curve === \"monotone\"\n          ? monotoneTangents(piece)\n          : naturalTangents(piece)),\n      );\n    else if (piece.length === 2) {\n      const [[x0, y0], [x1, y1]] = piece as [ChartPoint, ChartPoint];\n      const slope = (y1 - y0) / (x1 - x0);\n      tangents.push(slope, slope);\n    } else tangents.push(0);\n    from = i;\n  }\n  return tangents.map((tangent) => (Number.isFinite(tangent) ? tangent : 0));\n}\n\n// The control points of the cubic between point i and the next.\nfunction segmentControls(\n  points: readonly ChartPoint[],\n  tangents: readonly number[],\n  i: number,\n): [out: ChartPoint, into: ChartPoint] {\n  const [x0, y0] = points[i]!;\n  const [x1, y1] = points[i + 1]!;\n  const reach = (x1 - x0) / 3;\n  return [\n    [x0 + reach, y0 + reach * tangents[i]!],\n    [x1 - reach, y1 - reach * tangents[i + 1]!],\n  ];\n}\n\n// The path of cubics through the points along the given tangents, or straight\n// lines for fewer than three points.\nfunction bentPath(\n  points: readonly ChartPoint[],\n  curve: \"smooth\" | \"monotone\",\n): string {\n  const last = points.length - 1;\n  if (last < 2)\n    return points.map((point, i) => `${i ? \"L\" : \"M\"}${pair(point)}`).join(\" \");\n  const tangents = tangentsOf(points, curve);\n  let path = `M${pair(points[0]!)}`;\n  for (let i = 0; i < last; i++) {\n    const [out, into] = segmentControls(points, tangents, i);\n    path += ` C${pair(out)} ${pair(into)} ${pair(points[i + 1]!)}`;\n  }\n  return path;\n}\n\n/** The path through the points: a natural spline, or straight lines for fewer than three. */\nexport function naturalPath(points: readonly ChartPoint[]): string {\n  return bentPath(points, \"smooth\");\n}\n\n/** The path through the points: a monotone curve, or straight lines for fewer than three. */\nexport function monotonePath(points: readonly ChartPoint[]): string {\n  return bentPath(points, \"monotone\");\n}\n\n/** The path of straight lines through every point. */\nexport function linearPath(points: readonly ChartPoint[]): string {\n  return points.map((point, i) => `${i ? \"L\" : \"M\"}${pair(point)}`).join(\" \");\n}\n\n/**\n * The path of a step through every point: level from each point to the middle\n * between it and the next, then a vertical step to the height of the next\n * (d3's curveStep). It runs along horizontals and verticals only.\n */\nexport function stepPath(points: readonly ChartPoint[]): string {\n  if (!points.length) return \"\";\n  let path = `M${pair(points[0]!)}`;\n  for (let i = 1; i < points.length; i++) {\n    const [x0] = points[i - 1]!;\n    const [x1, y1] = points[i]!;\n    path += ` H${round((x0 + x1) / 2)} V${round(y1)} H${round(x1)}`;\n  }\n  return path;\n}\n\n/** The path through the points in the given curve. */\nexport function curvePath(\n  points: readonly ChartPoint[],\n  curve: ChartCurve,\n): string {\n  if (curve === \"linear\") return linearPath(points);\n  if (curve === \"step\") return stepPath(points);\n  if (curve === \"monotone\") return monotonePath(points);\n  return naturalPath(points);\n}\n\n/**\n * The height of the curve its path draws at `count` evenly spaced places from\n * its first point to its last, in the same curve (`smooth` by default). Curves\n * of any length put into the same number of places can be mixed, which is how\n * a chart glides to a series of another length. Needs two points or more.\n * Points that share an x are joined by a straight step, as the path joins\n * them, so every height is a number.\n */\nexport function sampleCurve(\n  points: readonly ChartPoint[],\n  count: number,\n  curve: ChartCurve = \"smooth\",\n): number[] {\n  const last = points.length - 1;\n  const tangents =\n    (curve === \"smooth\" || curve === \"monotone\") && last >= 2\n      ? tangentsOf(points, curve)\n      : [];\n  const start = points[0]![0];\n  const span = points[last]![0] - start;\n  let segment = 0;\n  return Array.from({ length: count }, (_, k) => {\n    const x = count > 1 ? start + (span * k) / (count - 1) : start;\n    while (segment < last - 1 && x > points[segment + 1]![0]) segment++;\n    const [x0, y0] = points[segment]!;\n    const [x1, y1] = points[segment + 1]!;\n    const t = x1 === x0 ? 0 : (x - x0) / (x1 - x0);\n    // A step holds the height of its point until the middle, then the next one.\n    if (curve === \"step\") return t < 0.5 ? y0 : y1;\n    if (curve === \"linear\" || last < 2) return y0 + (y1 - y0) * t;\n    // The control points sit a third of the way apart, so x moves evenly with t.\n    const [out, into] = segmentControls(points, tangents, segment);\n    const u = 1 - t;\n    return (\n      u ** 3 * y0 +\n      3 * u ** 2 * t * out[1] +\n      3 * u * t ** 2 * into[1] +\n      t ** 3 * y1\n    );\n  });\n}\n\n/**\n * The height of the curves a series is drawn in, at any x, for a mark that rides\n * along them: each run of points is sampled once, at about one place for every\n * px, in the curve it is drawn in, and the height between two places is read by\n * interpolation. Null for an x that no run reaches, such as the gap between two\n * runs, where the series is not drawn.\n */\nexport function curveTrace(\n  runs: readonly (readonly ChartPoint[])[],\n  curve: ChartCurve,\n): (x: number) => number | null {\n  const traces = runs\n    .filter((run) => run.length > 1)\n    .map((run) => {\n      const start = run[0]![0];\n      const end = run[run.length - 1]![0];\n      const count = Math.min(1200, Math.max(2, Math.ceil(end - start) + 1));\n      return { end, heights: sampleCurve(run, count, curve), start };\n    });\n  return (x) => {\n    const run = traces.find(({ end, start }) => x >= start && x <= end);\n    if (!run) return null;\n    // A run whose points all share one x has no length to move along.\n    const span = run.end - run.start;\n    const along =\n      span > 0 ? ((x - run.start) / span) * (run.heights.length - 1) : 0;\n    const index = Math.min(run.heights.length - 2, Math.floor(along));\n    const t = along - index;\n    return (\n      run.heights[index]! + (run.heights[index + 1]! - run.heights[index]!) * t\n    );\n  };\n}\n\n/**\n * The stops of the gradient that fills an area, from the top of its box to the\n * bottom: strongest at the edge away from 0 and gone at the 0 line, so an area\n * below 0 fades towards 0 as an area above 0 does, and one that crosses 0 fades\n * into it from both sides. `heights` are the heights of the area's edges in the\n * plot and `zero` the height of the 0 line; an area above 0 keeps the plain\n * fade from its top to its bottom.\n */\nexport function areaGradientStops(\n  heights: readonly number[],\n  zero: number,\n): { offset: number; opacity: number }[] {\n  const top = Math.min(...heights);\n  const bottom = Math.max(...heights);\n  const at = bottom > top ? (zero - top) / (bottom - top) : 1;\n  if (at >= 1)\n    return [\n      { offset: 0, opacity: 0.2 },\n      { offset: 1, opacity: 0 },\n    ];\n  if (at <= 0)\n    return [\n      { offset: 0, opacity: 0 },\n      { offset: 1, opacity: 0.2 },\n    ];\n  const offset = Math.round(at * 1000) / 1000;\n  return [\n    { offset: 0, opacity: 0.2 },\n    { offset, opacity: 0 },\n    { offset: 1, opacity: 0.2 },\n  ];\n}\n\n/**\n * The places a morphing curve is sampled at. Dense enough that the curve\n * through them is the real one to a fraction of a pixel, so the chart shows no\n * step when a morph starts or ends.\n */\nexport const morphSamples = 48;\n\n/**\n * The places a wide chart is sampled at: one for about every 4 px, never fewer\n * than `morphSamples`, so a long series keeps its shape through the morph.\n */\nexport function morphSampleCount(width: number): number {\n  return Math.min(240, Math.max(morphSamples, Math.round(width / 4)));\n}\n\n// How far the wanted count may move, in samples (48 px of plot), before a held\n// count gives way.\nconst sampleSlack = 12;\n\n/**\n * The count a chart keeps sampling at: the one it holds while the wanted count\n * stays within a few samples of it. A label of the value axis in another width\n * moves the edge of the plot by a few px, and a count that followed it would\n * leave the new curves no old ones of the same length to glide from: they would\n * snap in. A plot that really changes size, as on a resize, takes the wanted\n * count.\n */\nexport function heldSampleCount(held: number, wanted: number): number {\n  return Math.abs(wanted - held) <= sampleSlack ? held : wanted;\n}\n\n/** The points of a sampled curve, spread over the width of the chart (300 by default). */\nexport const morphPoints = (\n  heights: readonly number[],\n  width = 300,\n): ChartPoint[] =>\n  heights.map((y, k): ChartPoint => [\n    (width * k) / Math.max(1, heights.length - 1),\n    y,\n  ]);\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-curve.ts"},{"path":"packages/ui/src/components/chart/use-chart-width.ts","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * The width a chart lays out at where nothing can be measured: in an\n * environment without `ResizeObserver`, such as a test DOM without layout.\n */\nexport const CHART_FALLBACK_WIDTH = 640;\n\n/**\n * The width to lay out at after a measurement: the measured width in whole\n * pixels, or the one before it when the element has none to give (hidden, or\n * not laid out yet), which is null while the chart has never been measured.\n */\nexport function nextChartWidth(\n  previous: number | null,\n  measured: number,\n): number | null {\n  return Number.isFinite(measured) && measured > 0\n    ? Math.round(measured)\n    : previous;\n}\n\n// The width inside the padding, which is the width the chart draws in.\nfunction contentWidth(element: HTMLElement): number {\n  const style = getComputedStyle(element);\n  return (\n    element.clientWidth -\n    (Number.parseFloat(style.paddingLeft) || 0) -\n    (Number.parseFloat(style.paddingRight) || 0)\n  );\n}\n\n// The height inside the padding, which is the height a chart that fills its\n// box draws in.\nfunction contentHeight(element: HTMLElement): number {\n  const style = getComputedStyle(element);\n  return (\n    element.clientHeight -\n    (Number.parseFloat(style.paddingTop) || 0) -\n    (Number.parseFloat(style.paddingBottom) || 0)\n  );\n}\n\n/**\n * The content width of the element, so that a chart draws at the width it has,\n * or null until it has been measured. A chart draws its plot only once it has\n * a width, as a chart that sizes to its container must: on the server and in\n * the first client render there is none, so the server markup holds the box of\n * the plot and nothing laid out for another width, and the hydrated markup\n * agrees with it. The width is measured as soon as the element is laid out,\n * before the browser paints, and again whenever the element is resized. An\n * element that is hidden keeps the width it had, and one that has never had a\n * width is measured when it is shown. Where nothing can be measured, without\n * `ResizeObserver`, the chart lays out at `CHART_FALLBACK_WIDTH`.\n *\n * A resize redraws the chart as a transition: while its container keeps\n * changing width, such as beside a sidebar that slides in or out, React may\n * skip the widths in between instead of drawing every frame, and the chart\n * ends at the final width. The first width is drawn at once.\n */\nexport function useChartWidth(\n  ref: React.RefObject<HTMLElement | null>,\n): number | null {\n  const [width, setWidth] = React.useState<number | null>(null);\n\n  React.useLayoutEffect(() => {\n    const element = ref.current;\n    if (!element) return;\n    let measured = false;\n    const measure = (value: number) => {\n      if (Number.isFinite(value) && value > 0) measured = true;\n      setWidth((previous) => nextChartWidth(previous, value));\n    };\n    const first = contentWidth(element);\n    if (typeof ResizeObserver === \"undefined\") {\n      // Nothing measures it again: the first width, else the fallback.\n      setWidth(\n        (previous) => nextChartWidth(previous, first) ?? CHART_FALLBACK_WIDTH,\n      );\n      return;\n    }\n    measure(first);\n    const observer = new ResizeObserver((entries) => {\n      const entry = entries[entries.length - 1];\n      if (!entry) return;\n      const value = entry.contentRect.width;\n      if (measured) React.startTransition(() => measure(value));\n      else measure(value);\n    });\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, [ref]);\n\n  return width;\n}\n\n/**\n * The content height of the element while `enabled`, for a chart that fills\n * the height of its box, or null until it has been measured and while it is\n * not enabled. It is measured as `useChartWidth` measures the width: once the\n * element is laid out, before the browser paints, and again whenever it is\n * resized, a resize as a transition. The element must take its height from its\n * container and not from what the chart draws in it, or the two would chase\n * each other: the chart's box is size-contained while it fills.\n */\nexport function useChartHeight(\n  ref: React.RefObject<HTMLElement | null>,\n  enabled: boolean,\n): number | null {\n  const [height, setHeight] = React.useState<number | null>(null);\n\n  React.useLayoutEffect(() => {\n    const element = ref.current;\n    if (!enabled || !element) return;\n    let measured = false;\n    const measure = (value: number) => {\n      if (Number.isFinite(value) && value > 0) measured = true;\n      setHeight((previous) => nextChartWidth(previous, value));\n    };\n    measure(contentHeight(element));\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver((entries) => {\n      const entry = entries[entries.length - 1];\n      if (!entry) return;\n      const value = entry.contentRect.height;\n      if (measured) React.startTransition(() => measure(value));\n      else measure(value);\n    });\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, [enabled, ref]);\n\n  return enabled ? height : null;\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/use-chart-width.ts"},{"path":"packages/ui/src/components/chart/chart-interaction.ts","content":"\"use client\";\n\nimport { bisectCenter } from \"d3-array\";\nimport * as React from \"react\";\n\n/**\n * The key `step` places from `current` (1 forward, -1 back). A chart starts at\n * the first key when nothing is active, or the active key is no longer there.\n * `wrap` cycles through the ends, as the PieChart does; otherwise the ends hold.\n */\nexport function stepKey(\n  keys: readonly string[],\n  current: string | null,\n  step: 1 | -1,\n  wrap: boolean,\n): string | null {\n  if (keys.length === 0) return null;\n  const index = current === null ? -1 : keys.indexOf(current);\n  if (index < 0) return keys[0] ?? null;\n  const next = index + step;\n  return wrap\n    ? (keys[(next + keys.length) % keys.length] ?? null)\n    : (keys[Math.min(keys.length - 1, Math.max(0, next))] ?? null);\n}\n\n/** The first or the last key. */\nexport function edgeKey(\n  keys: readonly string[],\n  edge: \"start\" | \"end\",\n): string | null {\n  return (edge === \"start\" ? keys[0] : keys[keys.length - 1]) ?? null;\n}\n\n/** `current` while it is still in `keys`, otherwise null: the tooltip closes. */\nexport function retainKey(\n  keys: readonly string[],\n  current: string | null,\n): string | null {\n  return current !== null && keys.includes(current) ? current : null;\n}\n\n/** The index of the position nearest to `value`, for positions in ascending order. -1 for none. */\nexport function nearestIndex(\n  positions: readonly number[],\n  value: number,\n): number {\n  if (positions.length === 0 || Number.isNaN(value)) return -1;\n  return bisectCenter(positions, value);\n}\n\nfunction isKeyboardFocus(element: HTMLElement): boolean {\n  try {\n    return element.matches(\":focus-visible\");\n  } catch {\n    return true;\n  }\n}\n\nexport type ChartInteractionOptions = {\n  /** The items in keyboard order. */\n  keys: readonly string[];\n  /** The item under a point, in client coordinates: one lookup for the whole plot. */\n  itemAt: (clientX: number, clientY: number) => string | null;\n  /** Whether the arrow keys cycle through the ends (a ring) or stop there (an axis). */\n  wrap?: boolean;\n  /** `x` steps with ArrowLeft and ArrowRight, `y` with ArrowUp and ArrowDown. */\n  axis?: \"x\" | \"y\";\n};\n\nexport type ChartInteraction = {\n  /** The active item, named by its key, not its place, so it stays put when the data around it changes. */\n  activeKey: string | null;\n  /**\n   * Whether the active item was chosen on purpose, by the keyboard or a tap,\n   * and not by a mouse passing over the plot: the chart reads it out to\n   * assistive technology.\n   */\n  announce: boolean;\n  /** A tap or pen press pins the item until the next press elsewhere or Escape. */\n  pinned: boolean;\n  clear: () => void;\n  /**\n   * The handlers of the one focus stop that holds the plot. A chart with no\n   * items has nothing to step through, and is no focus stop.\n   */\n  triggerProps: {\n    tabIndex?: 0;\n    onFocus: React.FocusEventHandler<HTMLElement>;\n    onBlur: React.FocusEventHandler<HTMLElement>;\n    onPointerMove: React.PointerEventHandler<HTMLElement>;\n    onPointerLeave: React.PointerEventHandler<HTMLElement>;\n    onPointerDown: React.PointerEventHandler<HTMLElement>;\n    onKeyDown: React.KeyboardEventHandler<HTMLElement>;\n  };\n};\n\n/**\n * The state and the handlers that make a plot interactive: the active item\n * under a mouse, a tap that pins, Escape, and the arrow keys, Home and End on\n * one focus stop. The chart looks the item up (`itemAt`) and draws its own\n * tooltip content; this hook owns the rest, for every chart. The tooltip sits\n * beside the active item, whichever way it became active.\n */\nexport function useChartInteraction({\n  keys,\n  itemAt,\n  wrap = false,\n  axis = \"x\",\n}: ChartInteractionOptions): ChartInteraction {\n  // The active item is named by its key, not its place, so the tooltip stays on\n  // the same item when the data around it changes.\n  const [activeKey, setActiveKey] = React.useState<string | null>(null);\n  // A tap or pen press keeps its tooltip open until the next press elsewhere or\n  // Escape. Hovering with a mouse still closes it when the pointer leaves.\n  const [pinned, setPinned] = React.useState(false);\n  // The keyboard and a tap choose an item on purpose, and the chart reads it\n  // out; a mouse that passes over the plot does not.\n  const [announce, setAnnounce] = React.useState(false);\n  const clear = () => {\n    setActiveKey(null);\n    setPinned(false);\n    setAnnounce(false);\n  };\n  // Only an item of the chart can be active: a point lookup that finds a row\n  // with nothing to show, such as in a chart with no numbers, finds none.\n  const own = (key: string | null) => retainKey(keys, key);\n\n  // An item whose key is gone from the data closes its tooltip.\n  if (activeKey !== null && retainKey(keys, activeKey) === null) {\n    setActiveKey(null);\n    setPinned(false);\n    setAnnounce(false);\n  }\n\n  const back = axis === \"x\" ? \"ArrowLeft\" : \"ArrowUp\";\n  const forward = axis === \"x\" ? \"ArrowRight\" : \"ArrowDown\";\n  return {\n    activeKey,\n    announce,\n    pinned,\n    clear,\n    triggerProps: {\n      ...(keys.length > 0 ? { tabIndex: 0 as const } : {}),\n      onFocus: (event) => {\n        // Keyboard focus starts at the first item. A press selects the item it\n        // hits instead, so focus must not overwrite it.\n        if (isKeyboardFocus(event.currentTarget)) {\n          setActiveKey(edgeKey(keys, \"start\"));\n          setAnnounce(true);\n        }\n      },\n      onBlur: clear,\n      onPointerMove: (event) => {\n        if (event.pointerType !== \"mouse\" || pinned) return;\n        setActiveKey(own(itemAt(event.clientX, event.clientY)));\n        setAnnounce(false);\n      },\n      onPointerLeave: (event) => {\n        if (event.pointerType === \"mouse\" && !pinned) {\n          setActiveKey(null);\n          setAnnounce(false);\n        }\n      },\n      onPointerDown: (event) => {\n        // A tap or pen press pins the item it hits.\n        if (event.pointerType === \"mouse\") return;\n        const key = own(itemAt(event.clientX, event.clientY));\n        setActiveKey(key);\n        setPinned(key !== null);\n        setAnnounce(key !== null);\n      },\n      onKeyDown: (event) => {\n        if (event.key === forward || event.key === back) {\n          event.preventDefault();\n          const step = event.key === forward ? 1 : -1;\n          setActiveKey((old) => stepKey(keys, old, step, wrap));\n          setAnnounce(true);\n        }\n        if (event.key === \"Home\" || event.key === \"End\") {\n          event.preventDefault();\n          setActiveKey(edgeKey(keys, event.key === \"Home\" ? \"start\" : \"end\"));\n          setAnnounce(true);\n        }\n        if (event.key === \"Escape\") clear();\n      },\n    },\n  };\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-interaction.ts"},{"path":"packages/ui/src/components/chart/chart-legend.tsx","content":"\"use client\";\n\n/*\n * The legend of a chart and the focused series. The hover is adapted from bklit\n * UI (chart-legend-hover.tsx, legend/legend.tsx, legend/legend-item.tsx and\n * chart-legend.tsx, MIT): a mouse over an entry makes its series the focused\n * one, leaving it clears that, and the other entries step back. Everything else\n * is Siteplane's own.\n *\n * MIT License\n *\n * Copyright (c) 2026 uixmat\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport * as React from \"react\";\n\nimport { cn } from \"../../system/utils/cn\";\nimport { MotionItem, MotionList } from \"../motion\";\nimport {\n  chartLegendDimClassName,\n  seriesSteppedBack,\n  useChartDimClassName,\n} from \"./chart-dimming\";\nimport { retainKey } from \"./chart-interaction\";\n\n/** The 8 px dot that names a series, in the legend and in the tooltip. */\nexport function ChartSwatch({ color }: { color: string }): React.ReactElement {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"size-2 shrink-0 rounded-full\"\n      // The colour is a series colour, a token or what the consumer supplies as\n      // data, so it can only be set as a value.\n      style={{ background: color }}\n    />\n  );\n}\n\n/**\n * The dot before the name of a series, in a legend entry or a tooltip row: in\n * a box one line of the name tall, so that it centres on the name's first line\n * however many lines follow (rulebook I5). The row it starts aligns its items\n * at their top.\n */\nexport function ChartLabelSwatch({\n  color,\n}: {\n  color: string;\n}): React.ReactElement {\n  return (\n    <span className=\"flex h-lh shrink-0 items-center\">\n      <ChartSwatch color={color} />\n    </span>\n  );\n}\n\nexport type ChartLegendItem = {\n  key: string;\n  label: string;\n  color: string;\n  /** Read out to assistive technology after the label, never shown. */\n  value?: string;\n};\n\n/**\n * The series that has the mouse in the legend. A visual aid and nothing more:\n * it moves no focus, opens no tooltip and changes no name that a screen reader\n * reads.\n */\nexport type ChartSeriesFocus = {\n  /** The key of the series whose entry the mouse is over; null while none is. */\n  focusedKey: string | null;\n  setFocusedKey: (key: string | null) => void;\n};\n\n/**\n * The state of the focused series, for a chart that has a legend to own it: the\n * chart passes it to its legend, which sets it, and reads it for its marks. A\n * series that leaves the data is no longer the focused one: the entry that had\n * the mouse is gone, and no pointer leave will come to clear it.\n */\nexport function useChartSeriesFocus(keys: readonly string[]): ChartSeriesFocus {\n  const [focusedKey, setFocusedKey] = React.useState<string | null>(null);\n  const retained = retainKey(keys, focusedKey);\n  if (focusedKey !== null && retained === null) setFocusedKey(null);\n  return { focusedKey: retained, setFocusedKey };\n}\n\n/**\n * What makes an entry of the legend the focused series: a mouse over it, and\n * nothing else. A touch or a pen has no hover to show, so\n * neither sets or clears the focus; an entry is no focus stop, so the keyboard\n * does not either.\n */\nexport function legendEntryHandlers(\n  key: string,\n  setFocusedKey: (key: string | null) => void,\n): {\n  onPointerEnter: React.PointerEventHandler<HTMLElement>;\n  onPointerLeave: React.PointerEventHandler<HTMLElement>;\n} {\n  return {\n    onPointerEnter: (event) => {\n      if (event.pointerType === \"mouse\") setFocusedKey(key);\n    },\n    onPointerLeave: (event) => {\n      if (event.pointerType === \"mouse\") setFocusedKey(null);\n    },\n  };\n}\n\n/**\n * The series of a chart, each with its dot. Items go through the motion list,\n * so a series that comes or goes animates and the others glide. Given the\n * chart's `focus`, a mouse over an entry makes its series the focused one and\n * the other entries step back, as the marks of the chart do, unless `fade` is\n * off. The legend never grows wider than the room it is given: a long label\n * wraps, and a word too long for its line breaks inside it (rulebook K5),\n * while the dot stays on the label's first line (I5).\n */\nexport function ChartLegend({\n  items,\n  layout = \"list\",\n  focus,\n  fade = true,\n  className,\n}: {\n  items: readonly ChartLegendItem[];\n  /** `list` stacks the items in a column; `row` lays them out in a wrapping row, below a plot. */\n  layout?: \"list\" | \"row\";\n  /** The chart's focused series: the legend sets it under a mouse and fades the entries that are not it. */\n  focus?: ChartSeriesFocus;\n  /**\n   * Whether the entries that are not the focused series fade. A Pie Chart's do\n   * not: its colours are shades of one hue, and a faded entry would take the\n   * colour of another one.\n   */\n  fade?: boolean;\n  className?: string;\n}): React.ReactElement {\n  // A column opens and closes the space between its items; a row sets it with gap.\n  const place =\n    layout === \"row\" ? ({ layout: \"flow\" } as const) : ({ gap: 1 } as const);\n  const dimClassName = useChartDimClassName(\"mark\");\n  // A div and not the figure's caption: the caption would name the chart's\n  // figure after its series, and the chart is named by its label alone.\n  return (\n    <div\n      className={cn(\"sp-chart-legend\", \"min-w-0 max-w-full\", className)}\n      data-slot=\"chart-legend\"\n    >\n      <MotionList\n        className={cn(\n          \"text-sm text-muted-foreground\",\n          layout === \"row\" && \"flex flex-wrap justify-center gap-x-4 gap-y-1\",\n        )}\n        {...place}\n      >\n        {items.map((item) => (\n          <MotionItem key={item.key}>\n            <div\n              className={cn(\n                \"flex items-start gap-2\",\n                focus && fade && dimClassName,\n                focus &&\n                  fade &&\n                  seriesSteppedBack(focus.focusedKey, item.key) &&\n                  chartLegendDimClassName,\n              )}\n              data-focused={focus?.focusedKey === item.key ? \"\" : undefined}\n              {...(focus\n                ? legendEntryHandlers(item.key, focus.setFocusedKey)\n                : undefined)}\n            >\n              <ChartLabelSwatch color={item.color} />\n              <span className=\"min-w-0 wrap-anywhere\">\n                {item.label}\n                {item.value !== undefined && (\n                  <span className=\"sr-only\">: {item.value}</span>\n                )}\n              </span>\n            </div>\n          </MotionItem>\n        ))}\n      </MotionList>\n    </div>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-legend.tsx"},{"path":"packages/ui/src/components/chart/chart-tooltip.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\nimport { getMotionTransitionFromCss } from \"../../system/motion/css-transition\";\nimport { useSiteplaneMessages } from \"../../system/locale/messages-context\";\nimport { useReducedMotionPreference } from \"../../system/motion/use-reduced-motion-preference\";\nimport { focusRingRecipe } from \"../../system/recipes/focus-ring.recipe\";\nimport { cn } from \"../../system/utils/cn\";\nimport {\n  FOLLOW_TRAIL_DURATION,\n  MotionNumber,\n  useFollowFrames,\n  useFollowPoint,\n} from \"../motion\";\nimport { Tooltip, TooltipPopup, TooltipTrigger } from \"../tooltip\";\nimport { useLastValue } from \"./chart-cartesian\";\nimport type { ChartInteraction } from \"./chart-interaction\";\nimport { ChartLabelSwatch } from \"./chart-legend\";\nimport {\n  dipsAtFlip,\n  placeChartTooltip,\n  type ChartBounds,\n  type ChartCardSize,\n  type ChartTooltipAlign,\n} from \"./chart-tooltip-place\";\n\nexport type ChartTooltipProps = {\n  interaction: ChartInteraction;\n  /** The name of the focus stop that holds the plot. */\n  label: string;\n  /**\n   * The client point of an item, which the card sits beside: where the item's\n   * crosshair is, and the top of the plot or the middle of the item, whichever\n   * `align` asks for. Null for an item with no place.\n   */\n  anchorFor: (key: string) => { x: number; y: number } | null;\n  /** `start` puts the top of the card at the point (a crosshair that runs from the top of the plot down), `center` its middle. */\n  align?: ChartTooltipAlign;\n  /** What the card stays inside: the chart, or the window, for a ring that has no room beside it. */\n  boundary?: \"chart\" | \"viewport\";\n  /** What the card shows for the active item. Nothing closes it. */\n  content: React.ReactNode;\n  /**\n   * What the card says, in words: read out to assistive technology when the\n   * keyboard or a tap makes an item active, through a polite live region. A\n   * mouse that passes over the plot is not read out.\n   */\n  announcement?: string | undefined;\n  /**\n   * The chart's data in a sentence or two, such as a summary of its values or\n   * its empty label: the description of the plot, read when it takes focus.\n   */\n  description?: string | undefined;\n  /**\n   * The id of an element that describes the plot as well, such as the one a\n   * consumer names with `aria-describedby`. It is read after `description`.\n   */\n  describedBy?: string | undefined;\n  /** What screen readers call the plot: its role description. \"chart\" by default; an empty text leaves it out. */\n  roleDescription?: string | undefined;\n  /**\n   * How to move between the items, read after the descriptions while the plot\n   * is a focus stop. \"Use the arrow keys to move between data points.\" by\n   * default; an empty text leaves it out.\n   */\n  keyboardHint?: string | undefined;\n  /** The plot. */\n  children: React.ReactNode;\n  className?: string;\n  style?: React.CSSProperties;\n};\n\n// The ids of what describes the plot, in the order a screen reader reads them.\nfunction describedByIds(ids: readonly (string | false | undefined)[]) {\n  return ids.filter(Boolean).join(\" \") || undefined;\n}\n\n// The polite live region that reads what the card shows. It is present from\n// the start, empty, so that a screen reader hears it change.\nfunction ChartAnnouncer({ children }: { children?: React.ReactNode }) {\n  return (\n    <span\n      aria-atomic=\"true\"\n      aria-live=\"polite\"\n      className=\"sp-chart-announcer sr-only\"\n      data-slot=\"chart-announcer\"\n    >\n      {children}\n    </span>\n  );\n}\n\n/**\n * What a chart that sizes to its container renders until it has measured its\n * width: the box of its plot at its full height, with nothing drawn in it, so\n * that the page does not move when the plot is drawn and the server markup\n * never holds a plot laid out for another width. Screen readers meet it as the\n * chart, named and described as the drawn chart is, and its live region is\n * there from the start. It is no focus stop yet: there is nothing to step\n * through until the plot is drawn.\n */\nexport function ChartPlotPlaceholder({\n  className,\n  description,\n  describedBy,\n  height,\n  label,\n  roleDescription: roleDescriptionProp,\n}: Pick<\n  ChartTooltipProps,\n  \"className\" | \"description\" | \"describedBy\" | \"label\" | \"roleDescription\"\n> & {\n  /** The height of the plot in px: the height of its SVG once it is drawn. */\n  height: number;\n}): React.ReactElement {\n  const messages = useSiteplaneMessages(\"chart\");\n  const roleDescription = roleDescriptionProp ?? messages.roleDescription;\n  const descriptionId = React.useId();\n  return (\n    <>\n      <div\n        aria-describedby={describedByIds([\n          description && descriptionId,\n          describedBy,\n        ])}\n        aria-label={label}\n        aria-roledescription={roleDescription || undefined}\n        className={cn(\"sp-chart-placeholder\", className)}\n        data-slot=\"chart-placeholder\"\n        role=\"group\"\n      >\n        <svg aria-hidden=\"true\" className=\"block w-full\" height={height} />\n      </div>\n      {description ? (\n        <span\n          className=\"sp-chart-description sr-only\"\n          data-slot=\"chart-description\"\n          id={descriptionId}\n        >\n          {description}\n        </span>\n      ) : null}\n      <ChartAnnouncer />\n    </>\n  );\n}\n\n// What a card guesses its size to be until it is measured.\nconst guessedCard: ChartCardSize = { height: 80, width: 180 };\n// How long the card stays stepped back while it changes sides: a third of the\n// time the floating panel takes to fade, which is long enough for its opacity\n// transition to take it most of the way out of sight and no longer, so that it\n// is back in plain sight within about 200 ms. A time, and not a\n// count of frames, so that it looks the same on every display.\nconst dipDuration = \"--motion-duration-moderate\";\nconst dipShare = 1 / 3;\n// The room a card keeps from the edge of the window.\nconst viewportPadding = 8;\n\nfunction boundsOf(box: DOMRect, boundary: \"chart\" | \"viewport\"): ChartBounds {\n  if (boundary === \"chart\")\n    return { bottom: box.height, left: 0, right: box.width, top: 0 };\n  return {\n    bottom: window.innerHeight - box.y - viewportPadding,\n    left: viewportPadding - box.x,\n    right: window.innerWidth - box.x - viewportPadding,\n    top: viewportPadding - box.y,\n  };\n}\n\n/**\n * The focus stop of a chart and its tooltip: the public Tooltip, whose card sits\n * beside the active item, to its right or on its left near the edge, and glides\n * after the item with the follow motion, whatever moved the item: a mouse, a\n * tap or the keyboard. The plot goes inside; the interaction decides which item\n * is active.\n *\n * For assistive technology the focus stop is the chart: a group that announces\n * itself as a chart, named by the chart's label and described by its summary,\n * then by a hint that the arrow keys move between the items. The plot inside\n * is a picture that the chart hides from it. While the keyboard or a tap moves\n * between the items, a polite live region reads what the card shows.\n */\nexport function ChartTooltip({\n  interaction,\n  label,\n  anchorFor,\n  align = \"start\",\n  boundary = \"chart\",\n  content,\n  announcement,\n  description,\n  describedBy,\n  roleDescription: roleDescriptionProp,\n  keyboardHint: keyboardHintProp,\n  children,\n  className,\n  style,\n}: ChartTooltipProps): React.ReactElement {\n  const messages = useSiteplaneMessages(\"chart\");\n  const roleDescription = roleDescriptionProp ?? messages.roleDescription;\n  const keyboardHint = keyboardHintProp ?? messages.keyboardHint;\n  const tooltipId = React.useId();\n  const descriptionId = React.useId();\n  const hintId = React.useId();\n  const reduced = useReducedMotionPreference();\n  const trigger = React.useRef<HTMLElement | null>(null);\n  // The render reads the trigger's box, so it holds the trigger as state as\n  // well: a trigger that mounts renders the tooltip again. The effects and the\n  // card read the ref.\n  const [triggerElement, setTriggerElement] =\n    React.useState<HTMLElement | null>(null);\n  // The trigger is a div; the Trigger part types its ref for a button.\n  const setTrigger = React.useCallback((node: HTMLElement | null) => {\n    trigger.current = node;\n    setTriggerElement(node);\n  }, []);\n  const { activeKey, announce, clear, triggerProps } = interaction;\n  const open = activeKey !== null && Boolean(content);\n  // The hint belongs to the focus stop: a chart with no items is none, and\n  // nothing moves between items there.\n  const hint = triggerProps.tabIndex === 0 && keyboardHint ? keyboardHint : \"\";\n\n  // The size of the card as it is measured while it is there: it flips by its\n  // width and is centred by its height. Until the first measure it is a guess,\n  // and the card does not glide to its measured place.\n  const [size, setSize] = React.useState<ChartCardSize | null>(null);\n  const onMeasure = React.useCallback(\n    (next: ChartCardSize) =>\n      setSize((old) =>\n        old &&\n        Math.abs(next.width - old.width) < 1 &&\n        Math.abs(next.height - old.height) < 1\n          ? old\n          : next,\n      ),\n    [],\n  );\n\n  // Where the item is and what the card stays inside, in the coordinates of the\n  // chart itself, so that a page that scrolls takes the card along with it; and\n  // from them where the card sits beside the item.\n  const box = open ? triggerElement?.getBoundingClientRect() : undefined;\n  const point = open && activeKey !== null ? anchorFor(activeKey) : null;\n  const place =\n    box && point\n      ? placeChartTooltip({\n          align,\n          anchor: { x: point.x - box.x, y: point.y - box.y },\n          bounds: boundsOf(box, boundary),\n          card: size ?? guessedCard,\n        })\n      : null;\n  // The card leaves where it was while it fades out.\n  const left = useLastValue(place ? place.left : null) ?? 0;\n  const top = useLastValue(place ? place.top : null) ?? 0;\n  const flipped = useLastValue(place ? place.flipped : null) ?? false;\n\n  // A card that changes sides dips in opacity while it crosses its item, so that\n  // it is not in plain sight there.\n  // The tooltip stays open (M4): the card is told to step back, and the kit's own\n  // opacity transition on the floating panel takes it out and back.\n  const [dipping, setDipping] = React.useState(false);\n  const wasFlipped = React.useRef(flipped);\n  const timer = React.useRef<number | undefined>(undefined);\n  React.useLayoutEffect(() => {\n    const dip = dipsAtFlip({\n      flipped,\n      measured: size !== null,\n      open,\n      reduced,\n      wasFlipped: wasFlipped.current,\n    });\n    wasFlipped.current = flipped;\n    if (!dip) return;\n    window.clearTimeout(timer.current);\n    setDipping(true);\n    const { duration } = getMotionTransitionFromCss(\n      trigger.current ?? document.documentElement,\n      dipDuration,\n      \"--motion-ease-standard\",\n    );\n    timer.current = window.setTimeout(\n      () => setDipping(false),\n      duration * dipShare * 1000,\n    );\n  }, [flipped, open, reduced, size]);\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  return (\n    <Tooltip\n      open={open}\n      onOpenChange={(next) => {\n        if (!next) clear();\n      }}\n      // The tooltip is information about the item under the pointer; it never\n      // takes the pointer itself, so it cannot close itself.\n      disableHoverablePopup\n    >\n      <TooltipTrigger\n        render={<div />}\n        ref={setTrigger}\n        closeOnClick={false}\n        role=\"group\"\n        aria-roledescription={roleDescription || undefined}\n        aria-describedby={describedByIds([\n          description && descriptionId,\n          describedBy,\n          hint && hintId,\n        ])}\n        aria-label={label}\n        className={cn(className, focusRingRecipe())}\n        style={style}\n        {...triggerProps}\n      >\n        {children}\n        {/* Inside the focus stop it describes, so that the parts around the\n            chart keep their place whether or not it is one. */}\n        {hint ? (\n          <span\n            className=\"sp-chart-hint sr-only\"\n            data-slot=\"chart-hint\"\n            id={hintId}\n          >\n            {hint}\n          </span>\n        ) : null}\n      </TooltipTrigger>\n      {description ? (\n        <span\n          className=\"sp-chart-description sr-only\"\n          data-slot=\"chart-description\"\n          id={descriptionId}\n        >\n          {description}\n        </span>\n      ) : null}\n      <ChartAnnouncer>\n        {open && announce && announcement ? announcement : \"\"}\n      </ChartAnnouncer>\n      <ChartTooltipCard\n        content={content}\n        dipping={dipping}\n        id={tooltipId}\n        left={left}\n        measured={size !== null}\n        onMeasure={onMeasure}\n        open={open}\n        size={size ?? guessedCard}\n        top={top}\n        trigger={trigger}\n      />\n    </Tooltip>\n  );\n}\n\n/**\n * The card of the tooltip. It trails its item: where it sits is a point that\n * follows the place of the card beside the item (the follow motion, with its\n * trail duration), handed to the tooltip's positioner as a virtual anchor at\n * every frame it moves, so the public TooltipPopup stays the owner of the\n * surface, the portal and the collisions with the window. Only this part\n * renders at every frame; the chart and the content do not.\n */\nfunction ChartTooltipCard({\n  content,\n  dipping,\n  id,\n  left,\n  measured,\n  onMeasure,\n  open,\n  size,\n  top,\n  trigger,\n}: {\n  content: React.ReactNode;\n  /** The card is crossing its item, from one side of it to the other: it steps back. */\n  dipping: boolean;\n  id: string;\n  /** Where the card heads for: its left and its top, in the coordinates of the chart. */\n  left: number;\n  /** Whether `size` is the size of the card as measured, and not a guess. */\n  measured: boolean;\n  onMeasure: (size: ChartCardSize) => void;\n  open: boolean;\n  size: ChartCardSize;\n  top: number;\n  trigger: React.RefObject<HTMLElement | null>;\n}): React.ReactElement {\n  const [popup, setPopup] = React.useState<HTMLDivElement | null>(null);\n  // A number that rolls to more digits makes the card wider while it rolls, so\n  // the card is watched for as long as it is there. It is measured when it\n  // mounts, before the browser paints. The observer only notes that its size\n  // changed: the card is measured again, and its place updated, in the next\n  // animation frame, outside the observer's callback, so that no state\n  // changes while the browser delivers its observations.\n  React.useLayoutEffect(() => {\n    if (!popup) return;\n    const measure = () =>\n      onMeasure({ height: popup.offsetHeight, width: popup.offsetWidth });\n    measure();\n    if (typeof ResizeObserver === \"undefined\") return;\n    let frame = 0;\n    const observer = new ResizeObserver(() => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(measure);\n    });\n    observer.observe(popup);\n    return () => {\n      observer.disconnect();\n      cancelAnimationFrame(frame);\n    };\n  }, [onMeasure, popup]);\n\n  const follow = useFollowPoint(\n    { x: left, y: top },\n    {\n      active: open && measured,\n      container: trigger,\n      duration: FOLLOW_TRAIL_DURATION,\n    },\n  );\n  useFollowFrames([follow.x, follow.y]);\n\n  // The anchor is the line the card hangs from: at its left edge, as tall as\n  // the card, so the positioner puts the card's top on the top of the line. A\n  // point without a height would have it start a little above (the arrow\n  // padding, which a card without an arrow does not need).\n  const virtual = {\n    getBoundingClientRect: () => {\n      const origin = trigger.current?.getBoundingClientRect();\n      return new DOMRect(\n        (origin?.x ?? 0) + follow.x.get(),\n        (origin?.y ?? 0) + follow.y.get(),\n        0,\n        size.height,\n      );\n    },\n  };\n  return (\n    <TooltipPopup\n      align=\"start\"\n      anchor={virtual}\n      // The card hands the positioner a new anchor at every frame it moves, so\n      // the positioner need not watch the anchor or the card for changes: it\n      // would set its observers up anew at every frame, and each new observer\n      // reports in the frame it is made, which WebKit takes for a\n      // ResizeObserver loop. A change of the card's size reaches it through\n      // the measure above.\n      disableAnchorTracking\n      // The card is never wider than 18rem, the Tooltip's own width, which it\n      // takes from the popup: a long label wraps inside it and an unbroken\n      // word breaks (rulebook K5). So the card keeps one width while its item\n      // is active and fits beside the item on one side or the other, where a\n      // card of one long line would cross the cards around the chart and\n      // change sides as it was measured. Dipping takes the card out and back\n      // over the panel's own opacity transition, the one an entrance and an\n      // exit run on.\n      className=\"data-dipping:opacity-0\"\n      id={id}\n      ref={setPopup}\n      role=\"tooltip\"\n      side=\"right\"\n      sideOffset={0}\n      {...(dipping ? { \"data-dipping\": \"\" } : {})}\n    >\n      {content}\n    </TooltipPopup>\n  );\n}\n\n// The value keeps its place at the end of its row, in the label weight.\nconst tooltipValueClassName = \"ms-auto ps-4 font-(--sp-rw-font-weight-label)\";\n\nexport type ChartTooltipRow = {\n  key: string;\n  label: string;\n  value: string;\n  color: string;\n};\n\n/**\n * What a card with a heading and rows says, in words, for the chart's live\n * region: the heading, then each row's name and value.\n */\nexport function chartTooltipText(\n  heading: string | undefined,\n  rows: readonly Pick<ChartTooltipRow, \"label\" | \"value\">[],\n): string {\n  return [heading, ...rows.map((row) => `${row.label}: ${row.value}`)]\n    .filter(Boolean)\n    .join(\". \");\n}\n\n/**\n * The card of a chart with several series: a heading, then one row per series\n * with its dot, its name and its value. The values change to the next item's\n * at once; with `rolling` they roll to them, digit by digit, which about\n * doubles the work of the page while the pointer moves across the plot (each\n * digit is a wheel of its own). A name too long for the card wraps, and its\n * dot and its value stay on its first line (rulebook I5).\n */\nexport function ChartTooltipContent({\n  heading,\n  rolling = false,\n  rows,\n}: {\n  heading?: React.ReactNode;\n  /** Rolls the values to the next item's (the chart's `rollingNumbers`). */\n  rolling?: boolean;\n  rows: readonly ChartTooltipRow[];\n}): React.ReactElement {\n  return (\n    <div\n      className=\"sp-chart-tooltip-content grid gap-1\"\n      data-slot=\"chart-tooltip-content\"\n    >\n      {heading ? (\n        <div className=\"font-(--sp-rw-font-weight-label)\">{heading}</div>\n      ) : null}\n      {rows.map((row) => (\n        <div className=\"flex items-start gap-2\" key={row.key}>\n          <ChartLabelSwatch color={row.color} />\n          <span className=\"min-w-0 text-muted-foreground\">{row.label}</span>\n          {rolling ? (\n            <MotionNumber className={tooltipValueClassName} value={row.value} />\n          ) : (\n            <span\n              className={cn(\n                \"sp-chart-tooltip-value\",\n                tooltipValueClassName,\n                \"whitespace-nowrap tabular-nums\",\n              )}\n              data-slot=\"chart-tooltip-value\"\n            >\n              {row.value}\n            </span>\n          )}\n        </div>\n      ))}\n    </div>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-tooltip.tsx"},{"path":"packages/ui/src/components/chart/chart-tooltip-place.ts","content":"/*\n * Where the tooltip card of a chart sits beside the item it belongs to: to its\n * right at a fixed distance, on the other side once the card would leave the\n * chart, and kept inside the chart vertically. The placement is adapted from\n * bklit UI's tooltip box (tooltip/tooltip-box.tsx, MIT); the card itself, the\n * motion that brings it there and the surface it is drawn on are Siteplane's.\n *\n * MIT License\n *\n * Copyright (c) 2026 uixmat\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\n/** The distance between the item and the card, in px. */\nexport const CHART_TOOLTIP_OFFSET = 16;\n\nexport type ChartPoint2D = { x: number; y: number };\nexport type ChartBounds = {\n  bottom: number;\n  left: number;\n  right: number;\n  top: number;\n};\nexport type ChartCardSize = { height: number; width: number };\n\n/** How the card sits against the vertical of its item: its top at it, or its middle. */\nexport type ChartTooltipAlign = \"center\" | \"start\";\n\nexport type ChartTooltipPlace = {\n  /** The card is on the left of its item, because the right had no room. */\n  flipped: boolean;\n  /** The left and the top of the card, in the coordinates of the anchor and the bounds. */\n  left: number;\n  top: number;\n};\n\n/**\n * Whether the card dips: it has changed sides while it is shown and its place is\n * known (a card that is still a guess is put where it belongs, not moved), and\n * motion is not reduced (M5).\n */\nexport function dipsAtFlip({\n  flipped,\n  measured,\n  open,\n  reduced,\n  wasFlipped,\n}: {\n  flipped: boolean;\n  measured: boolean;\n  open: boolean;\n  reduced: boolean;\n  wasFlipped: boolean;\n}): boolean {\n  return flipped !== wasFlipped && open && measured && !reduced;\n}\n\n/**\n * The left and the top of a card of `card` size beside an item at `anchor`,\n * which lies inside `bounds`. The card is to the right of the item, `offset`\n * away; where it would cross the right edge of the bounds it is on the left\n * instead, and where it would cross the left edge it is held to it. With\n * `start` its top is the item's (a chart whose crosshair runs top to bottom);\n * with `center` its middle is the item's, and the card is kept `offset` clear\n * of the top and the bottom of the bounds (a bar in a horizontal chart, a share\n * of a ring).\n */\nexport function placeChartTooltip({\n  align,\n  anchor,\n  bounds,\n  card,\n  offset = CHART_TOOLTIP_OFFSET,\n}: {\n  align: ChartTooltipAlign;\n  anchor: ChartPoint2D;\n  bounds: ChartBounds;\n  card: ChartCardSize;\n  offset?: number;\n}): ChartTooltipPlace {\n  const flipped = anchor.x + offset + card.width > bounds.right;\n  const left = Math.max(\n    bounds.left,\n    flipped ? anchor.x - offset - card.width : anchor.x + offset,\n  );\n  const top =\n    align === \"start\"\n      ? anchor.y\n      : Math.max(\n          bounds.top + offset,\n          Math.min(\n            anchor.y - card.height / 2,\n            bounds.bottom - card.height - offset,\n          ),\n        );\n  return { flipped, left, top };\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-tooltip-place.ts"},{"path":"packages/ui/src/components/chart/chart-axis-pill.tsx","content":"\"use client\";\n\n/*\n * The pill on the axis of a chart: the active item's label in the kit's inverse\n * Badge, sitting on the axis at the item and gliding after it, with the labels\n * of the axis that it would cover fading out until it has passed. The idea and\n * the rule for the labels (a label within reach of the pill goes) are adapted\n * from bklit UI's date ticker (tooltip/date-ticker.tsx, MIT) and the labels of\n * its x axis (x-axis.tsx). The pill itself is the Badge, its digits roll through\n * MotionNumber when the chart asks for it (`rollingNumbers`), and its glide is\n * the follow motion.\n *\n * MIT License\n *\n * Copyright (c) 2026 uixmat\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport * as React from \"react\";\n\nimport { cn } from \"../../system/utils/cn\";\nimport { Badge } from \"../badge\";\nimport { MotionNumber, followed, useFollowValue } from \"../motion\";\nimport {\n  CHART_LABEL_HEIGHT,\n  CHART_X_LABEL_CENTRE,\n  CHART_Y_LABEL_INSET,\n  placeAxisLabel,\n  useFadeClassName,\n  useFirstSeen,\n  useLastValue,\n  type CartesianLayout,\n} from \"./chart-cartesian\";\nimport { estimateLabelWidth } from \"./chart-scales\";\n\n/** What a pill is wider than its text, in px: the padding and the border of the Badge on both sides. */\nconst pillPadding = 16;\n/** How tall the pill is reckoned to be, in px: the Badge at its tallest. */\nconst pillHeight = 22;\n/** The space a label that is left standing keeps from the pill, in px. */\nconst pillGap = 6;\n\n/** Where the pill is: what it says and where the item is along the axis, in px from the start of the plot. */\nexport type AxisPillSpot = { position: number; text: string };\n\n/**\n * Which of the labels of an axis the pill sits on: those within reach of it, the\n * half of the pill and the half of the label and a gap. Along the bottom the\n * labels and the pill are as wide as their text; down the left side they are as\n * tall as a line, and the pill as tall as the Badge.\n */\nexport function coveredLabels(\n  labels: readonly { position: number; text: string }[],\n  spot: AxisPillSpot | null,\n  side: \"bottom\" | \"left\",\n): boolean[] {\n  return labels.map((label) => {\n    if (!spot) return false;\n    const reach =\n      side === \"bottom\"\n        ? (estimateLabelWidth(spot.text) + pillPadding) / 2 +\n          estimateLabelWidth(label.text) / 2\n        : pillHeight / 2 + CHART_LABEL_HEIGHT / 2;\n    return Math.abs(label.position - spot.position) < reach + pillGap;\n  });\n}\n\n/**\n * The labels of an axis with the ones the pill sits on marked `covered`, and\n * whether they must be drawn ready to fade. The labels at rest carry nothing of\n * a fade; from the first time the pill is shown they carry its classes, and they\n * are covered from two frames later, so that the first label to go fades out\n * instead of snapping out of sight.\n */\nexport function useCoveredLabels<L extends { position: number; text: string }>(\n  labels: readonly L[],\n  spot: AxisPillSpot | null,\n  side: \"bottom\" | \"left\",\n): { fading: boolean; labels: (L & { covered: boolean })[] } {\n  const { released: armed, seen } = useFirstSeen(spot !== null);\n  const covered = coveredLabels(labels, armed ? spot : null, side);\n  return {\n    fading: seen,\n    labels: labels.map((label, index) => ({\n      ...label,\n      covered: covered[index] ?? false,\n    })),\n  };\n}\n\n/**\n * The pill: the label of the active item in the inverse Badge, on the axis, at\n * the item. It is not drawn until it is first shown. It appears where the item\n * is and glides after it from then on, its label changing to the next one at\n * once, or with `rolling` its digits rolling to the next label's; it fades in\n * and out with the active item and waits where it was while it goes.\n */\nexport function ChartAxisPill({\n  container,\n  layout,\n  rolling = false,\n  side,\n  spot,\n}: {\n  /** The element whose motion tokens set the glide: the chart. */\n  container?: React.RefObject<Element | null>;\n  layout: CartesianLayout;\n  /** Rolls the digits of the label to the next one's (the chart's `rollingNumbers`). */\n  rolling?: boolean;\n  /** The axis the pill sits on: the bottom of a chart with categories along it, the left side of a horizontal Bar Chart. */\n  side: \"bottom\" | \"left\";\n  /** Null while nothing is active. */\n  spot: AxisPillSpot | null;\n}): React.ReactElement | null {\n  const fade = useFadeClassName();\n  const active = spot !== null;\n  // The pill waits where it was, saying what it said, while it fades out.\n  const held = useLastValue(spot ? spot.text : null) ?? \"\";\n  const heldPosition = useLastValue(spot ? spot.position : null) ?? 0;\n  // It says what the label it covers says, cut and kept inside the box as that\n  // label is: to the column of labels at the left, and along the bottom moved\n  // in from either end of the box.\n  const placed = placeAxisLabel(held, heldPosition, layout, side);\n  const text = placed.text;\n  const place = useFollowValue(\n    side === \"bottom\"\n      ? layout.margin.left + placed.x\n      : layout.margin.top + heldPosition,\n    { active, container },\n  );\n\n  // Nothing is drawn until the pill is first shown, and then it is drawn\n  // invisible for two frames, so that the browser has a frame to fade in from.\n  const { released, seen } = useFirstSeen(active);\n\n  // A label that arrives with the pill is not a change of the one it showed\n  // before it went: it does not roll.\n  const [wasActive, setWasActive] = React.useState(false);\n  const [arrivals, setArrivals] = React.useState(0);\n  if (active !== wasActive) {\n    setWasActive(active);\n    if (active) setArrivals(arrivals + 1);\n  }\n\n  if (!seen) return null;\n  const bottom = side === \"bottom\";\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"sp-chart-axis-pill-layer pointer-events-none absolute inset-0\"\n      data-slot=\"chart-axis-pill-layer\"\n    >\n      <followed.div\n        className={cn(\n          \"sp-chart-axis-pill\",\n          \"absolute top-0 left-0 whitespace-nowrap -translate-y-1/2\",\n          bottom ? \"-translate-x-1/2\" : \"-translate-x-full\",\n          fade,\n        )}\n        data-side={side}\n        data-slot=\"chart-axis-pill\"\n        data-visible={active && released ? \"\" : undefined}\n        style={\n          bottom\n            ? {\n                x: place,\n                y: layout.margin.top + layout.plotHeight + CHART_X_LABEL_CENTRE,\n              }\n            : {\n                x: layout.margin.left - CHART_Y_LABEL_INSET + 4,\n                y: place,\n              }\n        }\n      >\n        {/* The sm Badge sets its text at the 12 px of the labels it covers;\n            the label, or the number that counts, sits in a span of its own. */}\n        <Badge size=\"sm\" variant=\"inverse\">\n          <span>\n            {rolling ? <MotionNumber key={arrivals} value={text} /> : text}\n          </span>\n        </Badge>\n      </followed.div>\n    </div>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-axis-pill.tsx"},{"path":"packages/ui/src/components/chart/chart-dimming.ts","content":"\"use client\";\n\n/*\n * How far the rest of a chart steps back while one item is active: the bars\n * outside the active category, and the lines and areas outside the stretch\n * around the active point. The levels are Siteplane's own and light, so the\n * active item stands out without the rest going pale (80 %, and 90 % for an\n * area). A Pie Chart steps nothing back: its shares are shades of one hue,\n * and a faded share took the colour of another one. The two speeds are\n * bklit's: a bar steps back quickly, a whole series slowly. They run on the\n * kit's duration tokens and ease in and out; reduced motion is instant.\n *\n * The same levels and speeds serve a series that steps back because the mouse\n * is over another one's entry in the legend, as in bklit UI\n * (series-hover-dim.tsx, bar.tsx `legendHoveredIndex`, chart-legend.tsx\n * `isFaded`, all MIT): the legend's own entries fade to the level of a line,\n * 80 %.\n *\n * MIT License\n *\n * Copyright (c) 2026 uixmat\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport { useReducedMotionPreference } from \"../../system/motion/use-reduced-motion-preference\";\n\n/**\n * What an item that steps back is drawn at, from 0 to 1: the one place that says\n * how far each kind steps back.\n */\nexport const CHART_DIM = {\n  /** An area, its fill and its outline, outside the highlighted stretch. */\n  area: 0.9,\n  /** A bar outside the active category. */\n  bar: 0.8,\n  /** The stroke of a line outside the highlighted stretch. */\n  line: 0.8,\n} as const;\n\nexport type ChartDimKind = keyof typeof CHART_DIM;\n\n/**\n * The opacity of an item of `kind`: `CHART_DIM` while it steps back, nothing\n * (its own, 1) while it does not, so a chart at rest carries no opacity at all.\n */\nexport function chartDimOpacity(\n  kind: ChartDimKind,\n  dimmed: boolean,\n): number | undefined {\n  return dimmed ? CHART_DIM[kind] : undefined;\n}\n\n/**\n * How long the step takes, as the classes that carry it, on the kit's tokens:\n * bars and legend entries take `--motion-duration-fast` (150 ms) and a series,\n * which has more to lose, `--motion-duration-linger` (380 ms), both easing in\n * and out. Spelled out in full, because Tailwind finds a class only where it is\n * written whole.\n */\nexport const chartDimClassName = {\n  mark: \"transition-opacity duration-(--motion-duration-fast) ease-(--motion-ease-soft-in-out)\",\n  series:\n    \"transition-opacity duration-(--motion-duration-linger) ease-(--motion-ease-soft-in-out)\",\n} as const;\n\n/**\n * Whether a series steps back because another series is the focused one, the\n * series whose entry in the legend has the mouse: every series but that one\n * does, and none does while no series is focused. A chart asks it for each mark\n * next to the rule of its active item, and a mark steps back when either rule\n * says so.\n */\nexport function seriesSteppedBack(\n  focusedKey: string | null,\n  key: string,\n): boolean {\n  return focusedKey !== null && focusedKey !== key;\n}\n\n/**\n * The level of a legend entry that steps back: the level of a line\n * (`CHART_DIM.line`, 80 %). An entry is HTML, which takes no `opacity`\n * attribute, so the level is a class, written whole for Tailwind; its test\n * holds it to `CHART_DIM.line`. The entry steps back over the `mark` speed.\n */\nexport const chartLegendDimClassName = \"opacity-80\";\n\n/** The class of a step back of `speed`, or none when motion is reduced (rulebook M5). */\nexport function useChartDimClassName(\n  speed: keyof typeof chartDimClassName,\n): string | undefined {\n  const reduced = useReducedMotionPreference();\n  return reduced ? undefined : chartDimClassName[speed];\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-dimming.ts"},{"path":"packages/ui/src/components/chart/chart-highlight.tsx","content":"\"use client\";\n\n/*\n * The highlighted stretch of a line or an area: while a point is active the\n * strokes step back (chart-dimming.ts) and the stretch around the point is\n * drawn again at full strength, the stroke re-drawn on the same path and clipped\n * to a band that glides after the point. The idea, and the band (one point\n * either side of the active one, held to the ends), are adapted from bklit UI's\n * highlight segment (highlight-segment.tsx, highlight-segment-bounds.ts, MIT);\n * the band's glide is the follow motion, the same that takes the crosshair.\n *\n * MIT License\n *\n * Copyright (c) 2026 uixmat\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport * as React from \"react\";\n\nimport { followed, useFollowValue } from \"../motion\";\nimport { useFirstSeen, useLastValue } from \"./chart-cartesian\";\nimport { useChartDimClassName } from \"./chart-dimming\";\nimport { cn } from \"../../system/utils/cn\";\n\n/** The band around an active point: from the point before it to the point after it, held to the ends, in px along the axis. Null for no such point. */\nexport function highlightBand(\n  positions: readonly number[],\n  index: number,\n): { width: number; x: number } | null {\n  if (index < 0 || index >= positions.length) return null;\n  const start = positions[Math.max(0, index - 1)]!;\n  const end = positions[Math.min(positions.length - 1, index + 1)]!;\n  return { width: Math.max(0, end - start), x: start };\n}\n\n/**\n * Whether the stretch of a stroke is at full strength: the active point has\n * brought it in, it has been drawn for two frames, and no other rule holds the\n * series back. A stroke that the legend holds back keeps the level of the rest\n * of its series, stretch or not.\n */\nexport function stretchShown({\n  active,\n  released,\n  steppedBack,\n}: {\n  active: boolean;\n  released: boolean;\n  steppedBack: boolean;\n}): boolean {\n  return active && released && !steppedBack;\n}\n\n// What the band glides over: the duration the strokes step back over, so they and\n// the stretch settle together.\nconst bandDuration = \"--motion-duration-linger\";\n// The room above and below the plot the band keeps, so that a stroke on the top\n// or the bottom edge of the plot is not cut in half by it.\nconst slack = 4;\n\n/**\n * The stretch around the active point, at full strength: each stroke drawn\n * again on its own path, clipped to the band, which glides to the next point\n * with the follow motion. It is not drawn before the first point is active, and\n * fades in and out with the active point.\n */\nexport function ChartHighlight({\n  band,\n  container,\n  height,\n  strokes,\n}: {\n  /** The band of the active point, null while nothing is active. */\n  band: { width: number; x: number } | null;\n  /** The element whose motion tokens set the glide: the chart. */\n  container?: React.RefObject<Element | null>;\n  /** The height of the plot, in px. */\n  height: number;\n  strokes: readonly {\n    color: string;\n    d: string;\n    key: string;\n    /**\n     * The series steps back for another reason as well (it is not the focused\n     * series of the legend), so its stretch is not brought back to full\n     * strength: a mark is held back when either rule holds it back.\n     */\n    steppedBack?: boolean;\n    width: number;\n  }[];\n}): React.ReactElement | null {\n  const clipId = React.useId();\n  const active = band !== null;\n  // The band waits where it was while the strokes fade back in. It follows a\n  // little slower than the crosshair, so the stretch trails the\n  // point it belongs to; its duration is the series' own, the one the strokes\n  // step back over.\n  const options = { active, container, duration: bandDuration } as const;\n  const x = useFollowValue(useLastValue(band ? band.x : null) ?? 0, options);\n  const width = useFollowValue(\n    useLastValue(band ? band.width : null) ?? 0,\n    options,\n  );\n  const className = useChartDimClassName(\"series\");\n  const { released, seen } = useFirstSeen(active);\n  if (!seen) return null;\n  return (\n    <g className=\"sp-chart-highlight-clip\" data-slot=\"chart-highlight-clip\">\n      <clipPath id={clipId}>\n        <followed.rect\n          height={height + slack * 2}\n          width={width}\n          x={x}\n          y={-slack}\n        />\n      </clipPath>\n      {strokes.map((stroke) => (\n        <path\n          className={cn(\"sp-chart-highlight\", className)}\n          clipPath={`url(#${clipId})`}\n          d={stroke.d}\n          data-slot=\"chart-highlight\"\n          fill=\"none\"\n          key={stroke.key}\n          opacity={\n            stretchShown({\n              active,\n              released,\n              steppedBack: stroke.steppedBack ?? false,\n            })\n              ? 1\n              : 0\n          }\n          stroke={stroke.color}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={stroke.width}\n        />\n      ))}\n    </g>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-highlight.tsx"},{"path":"packages/ui/src/components/chart/chart-cartesian.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\nimport { useReducedMotionPreference } from \"../../system/motion/use-reduced-motion-preference\";\nimport { cn } from \"../../system/utils/cn\";\nimport {\n  followed,\n  useFollowDerived,\n  useFollowValue,\n  useTweenedRows,\n  type TweenOptions,\n} from \"../motion\";\nimport { panelSwitchClassName } from \"../motion/motion-presence\";\nimport {\n  advanceGeneration,\n  presenceOf,\n  tickPosition,\n  type ChartPresence,\n  type Generation,\n} from \"./chart-model\";\nimport {\n  estimateLabelWidth,\n  labelGap,\n  labelStep,\n  readableLabels,\n  truncateLabel,\n} from \"./chart-scales\";\nimport { useChartHeight, useChartWidth } from \"./use-chart-width\";\n\n/** The space around the plot, in px, for the labels of the axes. */\nexport type ChartMargins = {\n  top: number;\n  right: number;\n  bottom: number;\n  left: number;\n};\n\nexport type CartesianLayout = {\n  /** The width of the SVG. */\n  width: number;\n  /** The height of the SVG: the height of the chart less the space kept for the legend below it. */\n  height: number;\n  margin: ChartMargins;\n  plotWidth: number;\n  plotHeight: number;\n};\n\n// The room a chart keeps around its plot, in px.\nconst marginTop = 12;\nconst marginRight = 16;\nconst marginSide = 12;\n// An x label sits 10 px under the plot and is 12 px tall, with room to spare.\nconst labelOffset = 10;\n/** Where the middle of the text of an x label is, in px under the plot: what a pill that covers the label centres on. */\nexport const CHART_X_LABEL_CENTRE = labelOffset + 4;\nconst marginBottomWithLabels = 28;\n// The space between the labels of an axis and the plot.\nconst labelInset = 8;\n/** How far the right edge of a label of the left axis is from the plot, in px: where a pill that covers the label ends. */\nexport const CHART_Y_LABEL_INSET = labelInset;\n/** The height kept below the plot for a legend of one row. */\nexport const CHART_LEGEND_BAND = 32;\n// The share of the width that the labels at the left of the plot may take: a\n// longer label is cut to fit, so that a long category name on a phone leaves\n// the bars their room.\nconst labelColumnShare = 0.4;\n// What a label at the left is narrower than its column: the inset to the plot\n// and room to spare at the edge.\nconst labelColumnSlack = labelInset + 4;\n// The widest the column of labels at the left of a chart this wide may be.\nconst labelColumn = (width: number) =>\n  Math.max(marginSide, Math.floor(Math.max(0, width) * labelColumnShare));\n\n/**\n * The size of the plot and the space around it. The margins follow the widths\n * of the labels as `estimateLabelWidth` reckons them, so that the server and\n * the client lay out alike. The labels at the left take at most 40 % of the\n * width, and the axes cut a longer one to the room it has\n * (`chartLabelRoom`). A legend takes its band from the height of the chart,\n * so a chart keeps its height with or without one.\n */\nexport function cartesianLayout({\n  width,\n  height,\n  yLabels,\n  xAxis,\n  yAxis,\n  legend,\n}: {\n  width: number;\n  height: number;\n  yLabels: readonly string[];\n  xAxis: boolean;\n  yAxis: boolean;\n  legend: boolean;\n}): CartesianLayout {\n  const widest = yAxis\n    ? Math.ceil(Math.max(0, ...yLabels.map(estimateLabelWidth)))\n    : 0;\n  const margin: ChartMargins = {\n    top: marginTop,\n    right: marginRight,\n    bottom: xAxis ? marginBottomWithLabels : marginSide,\n    left:\n      yAxis && widest > 0\n        ? Math.min(widest + labelColumnSlack, labelColumn(width))\n        : marginSide,\n  };\n  const svgHeight = Math.max(0, height - (legend ? CHART_LEGEND_BAND : 0));\n  return {\n    width,\n    height: svgHeight,\n    margin,\n    plotWidth: Math.max(0, width - margin.left - margin.right),\n    plotHeight: Math.max(0, svgHeight - margin.top - margin.bottom),\n  };\n}\n\n/** The layout of a chart at this size, kept while the size and the labels stay the same. */\nexport function useCartesianLayout(options: {\n  width: number;\n  height: number;\n  yLabels: readonly string[];\n  xAxis: boolean;\n  yAxis: boolean;\n  legend: boolean;\n}): CartesianLayout {\n  const { width, height, xAxis, yAxis, legend } = options;\n  const yLabels = options.yLabels.join(\"\\n\");\n  return React.useMemo(\n    () =>\n      cartesianLayout({\n        width,\n        height,\n        yLabels: yLabels === \"\" ? [] : yLabels.split(\"\\n\"),\n        xAxis,\n        yAxis,\n        legend,\n      }),\n    [width, height, yLabels, xAxis, yAxis, legend],\n  );\n}\n\n/**\n * The height of a Bar, Line or Area Chart in px, and the least height of one\n * that fills its box, when the chart is given neither.\n */\nexport const CHART_DEFAULT_HEIGHT = 240;\n\n/** The size a Bar, Line or Area Chart draws at, and what its box takes for it. */\nexport type ChartBox = {\n  /** The width to draw at in px, or null until the box has been measured. */\n  width: number | null;\n  /** The height to draw at in px, a legend included. */\n  height: number;\n  /** The classes of the box while the chart fills it. */\n  className: string | undefined;\n  /** The box's `data-height`: `fill` while the chart fills it. */\n  dataHeight: \"fill\" | undefined;\n  /** The style of the box while the chart fills it: its least height. */\n  style: React.CSSProperties | undefined;\n};\n\n/**\n * The size of a Bar, Line or Area Chart, the one owner of the box rules of all\n * three. The width is the content width of the chart's box (`useChartWidth`).\n * The height is `height` in px, or with `height=\"fill\"` the content height of\n * the box (`useChartHeight`), never lower than `minHeight`; until the box is\n * measured, on the server too, the chart is `minHeight` tall. A box that the\n * chart fills is the full height of its container, at least `minHeight` tall\n * and size-contained, so it takes its height from the container (a flex item\n * that grows, a grid cell or a box with a height) and never from what the\n * chart draws in it: the two never chase each other. Both heights are 240 px\n * by default.\n */\nexport function useChartBox(\n  root: React.RefObject<HTMLElement | null>,\n  {\n    height = CHART_DEFAULT_HEIGHT,\n    minHeight = CHART_DEFAULT_HEIGHT,\n  }: {\n    height?: number | \"fill\" | undefined;\n    minHeight?: number | undefined;\n  },\n): ChartBox {\n  const width = useChartWidth(root);\n  const fill = height === \"fill\";\n  const measured = useChartHeight(root, fill);\n  if (!fill)\n    return {\n      className: undefined,\n      dataHeight: undefined,\n      height,\n      style: undefined,\n      width,\n    };\n  return {\n    className: \"h-full [contain:size]\",\n    dataHeight: \"fill\",\n    height: Math.max(minHeight, measured ?? minHeight),\n    style: { minHeight },\n    width,\n  };\n}\n\n/**\n * The room a label at the left of the plot has for its text, in px: the widest\n * column a chart of this width allows, less the inset to the plot. A label\n * that the column was laid out for always fits, and so does one on its way out\n * while new data narrows the column; only a label longer than the column may\n * ever be is cut.\n */\nexport function chartLabelRoom(layout: CartesianLayout): number {\n  return Math.max(0, labelColumn(layout.width) - labelColumnSlack);\n}\n\n/**\n * A label of an axis as it is drawn: its text, cut to the room it has, and\n * where it sits. A label at the left is cut to its column and stands against\n * the plot. A label along the bottom is centred on its position as far as the\n * box of the chart allows: one at either end moves inwards until it is inside,\n * and one wider than the whole box is cut to it.\n */\nexport function placeAxisLabel(\n  text: string,\n  position: number,\n  layout: CartesianLayout,\n  side: \"bottom\" | \"left\",\n): { text: string; x: number } {\n  if (side === \"left\")\n    return {\n      text: truncateLabel(text, chartLabelRoom(layout)),\n      x: -labelInset,\n    };\n  const shown = truncateLabel(text, layout.width);\n  const half = estimateLabelWidth(shown) / 2;\n  const low = half - layout.margin.left;\n  const high = layout.plotWidth + layout.margin.right - half;\n  return {\n    text: shown,\n    x: low > high ? (low + high) / 2 : Math.min(high, Math.max(low, position)),\n  };\n}\n\n/**\n * The last value that was not null, so that a part that fades out keeps where\n * it was while it goes. Null until there has been a value.\n */\nexport function useLastValue<T>(value: T | null): T | null {\n  const [last, setLast] = React.useState<T | null>(value);\n  // Object.is, so that a value that is NaN is kept once and not on every render.\n  if (value !== null && !Object.is(value, last)) setLast(value);\n  return value ?? last;\n}\n\n/**\n * For a part that is not drawn at rest, such as a pill or a highlighted stretch:\n * whether it has ever been needed (`seen`, from the first render in which it is\n * active, for good) and whether it has been drawn for two frames since\n * (`released`). The part mounts when it is first needed, invisible, and is\n * released two frames later, so that the browser has a frame to fade it in from.\n */\nexport function useFirstSeen(active: boolean): {\n  released: boolean;\n  seen: boolean;\n} {\n  const [seen, setSeen] = React.useState(false);\n  if (active && !seen) setSeen(true);\n  const [released, setReleased] = React.useState(false);\n  React.useEffect(() => {\n    if (!seen || released) return;\n    let second = 0;\n    const first = requestAnimationFrame(() => {\n      second = requestAnimationFrame(() => setReleased(true));\n    });\n    return () => {\n      cancelAnimationFrame(first);\n      cancelAnimationFrame(second);\n    };\n  }, [released, seen]);\n  return { released, seen };\n}\n\n/**\n * A number that goes up whenever the signature changes. The labels of an axis\n * take it into their keys: when new data changes the axis, every label of the\n * old axis leaves and every label of the new one enters, even a label that both\n * have, because it sits in another place now. While the axis stays, a label\n * stays, and a change of width only moves it.\n */\nexport function useGeneration(signature: string): number {\n  const [state, setState] = React.useState<Generation>({\n    count: 0,\n    signature,\n  });\n  const next = advanceGeneration(state, signature);\n  if (next !== state) setState(next);\n  return next.count;\n}\n\n/** A label of a category or time axis as it is drawn: where it sits, what it says and whether it comes or goes. */\nexport type GlidingLabel = {\n  key: string;\n  position: number;\n  presence: ChartPresence | undefined;\n  text: string;\n  /** Set by a chart that shows a pill: the pill sits on the label. */\n  covered?: boolean;\n  /** The label of a category that the chart highlights, such as a period that is still running. */\n  highlighted?: boolean;\n};\n\n/**\n * The labels of a category or time axis, kept by key so that a label that comes\n * or goes switches at once while the others stay: the new labels show the moment\n * the old ones are hidden, so two are never seen on top of each other and the\n * axis is never bare (rulebook M8). Every label is replaced when the `signature`\n * of the axis changes; none is\n * while it stays. Hand the result to `ChartCategoryAxis` with `thin` off, having\n * thinned the labels yourself, since the old and the new labels are in the list\n * at once while one set replaces the other.\n */\nexport function useGlidingLabels({\n  container,\n  labels,\n  signature,\n}: {\n  /** The element whose motion tokens set the timing: the chart. */\n  container: NonNullable<TweenOptions[\"container\"]>;\n  /** The labels the axis has for the data now, none when it draws none. */\n  labels: readonly {\n    id: string;\n    position: number;\n    text: string;\n    highlighted?: boolean;\n  }[];\n  signature: string;\n}): GlidingLabel[] {\n  const generation = useGeneration(signature);\n  return useTweenedRows(\n    labels.map((label) => ({ ...label, id: `${generation}:${label.id}` })),\n    () => 1,\n    { container },\n  ).map((item) => ({\n    key: item.key,\n    position: item.row.position,\n    presence: presenceOf(item),\n    text: item.row.text,\n    ...(item.row.highlighted ? { highlighted: true } : {}),\n  }));\n}\n\n/** A tick of a value axis as it is drawn: where it is now, what it says and whether it comes or goes. */\nexport type GlidingTick = {\n  position: number;\n  presence: ChartPresence | undefined;\n  text: string;\n  value: number;\n};\n\n/**\n * The ticks of a value axis that moves when new data stretches it. A tick is\n * kept by its value, so one that stays or comes in sits where `place` puts its\n * value now and glides with the axis, and one that goes out fades where it stood\n * (`tickPosition`). A tick that has glided out of the plot has left.\n */\nexport function useGlidingTicks({\n  container,\n  length,\n  place,\n  ticks,\n}: {\n  /** The element whose motion tokens set the timing: the chart. */\n  container: NonNullable<TweenOptions[\"container\"]>;\n  /** How long the axis is, in px. */\n  length: number;\n  /** A value as a position along the axis now, which is moving while the axis glides. */\n  place: (value: number) => number;\n  /** The ticks the axis has for the data now, none when it draws none. */\n  ticks: readonly { text: string; value: number }[];\n}): GlidingTick[] {\n  return useTweenedRows(\n    ticks.map(({ text, value }) => ({\n      id: String(value),\n      position: place(value),\n      text,\n      value,\n    })),\n    () => 1,\n    { container },\n  )\n    .map((tick) => ({\n      position: tickPosition(tick, place),\n      presence: presenceOf(tick),\n      text: tick.row.text,\n      value: tick.row.value,\n    }))\n    .filter((tick) => tick.position >= -12 && tick.position <= length + 12);\n}\n\n/** The fade of a part that comes and goes, over the micro duration (M1). */\nconst microFadeClassName =\n  \"transition-opacity duration-(--motion-duration-micro) ease-(--motion-ease-standard)\";\n\n// Parts that come and go fade over the micro duration, and not at all for\n// reduced motion (rulebook M5).\nexport function useFadeClassName(): string {\n  const reduced = useReducedMotionPreference();\n  return cn(\n    \"opacity-0 data-visible:opacity-100\",\n    !reduced && microFadeClassName,\n  );\n}\n\n/**\n * How an axis label or a grid line takes part in a change. One that comes with\n * new data shows at once and one that goes is hidden at once, with the kit's\n * content switch (`panelSwitchClassName`, rulebook M8), so the old labels and\n * the new ones are never seen together and the axis is never bare (Lukas,\n * 4 November 2026: they faded through, which left the axis without labels for\n * about 80 ms). A label that the axis pill covers fades out over the micro\n * duration until the pill has passed, and back in, as the parts of the pill\n * come and go (M4).\n */\nfunction useAxisPart(\n  presence: ChartPresence | undefined,\n  covered = false,\n  fading = false,\n): {\n  className?: string;\n  \"data-ending-style\"?: string | undefined;\n} {\n  const reduced = useReducedMotionPreference();\n  const hidden = presence === \"leaving\" || covered ? \"\" : undefined;\n  if (presence)\n    return { className: panelSwitchClassName, \"data-ending-style\": hidden };\n  // A label at rest carries nothing, unless it can be covered by a pill: it\n  // takes the classes of the fade before that happens, so that its first fade\n  // out does not snap, and keeps them to fade back in. With reduced motion a\n  // label that is covered is out of sight at once and comes back at once (M5).\n  if (!covered && !fading) return {};\n  return {\n    className: cn(\n      \"data-[ending-style]:opacity-0\",\n      !reduced && microFadeClassName,\n    ),\n    \"data-ending-style\": hidden,\n  };\n}\n\nfunction FadingText({\n  className,\n  covered,\n  fading,\n  presence,\n  ...props\n}: React.ComponentProps<\"text\"> & {\n  /** Something sits on the label: it fades out until it is clear. */\n  covered?: boolean | undefined;\n  /** The label is ready to fade although it is at rest. */\n  fading?: boolean | undefined;\n  presence?: ChartPresence | undefined;\n}) {\n  const fade = useAxisPart(presence, covered, fading);\n  return (\n    <text\n      {...props}\n      {...fade}\n      className={cn(className, fade.className) || undefined}\n    />\n  );\n}\n\nfunction GridLine({\n  presence,\n  ...props\n}: React.ComponentProps<\"line\"> & { presence?: ChartPresence | undefined }) {\n  const part = useAxisPart(presence);\n  return <line {...props} {...part} />;\n}\n\n/** Hairlines across the plot, one per position, in the colour of the Separator. */\nexport function ChartGrid({\n  positions,\n  presences,\n  layout,\n  direction = \"horizontal\",\n}: {\n  /** Where the lines are, in px from the top (horizontal) or the left (vertical) of the plot. */\n  positions: readonly number[];\n  /** Which lines come or go with new data, in the order of `positions`. */\n  presences?: readonly (ChartPresence | undefined)[];\n  layout: CartesianLayout;\n  direction?: \"horizontal\" | \"vertical\";\n}): React.ReactElement {\n  return (\n    <g\n      aria-hidden=\"true\"\n      className=\"sp-chart-grid stroke-border\"\n      data-slot=\"chart-grid\"\n      fill=\"none\"\n      shapeRendering=\"crispEdges\"\n      strokeWidth={1}\n    >\n      {positions.map((position, index) => (\n        <GridLine\n          key={index}\n          presence={presences?.[index]}\n          x1={direction === \"horizontal\" ? 0 : position}\n          x2={direction === \"horizontal\" ? layout.plotWidth : position}\n          y1={direction === \"horizontal\" ? position : 0}\n          y2={direction === \"horizontal\" ? position : layout.plotHeight}\n        />\n      ))}\n    </g>\n  );\n}\n\n/** The labels of both axes, and the value labels of a Bar Chart: 12 px, muted, in tabular figures. */\nexport const chartAxisLabelClassName =\n  \"fill-muted-foreground text-xs tabular-nums\";\nconst axisLabelClassName = chartAxisLabelClassName;\n\n/**\n * The text of a category that a chart highlights, on its axis and over its\n * bars: the text colour and the medium weight, where the other labels are\n * muted.\n */\nexport const chartHighlightedTextClassName = \"fill-foreground font-medium\";\n\n/** The room a label of a left axis takes in a column of them: as tall as a line of 12 px text, in px. */\nexport const CHART_LABEL_HEIGHT = 14;\n/** The space kept between two labels of a left axis, in px. */\nexport const CHART_LABEL_COLUMN_GAP = 4;\n\n/** The labels of a value axis: along the left of the plot, or below it. */\nexport function ChartValueAxis({\n  ticks,\n  layout,\n  side,\n}: {\n  ticks: readonly {\n    value: number;\n    position: number;\n    text: string;\n    /** Whether the label comes or goes with new data. */\n    presence?: ChartPresence | undefined;\n  }[];\n  layout: CartesianLayout;\n  side: \"left\" | \"bottom\";\n}): React.ReactElement {\n  const placed = ticks.map((tick) =>\n    placeAxisLabel(tick.text, tick.position, layout, side),\n  );\n  // While new data stretches the axis its labels glide with it, and could end up\n  // on top of each other: the ones that come or go are left out until they clear.\n  const readable = readableLabels(\n    ticks.map((tick, index) =>\n      side === \"left\" ? tick.position : placed[index]!.x,\n    ),\n    placed.map((label) =>\n      side === \"left\" ? CHART_LABEL_HEIGHT : estimateLabelWidth(label.text),\n    ),\n    ticks.map((tick) => tick.presence),\n    side === \"left\" ? CHART_LABEL_COLUMN_GAP : labelGap,\n  );\n  return (\n    <g\n      aria-hidden=\"true\"\n      className={cn(\"sp-chart-axis\", axisLabelClassName)}\n      data-slot=\"chart-axis\"\n      data-side={side}\n    >\n      {ticks.map((tick, index) =>\n        readable[index] ? (\n          <FadingText\n            dy={side === \"left\" ? \"0.32em\" : \"0.71em\"}\n            key={tick.value}\n            presence={tick.presence}\n            textAnchor={side === \"left\" ? \"end\" : \"middle\"}\n            x={placed[index]!.x}\n            y={\n              side === \"left\" ? tick.position : layout.plotHeight + labelOffset\n            }\n          >\n            {placed[index]!.text}\n          </FadingText>\n        ) : null,\n      )}\n    </g>\n  );\n}\n\n/**\n * The labels of a category or time axis, along the bottom of the plot or down\n * its left side. However many there are, none collide: the axis shows every k-th\n * label, one k for all of them (the smallest that leaves them clear), so that\n * the labels keep an even rhythm.\n */\nexport function ChartCategoryAxis({\n  labels,\n  layout,\n  side,\n  thin = true,\n  fading = false,\n}: {\n  labels: readonly {\n    key: string;\n    position: number;\n    text: string;\n    /** Whether the label comes or goes with new data. */\n    presence?: ChartPresence | undefined;\n    /** Whether a pill sits on the label: it fades out until the pill has left (rulebook M4). */\n    covered?: boolean | undefined;\n    /** Whether the chart highlights the category: its label takes the text colour and the medium weight. */\n    highlighted?: boolean | undefined;\n  }[];\n  layout: CartesianLayout;\n  side: \"bottom\" | \"left\";\n  /** Whether to leave labels out so that none collide. A chart that places its labels itself turns this off. */\n  thin?: boolean;\n  /** Draws the labels ready to fade, which a chart that shows a pill asks for once the pill has been shown. */\n  fading?: boolean;\n}): React.ReactElement {\n  const placed = labels.map((label) =>\n    placeAxisLabel(label.text, label.position, layout, side),\n  );\n  const step = thin\n    ? labelStep(\n        labels.map((label) => label.position),\n        placed.map((label) =>\n          side === \"bottom\"\n            ? estimateLabelWidth(label.text)\n            : CHART_LABEL_HEIGHT,\n        ),\n        side === \"bottom\" ? labelGap : CHART_LABEL_COLUMN_GAP,\n      )\n    : 1;\n  return (\n    <g\n      aria-hidden=\"true\"\n      className={cn(\"sp-chart-axis\", axisLabelClassName)}\n      data-slot=\"chart-axis\"\n      data-side={side}\n    >\n      {labels.map((label, index) =>\n        index % step === 0 ? (\n          <FadingText\n            className={\n              label.highlighted ? chartHighlightedTextClassName : undefined\n            }\n            covered={label.covered}\n            dy={side === \"bottom\" ? \"0.71em\" : \"0.32em\"}\n            fading={fading}\n            key={label.key}\n            presence={label.presence}\n            textAnchor={side === \"bottom\" ? \"middle\" : \"end\"}\n            x={placed[index]!.x}\n            y={\n              side === \"bottom\"\n                ? layout.plotHeight + labelOffset\n                : label.position\n            }\n          >\n            {placed[index]!.text}\n          </FadingText>\n        ) : null,\n      )}\n    </g>\n  );\n}\n\n/**\n * A hairline through the plot at the active item. It glides to the next item\n * with the follow motion, fades in and out with the active item, and waits out\n * of sight, where it last was, until the next one is active.\n */\nexport function ChartCrosshair({\n  container,\n  position,\n  layout,\n  direction = \"vertical\",\n}: {\n  /** The element whose motion tokens set the glide: the chart. */\n  container?: React.RefObject<Element | null> | undefined;\n  /** Where the line is, in px from the left (vertical) or the top (horizontal) of the plot; null for none. */\n  position: number | null;\n  layout: CartesianLayout;\n  direction?: \"vertical\" | \"horizontal\";\n}): React.ReactElement {\n  const fade = useFadeClassName();\n  // It appears at the item and glides from then on, so it does not slide in\n  // from where it was last seen.\n  const at = useFollowValue(useLastValue(position) ?? 0, {\n    active: position !== null,\n    container,\n  });\n  return (\n    <followed.line\n      aria-hidden=\"true\"\n      className={cn(\"sp-chart-crosshair\", \"stroke-border-strong\", fade)}\n      data-slot=\"chart-crosshair\"\n      data-visible={position !== null ? \"\" : undefined}\n      strokeWidth={1}\n      x1={direction === \"vertical\" ? at : 0}\n      x2={direction === \"vertical\" ? at : layout.plotWidth}\n      y1={direction === \"vertical\" ? 0 : at}\n      y2={direction === \"vertical\" ? layout.plotHeight : at}\n    />\n  );\n}\n\n/**\n * The dot of a series at the active item: its colour, ringed in the colour of\n * the surface it sits on. It glides to the next item with the follow motion; on\n * a line or an area it rides the curve there (`trace`), so it does not cut\n * across the plot between two points that are far apart.\n */\nexport function ChartActiveDot({\n  container,\n  cx,\n  cy,\n  color,\n  trace,\n  visible = true,\n}: {\n  /** The element whose motion tokens set the glide: the chart. */\n  container?: React.RefObject<Element | null> | undefined;\n  cx: number;\n  cy: number;\n  color: string;\n  /** The height of the series' curve at an x, null where it is not drawn: what a dot on a line rides along. Without it the dot glides to its height in a straight line. */\n  trace?: ((x: number) => number | null) | undefined;\n  /** Hides the dot with a fade, and lets it appear in its place when it is shown again. */\n  visible?: boolean;\n}): React.ReactElement {\n  const fade = useFadeClassName();\n  const x = useFollowValue(cx, { active: visible, container });\n  // Its height follows its place on the curve, not a spring of its own; where\n  // there is no curve to ride it glides there.\n  const free = useFollowValue(cy, { active: visible && !trace, container });\n  const riding = useFollowDerived(x, (along) =>\n    // At its place it is exactly where the data puts it.\n    Math.abs(along - cx) < 0.01 ? cy : (trace?.(along) ?? cy),\n  );\n  return (\n    <followed.circle\n      aria-hidden=\"true\"\n      className={cn(\n        \"sp-chart-active-dot\",\n        \"stroke-(--sp-rw-surface-color,var(--sp-rw-bg-base))\",\n        fade,\n      )}\n      cx={x}\n      cy={trace ? riding : free}\n      data-slot=\"chart-active-dot\"\n      data-visible={visible ? \"\" : undefined}\n      fill={color}\n      r={4}\n      strokeWidth={2}\n    />\n  );\n}\n\n// The empty state of every chart: 14 px, muted.\nconst emptyClassName = \"text-sm text-muted-foreground\";\n\n/**\n * What a chart shows in place of its marks when there is nothing to draw: the\n * label in muted 14 px text, in the middle of the plot. Given a `layout` it is\n * SVG text in the plot of a cartesian chart, drawn at the size of its layout.\n * Without one it is HTML over the plot, filling the positioned element it sits\n * in, for a chart whose SVG is scaled to its box, such as the Pie Chart. It is\n * hidden from assistive technology: the chart says it in its description.\n */\nexport function ChartEmptyState({\n  label,\n  layout,\n}: {\n  label: string;\n  layout?: CartesianLayout;\n}): React.ReactElement {\n  if (!layout)\n    return (\n      <div\n        aria-hidden=\"true\"\n        className={cn(\n          \"sp-chart-empty\",\n          \"pointer-events-none absolute inset-0 flex items-center justify-center text-center\",\n          emptyClassName,\n        )}\n        data-slot=\"chart-empty\"\n      >\n        {label}\n      </div>\n    );\n  return (\n    <text\n      aria-hidden=\"true\"\n      className={cn(\"sp-chart-empty\", \"fill-current\", emptyClassName)}\n      data-slot=\"chart-empty\"\n      dominantBaseline=\"middle\"\n      textAnchor=\"middle\"\n      x={layout.margin.left + layout.plotWidth / 2}\n      y={layout.margin.top + layout.plotHeight / 2}\n    >\n      {label}\n    </text>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-cartesian.tsx"},{"path":"packages/ui/src/components/chart/chart-line-area.tsx","content":"\"use client\";\n\n/*\n * The renderer behind the Line Chart and the Area Chart. Two ideas are adapted\n * from bklit UI's chart components (MIT): the data point under the pointer is\n * the one nearest to it on the x axis (use-chart-interaction.ts), and the value\n * axis moves with the marks when new data arrives (use-animated-y-domains.ts).\n * Everything else is Siteplane's own, built on d3-scale, the kit Tooltip and\n * the kit's motion hooks.\n *\n * MIT License\n *\n * Copyright (c) 2026 uixmat\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport * as React from \"react\";\n\nimport {\n  useFormatLocale,\n  useSiteplaneMessages,\n} from \"../../system/locale/messages-context\";\nimport { cn } from \"../../system/utils/cn\";\nimport { useTweenedValues } from \"../motion\";\nimport {\n  ChartActiveDot,\n  ChartCategoryAxis,\n  ChartCrosshair,\n  ChartEmptyState,\n  ChartGrid,\n  ChartValueAxis,\n  cartesianLayout,\n  useCartesianLayout,\n  useChartBox,\n  useGlidingLabels,\n  useGlidingTicks,\n  useLastValue,\n} from \"./chart-cartesian\";\nimport {\n  areaGradientStops,\n  curvePath,\n  curveTrace,\n  heldSampleCount,\n  morphSampleCount,\n  sampleCurve,\n  type ChartPoint,\n} from \"./chart-curve\";\nimport {\n  CHART_DEFAULT_LOCALE,\n  chartLabels,\n  chartNumberFormatter,\n  chartTickLabels,\n  chartXFormatter,\n} from \"./chart-format\";\nimport { nearestIndex, useChartInteraction } from \"./chart-interaction\";\nimport { ChartLegend, useChartSeriesFocus } from \"./chart-legend\";\nimport {\n  buildCartesianModel,\n  cartesianSummary,\n  type CartesianModel,\n  type CartesianRow,\n} from \"./chart-model\";\nimport { seriesColors } from \"./chart-palette\";\nimport {\n  calendarLabels,\n  estimateLabelWidth,\n  labelStep,\n  pointScale,\n  timeScale,\n  valueScale,\n  valueScaleAt,\n  valueTicks,\n} from \"./chart-scales\";\nimport { ChartAxisPill, useCoveredLabels } from \"./chart-axis-pill\";\nimport {\n  chartDimOpacity,\n  seriesSteppedBack,\n  useChartDimClassName,\n} from \"./chart-dimming\";\nimport { ChartHighlight, highlightBand } from \"./chart-highlight\";\nimport {\n  ChartPlotPlaceholder,\n  ChartTooltip,\n  ChartTooltipContent,\n  chartTooltipText,\n} from \"./chart-tooltip\";\nimport type {\n  CartesianChartLabels,\n  CartesianChartProps,\n  ChartCurve,\n  ChartXValue,\n} from \"./chart-types\";\nimport type { ScaleTime } from \"d3-scale\";\n\n/** The runs of rows that have a number, so that a gap in the data breaks a line instead of bridging it. */\nexport function runsOf(values: readonly (number | null)[]): number[][] {\n  const runs: number[][] = [];\n  let run: number[] = [];\n  values.forEach((value, index) => {\n    if (value === null) {\n      if (run.length > 0) runs.push(run);\n      run = [];\n    } else run.push(index);\n  });\n  if (run.length > 0) runs.push(run);\n  return runs;\n}\n\n/** One curve that glides: the heights of a series along a run of rows. */\nexport type MorphCurve = {\n  place: number;\n  heights: readonly (number | null)[];\n  run: number[];\n};\n\n/**\n * The curves that glide when new data arrives, or null when the data cannot\n * glide: a series with a gap, a point on its own or no points shows at once. A\n * stacked area glides along its top and along its base.\n */\nexport function morphCurves(\n  model: CartesianModel,\n  stacked: boolean,\n): MorphCurve[] | null {\n  const runs = model.tops.map(runsOf);\n  if (\n    model.empty ||\n    !runs.every((own) => own.length === 1 && own[0]!.length > 1)\n  )\n    return null;\n  return runs.flatMap((own, place): MorphCurve[] => {\n    const top = { heights: model.tops[place]!, place, run: own[0]! };\n    return stacked ? [top, { ...top, heights: model.bases[place]! }] : [top];\n  });\n}\n\n/**\n * The numbers that glide, in the units of the data, so that what lies between\n * two grid lines stays between them: the two ends of the value axis, then the\n * height of every curve at `count` places along its length. Curves of any\n * length are put into the same number of places, which is how a series of\n * another length morphs into the old one.\n */\nexport function morphTarget(\n  curves: readonly MorphCurve[],\n  positions: readonly number[],\n  domain: readonly [number, number],\n  curve: ChartCurve,\n  count: number,\n): number[] {\n  return [\n    domain[0],\n    domain[1],\n    ...curves.flatMap(({ heights, run }) =>\n      sampleCurve(\n        run.map((index): ChartPoint => [positions[index]!, heights[index]!]),\n        count,\n        curve,\n      ),\n    ),\n  ];\n}\n\nconst hundredths = (value: number) => Math.round(value * 100) / 100;\n\n/**\n * The number of places the curves are sampled at while they glide: the one the\n * width of the plot asks for (`morphSampleCount`), held while that stays about\n * the same, so that a label of another width on the value axis does not leave\n * the curves nothing to glide from.\n */\nfunction useSampleCount(plotWidth: number): number {\n  const wanted = morphSampleCount(plotWidth);\n  const [held, setHeld] = React.useState(wanted);\n  const count = heldSampleCount(held, wanted);\n  if (count !== held) setHeld(count);\n  return count;\n}\n\n/** A label of the x axis: where it sits in the plot and what it says. */\nexport type AxisLabel = { id: string; position: number; text: string };\n\n/**\n * The labels of the x axis, with an even rhythm and none on top of another.\n * Dates are labelled on the calendar ticks of the time scale (days, weeks,\n * months, ...), which it spaces evenly and which need not sit on data points:\n * the chart core's one rule for a date axis (`calendarLabels`), which the Bar\n * Chart's date axis follows too. Categories show every k-th label, one k for\n * the whole axis: the smallest that leaves them clear.\n */\nexport function axisLabels({\n  format,\n  positions,\n  rows,\n  timeAxis,\n}: {\n  format: (value: ChartXValue) => string;\n  /** Where each row sits, in px. */\n  positions: readonly number[];\n  rows: readonly CartesianRow[];\n  /** The scale of a time axis, null for categories. */\n  timeAxis: ScaleTime<number, number> | null;\n}): AxisLabel[] {\n  if (timeAxis) return calendarLabels(timeAxis, format);\n  const labels = rows.map((row, index) => ({\n    id: row.id,\n    position: positions[index]!,\n    text: format(row.x),\n  }));\n  const step = labelStep(\n    labels.map((label) => label.position),\n    labels.map((label) => estimateLabelWidth(label.text)),\n  );\n  return labels.filter((_, index) => index % step === 0);\n}\n\n// The room beyond the plot, in px, in which the pointer still finds the first and the last point.\nconst pointerSlack = 16;\n// A static dot of a line: 3 px in the series colour.\nconst dotRadius = 3;\n// A line is 2 px. The outline along the top of an area is 1 px, over the tint.\nconst lineWidth = 2;\nconst areaOutlineWidth = 1;\nconst noValues: readonly number[] = [];\n\nexport type LineAreaChartProps = CartesianChartProps & {\n  mode: \"line\" | \"area\";\n  curve?: ChartCurve;\n  /** Draws a dot at every point of a line. A point on its own always has one. */\n  dots?: boolean;\n  /** Stacks the areas on each other. */\n  stacked?: boolean;\n  fill?: \"gradient\" | \"solid\";\n};\n\n// Where a series' marks go as points: a run of rows at the given heights.\ntype Drawn = { base: ChartPoint[] | null; top: ChartPoint[] };\n\n// The gradient of an area lives with the curves, where the Sparkline takes it\n// too; it is still reachable from here.\nexport { areaGradientStops };\n\n// The path of an area: along the top, back along the base.\nfunction areaPath(\n  { base, top }: Drawn,\n  curve: ChartCurve,\n  floor: number,\n): string {\n  const along = curvePath(top, curve);\n  if (base)\n    return `${along} ${curvePath([...base].reverse(), curve).replace(/^M/, \"L\")} Z`;\n  const round = (value: number) => Math.round(value * 100) / 100;\n  const first = top[0]!;\n  const last = top[top.length - 1]!;\n  return `${along} L${round(last[0])},${round(floor)} L${round(first[0])},${round(floor)} Z`;\n}\n\n/**\n * A line chart or an area chart: the scales, the axes, the marks, the crosshair\n * and the tooltip. It draws its plot once it has measured its width, as a\n * chart that sizes to its container must: until then, on the server too, it\n * renders the box of the plot at its full height, the legend and what screen\n * readers read, so the page does not move and no plot is ever laid out for\n * another width. A chart that fills the height of its box measures that height\n * too, and keeps `minHeight` until it knows it. It glides to new data.\n */\nexport function LineAreaChart({\n  mode,\n  stacked = false,\n  data,\n  x,\n  series,\n  label,\n  \"aria-describedby\": describedBy,\n  height: givenHeight,\n  minHeight,\n  formatValue,\n  formatX,\n  locale: localeProp,\n  palette = \"brand\",\n  legend = series.length > 1,\n  xAxis = true,\n  yAxis = true,\n  emptyLabel: emptyLabelProp,\n  labels,\n  className,\n  ...plot\n}: LineAreaChartProps): React.ReactElement {\n  const messages = useSiteplaneMessages(\"chart\");\n  const emptyLabel = emptyLabelProp ?? messages.noData;\n  // The chart's own locale, else the provider's, else en-US.\n  const locale = useFormatLocale(localeProp, CHART_DEFAULT_LOCALE);\n  const root = React.useRef<HTMLElement>(null);\n  // The chart core sizes the chart: its width, and its height in px or from\n  // the box it fills.\n  const box = useChartBox(root, { height: givenHeight, minHeight });\n  const { height, width } = box;\n  const area = mode === \"area\";\n  const stack = area && stacked;\n  const colors = seriesColors(series, palette);\n  const showLegend = legend && series.length > 0;\n  // The series whose entry the mouse is over in the legend: the chart owns it\n  // and hands it to the legend and to its own marks.\n  const focus = useChartSeriesFocus(series.map((item) => item.key));\n  const words = chartLabels(labels, messages);\n\n  const model = React.useMemo(\n    () => buildCartesianModel({ data, series, stacked: stack, x }),\n    [data, series, stack, x],\n  );\n  const formatHeading = formatX ?? chartXFormatter(locale, \"long\");\n  const formatNumber = formatValue ?? chartNumberFormatter(locale);\n  const description = cartesianSummary({\n    emptyLabel,\n    formatValue: formatNumber,\n    formatX: formatHeading,\n    labels: words,\n    model,\n    series,\n  });\n\n  return (\n    // The figure holds the plot and the legend. The focus stop inside is the\n    // chart for assistive technology, named by the label alone.\n    <figure\n      className={cn(\n        area ? \"sp-area-chart\" : \"sp-line-chart\",\n        \"flex w-full flex-col\",\n        box.className,\n        className,\n      )}\n      data-height={box.dataHeight}\n      data-slot={area ? \"area-chart\" : \"line-chart\"}\n      ref={root}\n      style={box.style}\n    >\n      {width === null ? (\n        <ChartPlotPlaceholder\n          className={plotClassName}\n          description={description}\n          describedBy={describedBy}\n          height={\n            cartesianLayout({\n              height,\n              legend: showLegend,\n              width: 0,\n              xAxis,\n              yAxis,\n              yLabels: [],\n            }).height\n          }\n          label={label}\n          roleDescription={words.roleDescription}\n        />\n      ) : (\n        <LineAreaPlot\n          {...plot}\n          area={area}\n          colors={colors}\n          describedBy={describedBy}\n          description={description}\n          emptyLabel={emptyLabel}\n          focusedKey={focus.focusedKey}\n          formatHeading={formatHeading}\n          formatNumber={formatNumber}\n          formatValue={formatValue}\n          formatX={formatX}\n          height={height}\n          label={label}\n          locale={locale}\n          model={model}\n          root={root}\n          series={series}\n          showLegend={showLegend}\n          stack={stack}\n          width={width}\n          words={words}\n          xAxis={xAxis}\n          yAxis={yAxis}\n        />\n      )}\n      {showLegend && (\n        <ChartLegend\n          className=\"pt-3\"\n          focus={focus}\n          items={series.map((item, place) => ({\n            color: colors[place]!,\n            key: item.key,\n            label: item.label,\n          }))}\n          layout=\"row\"\n        />\n      )}\n    </figure>\n  );\n}\n\n// The focus stop that holds the plot, and the box of the plot before it is drawn.\nconst plotClassName = \"relative block w-full rounded-lg\";\n\n/**\n * The plot of a line chart or an area chart at its measured width: the scales,\n * the axes, the marks, the crosshair and the tooltip. It mounts once the chart\n * knows its width, so every part that glides starts at the place it has at\n * that width.\n */\nfunction LineAreaPlot({\n  area,\n  colors,\n  // The monotone curve by default, as d3's curveMonotoneX draws it: it never\n  // swings past the data, so a spike cannot pull a line below 0 or through\n  // the labels of the axis.\n  curve = \"monotone\",\n  describedBy,\n  description,\n  dots = false,\n  emptyLabel,\n  fill = \"gradient\",\n  focusedKey,\n  formatHeading,\n  formatNumber,\n  formatValue,\n  formatX,\n  grid = true,\n  height,\n  label,\n  locale,\n  model,\n  rollingNumbers = false,\n  root,\n  series,\n  showLegend,\n  stack,\n  width,\n  words,\n  xAxis,\n  yAxis,\n}: Pick<\n  LineAreaChartProps,\n  \"curve\" | \"dots\" | \"fill\" | \"grid\" | \"rollingNumbers\"\n> & {\n  area: boolean;\n  colors: readonly string[];\n  describedBy: string | undefined;\n  description: string;\n  emptyLabel: string;\n  focusedKey: string | null;\n  formatHeading: (value: ChartXValue) => string;\n  formatNumber: (value: number) => string;\n  formatValue: ((value: number) => string) | undefined;\n  formatX: ((value: ChartXValue) => string) | undefined;\n  height: number;\n  label: string;\n  locale: string;\n  model: CartesianModel;\n  root: React.RefObject<HTMLElement | null>;\n  series: LineAreaChartProps[\"series\"];\n  showLegend: boolean;\n  stack: boolean;\n  width: number;\n  words: CartesianChartLabels;\n  xAxis: boolean;\n  yAxis: boolean;\n}): React.ReactElement {\n  const svg = React.useRef<SVGSVGElement>(null);\n  const gradient = React.useId();\n  const { rows } = model;\n\n  // The value axis the data asks for: a nice domain with ticks on its ends\n  // (seven at most), for a plot as tall as the chart less its labels.\n  const plotHeight = cartesianLayout({\n    height,\n    legend: showLegend,\n    width,\n    xAxis,\n    yAxis,\n    yLabels: [],\n  }).plotHeight;\n  const wanted = valueScale(model.extent, [plotHeight, 0]);\n  const tickValues = valueTicks(wanted, plotHeight);\n  const tickLabels = chartTickLabels(tickValues, locale, formatValue);\n  const layout = useCartesianLayout({\n    height,\n    legend: showLegend,\n    width,\n    xAxis,\n    yAxis,\n    yLabels: tickLabels,\n  });\n\n  // Where each row sits along the x axis, in hundredths of a pixel, and the time\n  // scale that puts it there when the axis counts dates.\n  const { positions, timeAxis } = React.useMemo(() => {\n    const range: [number, number] = [0, layout.plotWidth];\n    if (model.time) {\n      const scale = timeScale(\n        rows.map((row) => row.x as Date),\n        range,\n      );\n      return {\n        positions: rows.map((row) => hundredths(scale(row.x as Date))),\n        timeAxis: scale,\n      };\n    }\n    const scale = pointScale(\n      rows.map((row) => row.id),\n      range,\n    );\n    return {\n      positions: rows.map((row) => hundredths(scale(row.id) ?? 0)),\n      timeAxis: null,\n    };\n  }, [model.time, rows, layout.plotWidth]);\n\n  // New data glides in: the curves morph into the new ones and the value axis\n  // moves with them. Data that cannot glide, and a change in the number of\n  // series, show at once; at rest the real points are drawn.\n  const runs = model.tops.map(runsOf);\n  const count = useSampleCount(layout.plotWidth);\n  const curves = morphCurves(model, stack);\n  const [low, high] = wanted.domain() as [number, number];\n  const target = curves\n    ? morphTarget(curves, positions, [low, high], curve, count)\n    : noValues;\n  const tweened = useTweenedValues(target, { container: root });\n  const moving = curves !== null && tweened !== target;\n  const valueAxis = valueScaleAt(\n    moving ? [tweened[0]!, tweened[1]!] : [low, high],\n    [layout.plotHeight, 0],\n  );\n  // A value as a height in the plot, in hundredths of a pixel.\n  const yScale = (value: number) => hundredths(valueAxis(value));\n\n  // The marks of each series as points, from the real data at rest and from the\n  // samples while they glide.\n  const drawn: Drawn[][] = series.map((_, place) =>\n    runs[place]!.map((run): Drawn => {\n      const first = positions[run[0]!]!;\n      const last = positions[run[run.length - 1]!]!;\n      const along = (heights: readonly number[]) =>\n        heights.map((value, step): ChartPoint => [\n          first + ((last - first) * step) / Math.max(1, heights.length - 1),\n          yScale(value),\n        ]);\n      if (moving && curves) {\n        const slot = curves.findIndex((item) => item.place === place);\n        // The top of the series is its first curve, and its base the next.\n        const part = (offset: number) =>\n          tweened.slice(\n            2 + (slot + offset) * count,\n            2 + (slot + offset + 1) * count,\n          );\n        return {\n          base: stack ? along(part(1)) : null,\n          top: along(part(0)),\n        };\n      }\n      const at = (heights: readonly (number | null)[]) =>\n        run.map((index): ChartPoint => [\n          positions[index]!,\n          yScale(heights[index]!),\n        ]);\n      return {\n        base: stack ? at(model.bases[place]!) : null,\n        top: at(model.tops[place]!),\n      };\n    }),\n  );\n\n  // The dots of the chart: one at every point of a line when `dots` is on, and\n  // always one for a point on its own, which has no line to show it. They sit at\n  // the real points, so they wait while the marks glide.\n  const marks = series.flatMap((item, place) =>\n    runs[place]!.flatMap((run, index) =>\n      run.length === 1 || dots\n        ? run.map((row, step) => ({\n            cx: positions[row]!,\n            cy: yScale(model.tops[place]![row]!),\n            key: `${item.key}-${index}-${step}`,\n            place,\n            row,\n          }))\n        : [],\n    ),\n  );\n\n  // The labels of the axes. Each is keyed, so a label that comes or goes\n  // switches at once while the others stay: the new labels show the moment the\n  // old ones are hidden, and never are two seen on top of each other. The x\n  // labels of an axis that new data changes are all replaced, even a date that\n  // both axes have, which sits in another place on the new one. The value\n  // labels stay by their value and glide with the axis, the grid lines with\n  // them; the ones that go are hidden where they stood, and the axis leaves out\n  // any that would touch another while it moves (`ChartValueAxis`).\n  const formatText = formatX ?? chartXFormatter(locale, \"short\");\n  const xLabels = useGlidingLabels({\n    container: root,\n    labels:\n      xAxis && !model.empty\n        ? axisLabels({ format: formatText, positions, rows, timeAxis })\n        : [],\n    signature: timeAxis\n      ? `${rows[0]?.id}|${rows[rows.length - 1]?.id}`\n      : rows.map((row) => row.id).join(\"|\"),\n  });\n  const yTicks = useGlidingTicks({\n    container: root,\n    length: layout.plotHeight,\n    place: yScale,\n    ticks:\n      (grid || yAxis) && !model.empty\n        ? tickValues.map((value, index) => ({\n            text: tickLabels[index]!,\n            value,\n          }))\n        : [],\n  });\n\n  // The item under the pointer is the row nearest to it along the x axis. A\n  // chart with no numbers has no items: nothing to point at, no focus stop.\n  const rowAt = (clientX: number): string | null => {\n    const box = svg.current?.getBoundingClientRect();\n    if (!box || !box.width || model.empty) return null;\n    const along =\n      ((clientX - box.x) * layout.width) / box.width - layout.margin.left;\n    if (along < -pointerSlack || along > layout.plotWidth + pointerSlack)\n      return null;\n    const index = nearestIndex(positions, along);\n    return index < 0 ? null : rows[index]!.id;\n  };\n  const interaction = useChartInteraction({\n    itemAt: (clientX) => rowAt(clientX),\n    keys: model.empty ? [] : rows.map((row) => row.id),\n  });\n  const activeIndex = rows.findIndex((row) => row.id === interaction.activeKey);\n  const active = activeIndex >= 0 ? rows[activeIndex] : undefined;\n  // The dots and the crosshair wait where they last were while they fade out.\n  const lastKey = useLastValue(interaction.activeKey);\n  const shownIndex = Math.max(\n    0,\n    rows.findIndex((row) => row.id === lastKey),\n  );\n\n  // The pill sits on the axis at the active row, and the labels it would cover\n  // fade out until it has passed.\n  const spot =\n    xAxis && !model.empty && active\n      ? { position: positions[activeIndex]!, text: formatText(active.x) }\n      : null;\n  const covered = useCoveredLabels(xLabels, spot, \"bottom\");\n\n  // While a point is active the strokes step back, an area with its fill, and the\n  // stretch around the point is drawn again at full strength on top of them. A\n  // series steps back as well while another one's entry has the mouse in the\n  // legend; either rule is enough, and the stretch of a series that the legend\n  // holds back stays at that level.\n  const dimmed = active !== undefined && !model.empty;\n  const steppedBack = (key: string) =>\n    dimmed || seriesSteppedBack(focusedKey, key);\n  // A dot steps back with its line: outside the highlighted stretch, one point\n  // either side of the active one, and wholly while the legend holds its series\n  // back, so that no dot stays in full colour on a line that has stepped back.\n  const dotSteppedBack = (mark: (typeof marks)[number]) =>\n    seriesSteppedBack(focusedKey, series[mark.place]!.key) ||\n    (dimmed && Math.abs(mark.row - activeIndex) > 1);\n  const dimKind = area ? \"area\" : \"line\";\n  const dimClassName = useChartDimClassName(\"series\");\n  const strokeWidth = area ? areaOutlineWidth : lineWidth;\n  // The dots ride the curves of their series from point to point.\n  const traces = drawn.map((own) =>\n    curveTrace(\n      own.map((part) => part.top),\n      curve,\n    ),\n  );\n  const lineDs = drawn.map((own) =>\n    own\n      .filter((part) => part.top.length > 1)\n      .map((part) => curvePath(part.top, curve))\n      .join(\" \"),\n  );\n\n  // The card sits beside the crosshair of the row, at the top of the plot.\n  const anchorFor = (key: string) => {\n    const index = rows.findIndex((row) => row.id === key);\n    const box = svg.current?.getBoundingClientRect();\n    if (index < 0 || !box || !box.width) return null;\n    const scale = box.width / layout.width;\n    return {\n      x: box.x + (layout.margin.left + positions[index]!) * scale,\n      y: box.y + layout.margin.top * scale,\n    };\n  };\n  const heading = active ? formatHeading(active.x) : undefined;\n  const tooltipRows = active\n    ? series.flatMap((item, place) => {\n        const value = model.values[place]![activeIndex];\n        return value === null || value === undefined\n          ? []\n          : [\n              {\n                color: colors[place]!,\n                key: item.key,\n                label: item.label,\n                value: formatNumber(value),\n              },\n            ];\n      })\n    : [];\n  const content = active && (\n    <ChartTooltipContent\n      heading={heading}\n      rolling={rollingNumbers}\n      rows={tooltipRows}\n    />\n  );\n\n  return (\n    <ChartTooltip\n      anchorFor={anchorFor}\n      announcement={active ? chartTooltipText(heading, tooltipRows) : undefined}\n      className={plotClassName}\n      content={content}\n      describedBy={describedBy}\n      description={description}\n      interaction={interaction}\n      keyboardHint={words.keyboardHint}\n      label={label}\n      roleDescription={words.roleDescription}\n    >\n      {/* The plot is as tall as the chart and as wide as its box, which the\n          chart kept at that size before it was drawn. It is a picture that\n          the focus stop describes, so it is hidden from assistive\n          technology. */}\n      <svg\n        aria-hidden=\"true\"\n        className=\"block w-full overflow-visible\"\n        height={layout.height}\n        ref={svg}\n        viewBox={`0 0 ${layout.width} ${layout.height}`}\n        width={layout.width}\n      >\n        {/* No SVG titles: the browser would show its own tooltip next to the\n              chart's. The label names the chart; the tooltip carries the values. */}\n        {area && fill === \"gradient\" && (\n          <defs>\n            {series.map((item, place) => (\n              <linearGradient\n                id={`${gradient}-${place}`}\n                key={item.key}\n                x1=\"0\"\n                x2=\"0\"\n                y1=\"0\"\n                y2=\"1\"\n              >\n                {areaGradientStops(\n                  drawn[place]!.flatMap((part) =>\n                    [...part.top, ...(part.base ?? [])].map(([, y]) => y),\n                  ).concat(stack ? [] : [yScale(0)]),\n                  yScale(0),\n                ).map((stop) => (\n                  <stop\n                    key={stop.offset}\n                    offset={stop.offset}\n                    stopColor={colors[place]}\n                    stopOpacity={stop.opacity}\n                  />\n                ))}\n              </linearGradient>\n            ))}\n          </defs>\n        )}\n        <g transform={`translate(${layout.margin.left} ${layout.margin.top})`}>\n          {grid && !model.empty && (\n            <ChartGrid\n              layout={layout}\n              positions={yTicks.map((tick) => tick.position)}\n              presences={yTicks.map((tick) => tick.presence)}\n            />\n          )}\n          {yAxis && !model.empty && (\n            <ChartValueAxis layout={layout} side=\"left\" ticks={yTicks} />\n          )}\n          {xAxis && !model.empty && (\n            <ChartCategoryAxis\n              fading={covered.fading}\n              labels={covered.labels}\n              layout={layout}\n              side=\"bottom\"\n              thin={false}\n            />\n          )}\n          {area &&\n            series.map((item, place) => {\n              const parts = drawn[place]!.filter((part) => part.top.length > 1);\n              return parts.length > 0 ? (\n                <path\n                  d={parts\n                    .map((part) => areaPath(part, curve, yScale(0)))\n                    .join(\" \")}\n                  data-slot=\"chart-area\"\n                  className={cn(\"sp-chart-area\", dimClassName)}\n                  fill={\n                    fill === \"gradient\"\n                      ? `url(#${gradient}-${place})`\n                      : colors[place]\n                  }\n                  fillOpacity={fill === \"solid\" ? 0.18 : undefined}\n                  key={item.key}\n                  opacity={chartDimOpacity(dimKind, steppedBack(item.key))}\n                />\n              ) : null;\n            })}\n          {series.map((item, place) =>\n            lineDs[place] ? (\n              <path\n                d={lineDs[place]}\n                data-slot=\"chart-line\"\n                className={cn(\"sp-chart-line\", dimClassName)}\n                fill=\"none\"\n                key={item.key}\n                opacity={chartDimOpacity(dimKind, steppedBack(item.key))}\n                stroke={colors[place]}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth={strokeWidth}\n              />\n            ) : null,\n          )}\n          {!model.empty && (\n            <ChartHighlight\n              band={dimmed ? highlightBand(positions, activeIndex) : null}\n              container={root}\n              height={layout.plotHeight}\n              strokes={series.flatMap((item, place) =>\n                lineDs[place]\n                  ? [\n                      {\n                        color: colors[place]!,\n                        d: lineDs[place],\n                        key: item.key,\n                        steppedBack: seriesSteppedBack(focusedKey, item.key),\n                        width: strokeWidth,\n                      },\n                    ]\n                  : [],\n              )}\n            />\n          )}\n          {marks.length > 0 && (\n            <g\n              className={\n                moving\n                  ? \"opacity-0\"\n                  : \"transition-opacity duration-(--motion-duration-micro) ease-(--motion-ease-state)\"\n              }\n            >\n              {marks.map((mark) => (\n                <circle\n                  className={cn(\"sp-chart-dot\", dimClassName)}\n                  cx={mark.cx}\n                  cy={mark.cy}\n                  data-slot=\"chart-dot\"\n                  fill={colors[mark.place]}\n                  key={mark.key}\n                  opacity={chartDimOpacity(dimKind, dotSteppedBack(mark))}\n                  r={dotRadius}\n                />\n              ))}\n            </g>\n          )}\n          {!model.empty && (\n            <>\n              <ChartCrosshair\n                container={root}\n                layout={layout}\n                position={active ? positions[activeIndex]! : null}\n              />\n              {series.map((item, place) => {\n                const value = model.tops[place]![shownIndex];\n                return value === null || value === undefined ? null : (\n                  <ChartActiveDot\n                    color={colors[place]!}\n                    container={root}\n                    cx={positions[shownIndex]!}\n                    trace={traces[place]}\n                    cy={yScale(value)}\n                    key={item.key}\n                    visible={active !== undefined && !moving}\n                  />\n                );\n              })}\n            </>\n          )}\n        </g>\n        {model.empty && <ChartEmptyState label={emptyLabel} layout={layout} />}\n      </svg>\n      <ChartAxisPill\n        container={root}\n        layout={layout}\n        rolling={rollingNumbers}\n        side=\"bottom\"\n        spot={spot}\n      />\n    </ChartTooltip>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-line-area.tsx"},{"path":"packages/ui/src/components/chart/chart-ring.ts","content":"/*\n * What the charts drawn as rings share: the circle a ring is drawn along, from\n * 12 o'clock clockwise, and the rule that fits a figure and its caption into the\n * hole of a ring. The Pie Chart's donut and the Radial Bar Chart's rings both\n * read them, so a share, a bar and a figure in the hole mean the same in each.\n */\n\nimport { estimateLabelWidth } from \"./chart-scales\";\n\n/**\n * The path of the circle of `radius` around `cx`, `cy`, from 12 o'clock\n * clockwise and back, left open. A ring is dashed along it, so a share or a\n * bar starts at 12 o'clock and runs clockwise; a closed circle would let the\n * dasher join the last dash to the first at the closing point.\n */\nexport function chartCirclePath(\n  cx: number,\n  cy: number,\n  radius: number,\n): string {\n  const top = cy - radius;\n  const bottom = cy + radius;\n  return `M ${cx} ${top} A ${radius} ${radius} 0 1 1 ${cx} ${bottom} A ${radius} ${radius} 0 1 1 ${cx} ${top}`;\n}\n\n/** Where a figure and its caption sit in the hole of a ring. */\nexport type ChartRingCenterFit = {\n  /** Whether the figure is written in compact notation, such as 1.2M. */\n  compact: boolean;\n  /** The text size of the figure, in px: one of the steps the chart offers. */\n  size: number;\n  /** The widest the caption may be, in percent of the hole's width: the chord of the hole at the caption's outer edge. */\n  captionWidth: number;\n};\n\n/**\n * The step a figure in the hole of a ring takes, and whether it is written in\n * compact notation: the first that fits, in the order of `steps` (largest\n * first), the full figure in the first `fullSteps` steps and then the compact\n * one in every step. A line fits when its corners stay inside the hole, less a\n * margin of 12 % of its radius from the ring (at least 4 px), with the figure\n * and the caption centred in the hole as one block. The widths are the kit's\n * estimate (`estimateLabelWidth`), so that the server and the client pick the\n * same step; it errs on the wide side. A figure that fits in no step takes the\n * smallest, compact.\n */\nexport function ringCenterFit({\n  caption,\n  captionGap = 0,\n  captionHeight,\n  compact,\n  figureHeight = (size) => size,\n  full,\n  fullSteps,\n  holeRadius,\n  steps,\n}: {\n  caption?: string | undefined;\n  /** The space between the figure and the caption, in px. */\n  captionGap?: number;\n  /** The height of the caption's line, in px. */\n  captionHeight: number;\n  /** The figure in compact notation. */\n  compact: string;\n  /** The height of the figure's line at a step, in px: the step itself by default. */\n  figureHeight?: (size: number) => number;\n  /** The figure in full. */\n  full: string;\n  /** How many of the largest steps keep the figure in full before it is written compact. */\n  fullSteps: number;\n  /** The radius of the hole, in px. */\n  holeRadius: number;\n  /** The text sizes of the figure in px, from the largest. */\n  steps: readonly number[];\n}): ChartRingCenterFit {\n  const hole = Math.max(0, holeRadius);\n  const room = Math.max(0, hole - Math.max(4, hole * 0.12));\n  // The width of a line whose corners sit `edge` px above or below the centre.\n  const chord = (edge: number) =>\n    edge >= room ? 0 : 2 * Math.sqrt(room * room - edge * edge);\n  const blockHeight = (size: number) =>\n    figureHeight(size) + (caption ? captionGap + captionHeight : 0);\n  const width = (text: string, size: number) =>\n    (estimateLabelWidth(text) * size) / 12;\n  const candidates = [\n    ...steps.slice(0, fullSteps).map((size) => ({ compact: false, size })),\n    ...steps.map((size) => ({ compact: true, size })),\n  ];\n  const smallest = steps[steps.length - 1] ?? 12;\n  const chosen = candidates.find(\n    ({ compact: short, size }) =>\n      width(short ? compact : full, size) <= chord(blockHeight(size) / 2),\n  ) ?? { compact: true, size: smallest };\n  const captionWidth =\n    hole > 0 ? (chord(blockHeight(chosen.size) / 2) / (2 * hole)) * 100 : 0;\n  return {\n    captionWidth: Math.round(captionWidth * 100) / 100,\n    compact: chosen.compact,\n    size: chosen.size,\n  };\n}\n","type":"registry:ui","target":"~/src/siteplane/components/chart/chart-ring.ts"},{"path":"packages/ui/src/components/chart/index.ts","content":"export * from \"./chart-curve\";\nexport * from \"./chart-data\";\nexport * from \"./chart-format\";\nexport * from \"./chart-model\";\nexport * from \"./chart-palette\";\nexport * from \"./chart-ring\";\nexport * from \"./chart-scales\";\nexport * from \"./chart-stack\";\nexport * from \"./chart-types\";\nexport * from \"./use-chart-width\";\nexport * from \"./chart-interaction\";\nexport * from \"./chart-legend\";\nexport * from \"./chart-tooltip\";\nexport * from \"./chart-axis-pill\";\nexport * from \"./chart-dimming\";\nexport * from \"./chart-highlight\";\nexport {\n  CHART_LABEL_COLUMN_GAP,\n  CHART_LABEL_HEIGHT,\n  CHART_LEGEND_BAND,\n  ChartActiveDot,\n  ChartCategoryAxis,\n  ChartCrosshair,\n  ChartEmptyState,\n  ChartGrid,\n  ChartValueAxis,\n  cartesianLayout,\n  useCartesianLayout,\n  useGeneration,\n  useGlidingLabels,\n  useGlidingTicks,\n  type CartesianLayout,\n  type ChartMargins,\n  type GlidingLabel,\n  type GlidingTick,\n} from \"./chart-cartesian\";\n","type":"registry:ui","target":"~/src/siteplane/components/chart/index.ts"}],"type":"registry:ui"}