{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"bar-chart","title":"Bar Chart","description":"Compare values across categories as columns or horizontal bars, side by side or stacked. Search terms: grouped, comparison, graph, analytics.","dependencies":["d3-scale@4.0.2"],"registryDependencies":["@siteplane/base","@siteplane/motion","@siteplane/chart"],"files":[{"path":"packages/ui/src/components/bar-chart/bar-chart.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"../../system/utils/cn\";\nimport {\n  ChartActiveDot,\n  ChartCategoryAxis,\n  ChartCrosshair,\n  ChartEmptyState,\n  ChartGrid,\n  ChartValueAxis,\n  cartesianLayout,\n  chartAxisLabelClassName,\n  chartHighlightedTextClassName,\n  useCartesianLayout,\n  useChartBox,\n  useGlidingLabels,\n  useGlidingTicks,\n  useLastValue,\n} from \"../chart/chart-cartesian\";\nimport { chartXKey } from \"../chart/chart-data\";\nimport {\n  CHART_DEFAULT_LOCALE,\n  chartLabels,\n  chartNumberFormatter,\n  chartTickLabels,\n  chartXFormatter,\n} from \"../chart/chart-format\";\nimport { useChartInteraction } from \"../chart/chart-interaction\";\nimport { ChartLegend, useChartSeriesFocus } from \"../chart/chart-legend\";\nimport {\n  buildCartesianModel,\n  cartesianSummary,\n  type CartesianModel,\n} from \"../chart/chart-model\";\nimport { seriesColors } from \"../chart/chart-palette\";\nimport {\n  bandScale,\n  valueScale,\n  valueScaleAt,\n  valueTicks,\n} from \"../chart/chart-scales\";\nimport { ChartAxisPill, useCoveredLabels } from \"../chart/chart-axis-pill\";\nimport {\n  chartDimOpacity,\n  seriesSteppedBack,\n  useChartDimClassName,\n} from \"../chart/chart-dimming\";\nimport {\n  ChartPlotPlaceholder,\n  ChartTooltip,\n  ChartTooltipContent,\n  chartTooltipText,\n} from \"../chart/chart-tooltip\";\nimport type {\n  CartesianChartLabels,\n  CartesianChartProps,\n  ChartXValue,\n} from \"../chart/chart-types\";\nimport { useTweenedRows, useTweenedValues } from \"../motion\";\nimport {\n  barPath,\n  barSlots,\n  barValueLabels,\n  barsAt,\n  categoryAt,\n  categoryBand,\n  categoryCentres,\n  categoryFractions,\n  categoryLabels,\n  fittingTicks,\n  glidingSlots,\n  hundredths,\n  valueLabelRoom,\n  valueLabelTexts,\n  visibleValueLabels,\n  type BandFraction,\n  type BarSlot,\n} from \"./bar-layout\";\n\nexport type BarChartProps = CartesianChartProps & {\n  /**\n   * `vertical` draws columns over a category axis along the bottom; `horizontal`\n   * puts the categories down the left side and the bars along them, the way a\n   * ranking reads. `xAxis` labels the categories and `yAxis` the values in\n   * either orientation, so a horizontal chart draws the first at the left and\n   * the second along the bottom.\n   */\n  orientation?: \"vertical\" | \"horizontal\";\n  /**\n   * Stacks the bars of a category on each other, in the order of the series:\n   * positive values up from 0 and negative ones down from it. Otherwise the\n   * bars stand side by side.\n   */\n  stacked?: boolean;\n  /**\n   * Writes the value of each bar at its end: above a column, below one under\n   * 0, beside a horizontal bar. A stack shows the sum of each sign at its outer\n   * end. The labels are muted 12 px text in the format of the tooltip, and a\n   * chart that has no room for all of them shows every k-th category's.\n   */\n  valueLabels?: boolean;\n  /**\n   * The x value of one category to set apart, such as a period that is still\n   * running: its bars take a hatched fill in their colour, and its label on the\n   * axis, and its value labels, the text colour.\n   */\n  highlight?: ChartXValue;\n};\n\n// The stripes of the hatched fill of a highlighted category: a soft stripe of\n// the series' colour every 6 px, 2 px wide, at 45 degrees, over a tint of that\n// colour. The tint is 40 % of the colour, and the stripe, 60 % of the colour\n// drawn over the tint, 76 % in all: half the step between a stripe and its gap\n// that full stripes on a 24 % tint had (Lukas, 15 October 2026), while the bar\n// as a whole stands out from the card as much as before and stays clearly\n// lighter than a plain bar. The tile counts in the units of the plot, so the\n// stripes stay put while a bar grows or shrinks across them.\nconst hatchPitch = 6;\nconst hatchStripe = 2;\nconst hatchTint = 40;\nconst hatchStripeStrength = 60;\n\n// A bar as the chart keeps it: its place and number, and the colours it keeps\n// even while it shrinks away after its series has gone.\ntype BarRow = {\n  id: string;\n  slot: BarSlot;\n  color: string;\n  negativeColor: string | undefined;\n};\n\n/**\n * One or several series of bars over categories or dates, side by side or\n * stacked, as columns or as horizontal bars, with a tooltip for the category\n * under the pointer and arrow-key navigation. The chart core draws its axes,\n * grid, tooltip and legend; new data glides in: a new bar grows from the 0\n * line, a bar that is gone shrinks away, and the value axis moves with the\n * bars. The bars are drawn once the chart has measured its width: until then,\n * on the server too, it renders the box of the plot at its full height, the\n * legend and what screen readers read. A chart that fills the height of its\n * box measures that height too, and keeps `minHeight` until it knows it.\n */\nexport function BarChart({\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 = CHART_DEFAULT_LOCALE,\n  palette = \"brand\",\n  legend = series.length > 1,\n  emptyLabel = \"No data\",\n  labels,\n  className,\n  ...plot\n}: BarChartProps): React.ReactElement {\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 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 bars.\n  const focus = useChartSeriesFocus(series.map((item) => item.key));\n  const words = chartLabels(labels);\n\n  // The rows of the chart and the span of its numbers, read as the Line and\n  // the Area Chart read theirs: a row for each category or date (dates in\n  // order), repeated ones kept apart, a missing value left out.\n  const model = React.useMemo(\n    () => buildCartesianModel({ data, series, stacked, x }),\n    [data, series, stacked, 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        \"sp-bar-chart\",\n        \"flex w-full flex-col\",\n        box.className,\n        className,\n      )}\n      data-height={box.dataHeight}\n      data-slot=\"bar-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: true,\n              yAxis: true,\n              yLabels: [],\n            }).height\n          }\n          label={label}\n          roleDescription={words.roleDescription}\n        />\n      ) : (\n        <BarPlot\n          {...plot}\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          stacked={stacked}\n          width={width}\n          words={words}\n          x={x}\n        />\n      )}\n      {showLegend && (\n        <ChartLegend\n          className=\"pt-3\"\n          focus={focus}\n          items={series.map((item, order) => ({\n            color: colors[order]!,\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 Bar Chart at its measured width: the bars, the axes, the grid,\n * the crosshair and the tooltip. It mounts once the chart knows its width, so\n * every part that glides starts at the place it has at that width.\n */\nfunction BarPlot({\n  colors,\n  describedBy,\n  description,\n  emptyLabel,\n  focusedKey,\n  formatHeading,\n  formatNumber,\n  formatValue,\n  formatX,\n  grid = true,\n  height,\n  highlight,\n  label,\n  locale,\n  model,\n  orientation = \"vertical\",\n  root,\n  series,\n  showLegend,\n  stacked,\n  valueLabels = false,\n  width,\n  words,\n  x,\n  xAxis = true,\n  yAxis = true,\n}: Pick<\n  BarChartProps,\n  \"grid\" | \"highlight\" | \"orientation\" | \"valueLabels\" | \"xAxis\" | \"yAxis\"\n> & {\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: BarChartProps[\"series\"];\n  showLegend: boolean;\n  stacked: boolean;\n  width: number;\n  words: CartesianChartLabels;\n  x: string;\n}): React.ReactElement {\n  const svg = React.useRef<SVGSVGElement>(null);\n  const horizontal = orientation === \"horizontal\";\n  const { rows } = model;\n  const formatText = formatX ?? chartXFormatter(locale, \"short\");\n\n  // A horizontal chart turns the axes: its values run along the bottom and its\n  // categories down the left side, and the layout reserves room for the labels\n  // of the side they are on.\n  const frame = {\n    height,\n    legend: showLegend,\n    width,\n    ...(horizontal ? { xAxis: yAxis, yAxis: xAxis } : { xAxis, yAxis }),\n  };\n  // The room the value labels need beyond the ends of the bars, at the start of\n  // the value axis (below a column under 0, before a horizontal bar under 0)\n  // and at its end. The value axis keeps it inside the plot, less what the\n  // margin beyond the end of the axis already gives, so that no label runs\n  // into the axis labels or out of the chart.\n  const labelRoom =\n    valueLabels && !model.empty\n      ? valueLabelRoom(\n          valueLabelTexts(model.values, stacked, formatNumber),\n          orientation,\n        )\n      : { end: 0, start: 0 };\n  // The value axis the data asks for: a nice domain with ticks on its ends\n  // (seven at most). A vertical axis is as long as the plot is tall,\n  // whatever its labels are, so it is drawn before the layout; a horizontal one\n  // is as long as the plot is wide, which the labels of the categories decide.\n  // `margin` is the room beyond the end of the axis: above a vertical one,\n  // after a horizontal one.\n  const axisFor = (length: number, margin: number) => {\n    const start = labelRoom.start;\n    const end = Math.max(0, labelRoom.end - margin);\n    const range: [number, number] = horizontal\n      ? [start, Math.max(start, length - end)]\n      : [Math.max(end, length - start), end];\n    const inner = Math.abs(range[1] - range[0]);\n    const wanted = valueScale(model.extent, range);\n    const values = horizontal\n      ? fittingTicks(wanted, inner, (ticks) =>\n          chartTickLabels(ticks, locale, formatValue),\n        )\n      : valueTicks(wanted, inner);\n    return {\n      labels: chartTickLabels(values, locale, formatValue),\n      range,\n      values,\n      wanted,\n    };\n  };\n  const base = cartesianLayout({ ...frame, yLabels: [] });\n  const vertical = horizontal\n    ? null\n    : axisFor(base.plotHeight, base.margin.top);\n  const layout = useCartesianLayout({\n    ...frame,\n    yLabels: vertical ? vertical.labels : rows.map((row) => formatText(row.x)),\n  });\n  const axis = vertical ?? axisFor(layout.plotWidth, layout.margin.right);\n  const valueLength = horizontal ? layout.plotWidth : layout.plotHeight;\n  const categoryLength = horizontal ? layout.plotHeight : layout.plotWidth;\n\n  // The bands of the categories, and the bars before they have a length.\n  const categoryScale = React.useMemo(\n    () =>\n      bandScale(\n        rows.map((row) => row.id),\n        [0, categoryLength],\n      ),\n    [rows, categoryLength],\n  );\n  const centres = categoryCentres(\n    categoryScale,\n    rows.map((row) => row.id),\n  );\n  const slots = React.useMemo(\n    () =>\n      barSlots({\n        categoryScale,\n        data: rows.map((row) => ({ ...row.datum, [x]: row.id })),\n        seriesKeys: series.map((item) => item.key),\n        stacked,\n        x,\n      }),\n    [categoryScale, rows, series, stacked, x],\n  );\n\n  // New data glides in. Each bar is told by its category and series: a bar that\n  // is new grows from the 0 line where it stands, one that is gone shrinks away\n  // where it stood and then leaves, the others move to their new value. The\n  // value axis moves with them, so grid lines and labels stay with the bars.\n  const entries = useTweenedRows(\n    slots.map((slot): BarRow => ({\n      color: colors[slot.order]!,\n      id: slot.key,\n      negativeColor: series[slot.order]?.negativeColor,\n      slot,\n    })),\n    (row) => row.slot.value,\n    { container: root },\n  );\n  // The bars of a category that stays glide to their new place and thickness\n  // when categories come, go or change order. The band of each category is\n  // tweened as fractions of the axis, which say where the category is among the\n  // others and not how wide the chart is, so a chart that is resized or measured\n  // after it mounts does not glide. A category that is new appears in its place\n  // and one that is gone stays in its place while its bars shrink.\n  const bands = React.useMemo(\n    () =>\n      rows.flatMap((row) => {\n        const band = categoryFractions(categoryScale, row.id);\n        return band ? [{ ...band, id: row.id }] : [];\n      }),\n    [categoryScale, rows],\n  );\n  const starts = useTweenedRows(bands, (band) => band.start, {\n    absent: (band) => band.start,\n    container: root,\n  });\n  const sizes = useTweenedRows(bands, (band) => band.size, {\n    absent: (band) => band.size,\n    container: root,\n  });\n  const sizeOf = new Map(sizes.map((entry) => [entry.key, entry]));\n  const moving = new Map<string, BandFraction>();\n  for (const start of starts) {\n    const size = sizeOf.get(start.key);\n    if (\n      size &&\n      !start.leaving &&\n      (start.value !== start.row.start || size.value !== size.row.size)\n    )\n      moving.set(start.key, { size: size.value, start: start.value });\n  }\n  const [low, high] = axis.wanted.domain() as [number, number];\n  const domain = [low, high];\n  const glidingDomain = useTweenedValues(domain, { container: root });\n  const valueAxis = valueScaleAt(\n    glidingDomain !== domain\n      ? [glidingDomain[0]!, glidingDomain[1]!]\n      : [low, high],\n    axis.range,\n  );\n  // A number as a position on the value axis, in hundredths of a pixel.\n  const at = (value: number) => hundredths(valueAxis(value));\n  const bars = barsAt(\n    glidingSlots(\n      entries.map((entry) => ({\n        leaving: entry.leaving,\n        slot: entry.row.slot,\n        value: entry.value,\n      })),\n      moving,\n      { count: series.length, length: categoryLength, stacked },\n    ),\n    { orientation, stacked, valueScale: valueAxis },\n  );\n\n  // The labels of the axes. Each is keyed, so that one that comes or goes fades\n  // through while the others stay: the old labels go out before the new ones\n  // come in, and never are two legible on top of each other. The labels of the\n  // categories are all replaced when the categories change; the labels of the\n  // value axis stay by their value and glide with the axis, the grid lines with\n  // them, and the ones that go fade where they stood.\n  // The category the chart sets apart, such as a period that is still running:\n  // the rows whose x value is the one it is given.\n  const highlightKey = highlight === undefined ? \"\" : chartXKey(highlight);\n  const highlighted = new Set(\n    highlightKey === \"\"\n      ? []\n      : rows\n          .filter((row) => chartXKey(row.x) === highlightKey)\n          .map((row) => row.id),\n  );\n  const categoryTicks = useGlidingLabels({\n    container: root,\n    labels:\n      xAxis && !model.empty\n        ? categoryLabels({\n            centres,\n            format: formatText,\n            rows,\n            side: horizontal ? \"left\" : \"bottom\",\n          }).map((tick) =>\n            highlighted.has(tick.id) ? { ...tick, highlighted: true } : tick,\n          )\n        : [],\n    signature: rows.map((row) => row.id).join(\"|\"),\n  });\n  const valueMarks = useGlidingTicks({\n    container: root,\n    length: valueLength,\n    place: at,\n    ticks:\n      (grid || yAxis) && !model.empty\n        ? axis.values.map((value, index) => ({\n            text: axis.labels[index]!,\n            value,\n          }))\n        : [],\n  });\n\n  // The item under the pointer is the category whose band it is in. A chart\n  // with no numbers has no items: nothing to point at, no focus stop.\n  const itemAt = (clientX: number, clientY: number): string | null => {\n    const box = svg.current?.getBoundingClientRect();\n    if (!box || !box.width || !box.height || model.empty) return null;\n    const along = horizontal\n      ? ((clientY - box.y) * layout.height) / box.height - layout.margin.top\n      : ((clientX - box.x) * layout.width) / box.width - layout.margin.left;\n    const index = categoryAt(centres, along, categoryLength);\n    return index < 0 ? null : rows[index]!.id;\n  };\n  const interaction = useChartInteraction({\n    axis: horizontal ? \"y\" : \"x\",\n    itemAt,\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\n  // The bars outside the active category step back, and so do the bars of every\n  // series but the focused one while the mouse is on an entry of the legend; a\n  // bar steps back when either rule says so.\n  const dimClassName = useChartDimClassName(\"mark\");\n\n  // The crosshair and the dots wait where they last were while they fade out. A\n  // dot sits at the end of its bar, so it waits for the bars to stop gliding.\n  const lastKey = useLastValue(interaction.activeKey);\n  const shownKey = rows.find((row) => row.id === lastKey)?.id ?? rows[0]?.id;\n  const gliding =\n    moving.size > 0 ||\n    entries.some(\n      (entry) => entry.leaving || entry.value !== entry.row.slot.value,\n    );\n\n  // The pill sits on the axis at the active category, and the labels it would\n  // cover fade out until it has passed.\n  const spot =\n    xAxis && !model.empty && active\n      ? { position: centres[activeIndex]!, text: formatText(active.x) }\n      : null;\n  const covered = useCoveredLabels(\n    categoryTicks,\n    spot,\n    horizontal ? \"left\" : \"bottom\",\n  );\n\n  // The card sits beside the category: to the right of the crosshair of a column\n  // chart, at the top of the plot, and to the right of the longest bar of a\n  // horizontal chart, at the middle of its category.\n  const anchorFor = (key: string) => {\n    const slot = categoryBand(categoryScale, key);\n    const box = svg.current?.getBoundingClientRect();\n    if (!slot || !box || !box.width) return null;\n    const scale = box.width / layout.width;\n    const own = bars.filter((bar) => bar.x === key);\n    const middle = slot.start + slot.size / 2;\n    return horizontal\n      ? {\n          x:\n            box.x +\n            (layout.margin.left +\n              Math.max(at(0), ...own.map((bar) => bar.x0 + bar.width))) *\n              scale,\n          y: box.y + (layout.margin.top + middle) * scale,\n        }\n      : {\n          x: box.x + (layout.margin.left + middle) * 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, order) => {\n        const value = model.values[order]![activeIndex];\n        return value === null || value === undefined\n          ? []\n          : [\n              {\n                color:\n                  value < 0 && item.negativeColor\n                    ? item.negativeColor\n                    : colors[order]!,\n                key: item.key,\n                label: item.label,\n                value: formatNumber(value),\n              },\n            ];\n      })\n    : [];\n  const content = active && (\n    <ChartTooltipContent heading={heading} rows={tooltipRows} />\n  );\n\n  // The colour each bar is drawn in, and the hatched fill of each colour that\n  // a bar of the highlighted category takes: one pattern per colour.\n  const colorOf = (bar: (typeof bars)[number], row: BarRow) =>\n    bar.value < 0 && row.negativeColor ? row.negativeColor : row.color;\n  const hatchId = React.useId();\n  const hatches = new Map<string, string>();\n  bars.forEach((bar, index) => {\n    const color = colorOf(bar, entries[index]!.row);\n    if (highlighted.has(bar.x) && !hatches.has(color))\n      hatches.set(color, `${hatchId}-hatch-${hatches.size}`);\n  });\n\n  // The value labels at the ends of the bars that stay, every k-th category's\n  // when they do not all fit. They wait while the bars glide, as the dots do,\n  // and fade in where the bars have come to rest.\n  const shownLabels =\n    valueLabels && !model.empty\n      ? visibleValueLabels(\n          barValueLabels(\n            bars.filter((_, index) => !entries[index]!.leaving),\n            { format: formatNumber, orientation, stacked },\n          ),\n          orientation,\n        )\n      : [];\n\n  return (\n    <ChartTooltip\n      align={horizontal ? \"center\" : \"start\"}\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        {hatches.size > 0 && (\n          <defs>\n            {[...hatches].map(([color, id]) => (\n              <pattern\n                height={hatchPitch}\n                id={id}\n                key={id}\n                patternTransform=\"rotate(45)\"\n                patternUnits=\"userSpaceOnUse\"\n                width={hatchPitch}\n              >\n                <rect\n                  fill={`color-mix(in oklab, ${color} ${hatchTint}%, transparent)`}\n                  height={hatchPitch}\n                  width={hatchPitch}\n                />\n                {/* Drawn over the whole tint rather than beside it, so the\n                    edges of a stripe leave no seam where two shapes meet. */}\n                <rect\n                  fill={`color-mix(in oklab, ${color} ${hatchStripeStrength}%, transparent)`}\n                  height={hatchPitch}\n                  width={hatchStripe}\n                />\n              </pattern>\n            ))}\n          </defs>\n        )}\n        <g transform={`translate(${layout.margin.left} ${layout.margin.top})`}>\n          {grid && !model.empty && (\n            <ChartGrid\n              direction={horizontal ? \"vertical\" : \"horizontal\"}\n              layout={layout}\n              positions={valueMarks.map((tick) => tick.position)}\n              presences={valueMarks.map((tick) => tick.presence)}\n            />\n          )}\n          {yAxis && !model.empty && (\n            <ChartValueAxis\n              layout={layout}\n              side={horizontal ? \"bottom\" : \"left\"}\n              ticks={valueMarks}\n            />\n          )}\n          {xAxis && !model.empty && (\n            <ChartCategoryAxis\n              fading={covered.fading}\n              labels={covered.labels}\n              layout={layout}\n              side={horizontal ? \"left\" : \"bottom\"}\n              thin={false}\n            />\n          )}\n          {/* The hairline is drawn before the bars, so a bar covers it: only\n                what lies between the end of the bar and the edge of the plot\n                shows. The dots stay above the bars. */}\n          {!model.empty && (\n            <ChartCrosshair\n              container={root}\n              direction={horizontal ? \"horizontal\" : \"vertical\"}\n              layout={layout}\n              position={active ? centres[activeIndex]! : null}\n            />\n          )}\n          {!model.empty &&\n            bars.map((bar, index) => {\n              const row = entries[index]!.row;\n              const color = colorOf(bar, row);\n              const hatch = highlighted.has(bar.x)\n                ? hatches.get(color)\n                : undefined;\n              return (\n                <path\n                  d={barPath(bar, orientation)}\n                  data-highlighted={hatch ? \"\" : undefined}\n                  data-slot=\"chart-bar\"\n                  fill={hatch ? `url(#${hatch})` : color}\n                  className={cn(\"sp-chart-bar\", dimClassName)}\n                  key={bar.key}\n                  opacity={chartDimOpacity(\n                    \"bar\",\n                    (active !== undefined && bar.x !== active.id) ||\n                      seriesSteppedBack(focusedKey, bar.series),\n                  )}\n                />\n              );\n            })}\n          {shownLabels.length > 0 && (\n            <g\n              aria-hidden=\"true\"\n              className={cn(\n                \"sp-chart-value-labels\",\n                chartAxisLabelClassName,\n                gliding\n                  ? \"opacity-0\"\n                  : \"transition-opacity duration-(--motion-duration-micro) ease-(--motion-ease-state)\",\n              )}\n              data-slot=\"chart-value-labels\"\n            >\n              {shownLabels.map((item) => (\n                <text\n                  className={\n                    highlighted.has(item.category)\n                      ? chartHighlightedTextClassName\n                      : undefined\n                  }\n                  dy={\n                    item.side === \"below\"\n                      ? \"0.71em\"\n                      : item.side === \"above\"\n                        ? undefined\n                        : \"0.32em\"\n                  }\n                  key={item.key}\n                  textAnchor={\n                    item.side === \"after\"\n                      ? \"start\"\n                      : item.side === \"before\"\n                        ? \"end\"\n                        : \"middle\"\n                  }\n                  x={item.x}\n                  y={item.y}\n                >\n                  {item.text}\n                </text>\n              ))}\n            </g>\n          )}\n          {!model.empty &&\n            !horizontal &&\n            bars.map((bar, index) => {\n              const row = entries[index]!.row;\n              return bar.x === shownKey ? (\n                <ChartActiveDot\n                  color={\n                    bar.value < 0 && row.negativeColor\n                      ? row.negativeColor\n                      : row.color\n                  }\n                  container={root}\n                  cx={hundredths(bar.x0 + bar.width / 2)}\n                  cy={bar.value < 0 ? bar.y0 + bar.height : bar.y0}\n                  // A dot is a series': it glides on to the next category.\n                  key={bar.series}\n                  visible={active !== undefined && !gliding}\n                />\n              ) : null;\n            })}\n        </g>\n        {model.empty && <ChartEmptyState label={emptyLabel} layout={layout} />}\n      </svg>\n      <ChartAxisPill\n        container={root}\n        layout={layout}\n        side={horizontal ? \"left\" : \"bottom\"}\n        spot={spot}\n      />\n    </ChartTooltip>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/bar-chart/bar-chart.tsx"},{"path":"packages/ui/src/components/bar-chart/bar-layout.ts","content":"/*\n * Where the bars of a Bar Chart sit. Three ideas are adapted from bklit UI's\n * chart components (MIT): the bars of one category share its band with a fixed\n * gap between them (bar.tsx), a bar has a width floor (series-bar-layout.ts),\n * and the category under the pointer is the band the pointer is in, held to the\n * ends of the plot (bar-chart.tsx). Everything else is Siteplane's own, on the\n * chart core's scales and stacking.\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 type { ScaleBand, ScaleLinear } from \"d3-scale\";\n\nimport {\n  CHART_LABEL_COLUMN_GAP,\n  CHART_LABEL_HEIGHT,\n} from \"../chart/chart-cartesian\";\nimport { chartValue, chartXKey } from \"../chart/chart-data\";\nimport { nearestIndex } from \"../chart/chart-interaction\";\nimport {\n  estimateLabelWidth,\n  labelGap,\n  labelStep,\n  valueTicks,\n} from \"../chart/chart-scales\";\nimport { stackSeries } from \"../chart/chart-stack\";\nimport type { ChartDatum, ChartXValue } from \"../chart/chart-types\";\n\n/** The space between the bars of one category, in px, as long as the category has room for it. */\nexport const BAR_GAP = 4;\n/**\n * The radius at the value end of a bar, in px: 8, as shadcn rounds its bars. It\n * is the kit's control radius, `--sp-rw-radius-control`, at the default md\n * radius. An SVG path cannot read a token, so the number is kept here in step\n * with it; a bar keeps it at every provider radius.\n */\nexport const BAR_RADIUS = 8;\n\n// The room beyond the plot, in px, in which the pointer still finds the first and the last category.\nconst pointerSlack = 16;\n\n/** A number in hundredths of a pixel, which keeps the markup short and the same on the server and the client. */\nexport const hundredths = (value: number) => Math.round(value * 100) / 100;\n\nexport type BarOrientation = \"vertical\" | \"horizontal\";\n\n/** One bar in pixels, in the plot: its top-left corner and its size. */\nexport type BarRect = {\n  /** `x|series`: the key of the bar, which stays with it while the data around it changes. */\n  key: string;\n  /** The key of its category. */\n  x: string;\n  series: string;\n  /** The number it stands for. */\n  value: number;\n  x0: number;\n  y0: number;\n  width: number;\n  height: number;\n  /**\n   * Which end is rounded: `end` is the value end, away from the 0 line, `start`\n   * the end at the 0 line. A bar rounds only its value end, and in a stack only\n   * the outer segment of each sign; the Bar Chart itself never asks for `start`.\n   */\n  rounded: \"start\" | \"end\" | \"none\";\n};\n\n/**\n * A bar before it has a length: its place across its category, which it keeps\n * while it lives, and the number it has right now. The numbers glide when new\n * data arrives, and the bars are laid out again at every step of the glide.\n */\nexport type BarSlot = {\n  key: string;\n  x: string;\n  series: string;\n  /** The place of the series among the series of the chart, which is the order of a stack. */\n  order: number;\n  value: number;\n  /** Where the bar starts across its category, in px from the start of the category axis. */\n  start: number;\n  /** How thick the bar is across its category. */\n  size: number;\n};\n\n/**\n * How thick the bars of a category are and where each starts inside its band.\n * A lone bar and stacked segments take the whole band, and side-by-side bars\n * share it with a gap, so the bars fill their band the way shadcn's do. In a\n * band too narrow for the gap the gap gives way first; a bar is never thinner\n * than 1 px, and bars that the band cannot hold at that thickness are centred\n * on it.\n */\nfunction thickness(band: number, count: number, stacked: boolean) {\n  if (stacked || count <= 1) {\n    const size = Math.max(1, band);\n    return {\n      offsets: Array.from({ length: count }, () => (band - size) / 2),\n      size,\n    };\n  }\n  let gap = BAR_GAP;\n  let size = (band - gap * (count - 1)) / count;\n  if (size < 1) {\n    size = 1;\n    gap = Math.max(0, (band - count) / (count - 1));\n  }\n  const first = (band - (size * count + gap * (count - 1))) / 2;\n  return {\n    offsets: Array.from(\n      { length: count },\n      (_, index) => first + index * (size + gap),\n    ),\n    size,\n  };\n}\n\n/**\n * Where the bar of the series at `order` sits in the band of its category, for\n * `count` series side by side or stacked: where it starts across the category\n * axis and how thick it is. The band is where the category is, in px.\n */\nexport function barPlace(\n  band: { start: number; size: number },\n  order: number,\n  count: number,\n  stacked: boolean,\n): { start: number; size: number } {\n  const { offsets, size } = thickness(band.size, count, stacked);\n  return { size, start: hundredths(band.start + (offsets[order] ?? 0)) };\n}\n\n/**\n * The bars of the data before any length: one for every finite value, in the\n * order of the data and then of the series. A category that the scale does not\n * know has none, and neither has a missing value, which leaves its slot empty.\n * Categories must be unique; the chart makes repeated ones unique first.\n */\nexport function barSlots({\n  data,\n  x,\n  seriesKeys,\n  stacked,\n  categoryScale,\n}: {\n  data: readonly ChartDatum[];\n  x: string;\n  seriesKeys: readonly string[];\n  stacked: boolean;\n  categoryScale: ScaleBand<string>;\n}): BarSlot[] {\n  const size = categoryScale.bandwidth();\n  const slots: BarSlot[] = [];\n  for (const datum of data) {\n    const category = chartXKey(datum[x]);\n    const start = categoryScale(category);\n    if (start === undefined) continue;\n    seriesKeys.forEach((series, order) => {\n      const value = chartValue(datum, series);\n      if (value === null) return;\n      slots.push({\n        ...barPlace({ size, start }, order, seriesKeys.length, stacked),\n        key: `${category}|${series}`,\n        order,\n        series,\n        value,\n        x: category,\n      });\n    });\n  }\n  return slots;\n}\n\n/** The band of a category along its axis as fractions of the length of the axis, so that it says where the category is among the others and not how wide the chart is. */\nexport type BandFraction = { start: number; size: number };\n\n// A fraction is kept to a billionth, so that the rounding noise of a chart that\n// is a little wider or narrower does not make it a different fraction.\nconst billionths = (value: number) => Math.round(value * 1e9) / 1e9;\n\n/**\n * Where the band of a category lies along its axis, as fractions of the axis. The\n * fractions depend on how many categories there are and where this one is, and\n * not on how wide the chart is: a chart that is resized keeps them. Null for a\n * category the scale does not know.\n */\nexport function categoryFractions(\n  scale: ScaleBand<string>,\n  key: string,\n): BandFraction | null {\n  const at = scale(key);\n  if (at === undefined) return null;\n  const [from = 0, to = 0] = scale.range();\n  const length = Math.abs(to - from);\n  if (length === 0) return { size: 0, start: 0 };\n  return {\n    size: billionths(scale.bandwidth() / length),\n    start: billionths((at - Math.min(from, to)) / length),\n  };\n}\n\n/**\n * The slots of the bars as they are drawn at this step of a glide. A bar that is\n * leaving stays where it stood, and a bar in a band that is moving is placed in\n * the band as it is now, so its place and thickness glide with the category; any\n * other bar is where its slot says. `bands` holds the categories whose band is\n * moving, as fractions of the `length` of the category axis, and `count` is the\n * number of series. Each slot gets the number it has at this step.\n */\nexport function glidingSlots(\n  entries: readonly { leaving: boolean; slot: BarSlot; value: number }[],\n  bands: ReadonlyMap<string, BandFraction>,\n  {\n    count,\n    length,\n    stacked,\n  }: { count: number; length: number; stacked: boolean },\n): BarSlot[] {\n  return entries.map(({ leaving, slot, value }) => {\n    const band = leaving ? undefined : bands.get(slot.x);\n    return band\n      ? {\n          ...slot,\n          ...barPlace(\n            { size: band.size * length, start: band.start * length },\n            slot.order,\n            count,\n            stacked,\n          ),\n          value,\n        }\n      : { ...slot, value };\n  });\n}\n\n// A name for the category in the rows that are stacked, which no series can have.\nconst categoryField = \"\\u0000category\";\n\n/**\n * Where each bar of a stack starts and ends on the value axis, as `stackSeries`\n * puts them, and which bars end a stack: the outer segment of each sign in each\n * category. A segment of 0 has no extent and takes the end from none.\n */\nfunction stackPlaces(slots: readonly BarSlot[]) {\n  const rows = new Map<string, ChartDatum>();\n  const orders = new Map<string, number>();\n  for (const slot of slots) {\n    const row = rows.get(slot.x) ?? { [categoryField]: slot.x };\n    row[slot.series] = slot.value;\n    rows.set(slot.x, row);\n    if (!orders.has(slot.series)) orders.set(slot.series, slot.order);\n  }\n  // The order of the series, and for series of one place the order of the list.\n  const keys = [...orders]\n    .sort((a, b) => a[1] - b[1])\n    .map(([series]) => series);\n  const places = new Map<string, { y0: number; y1: number }>();\n  const up = new Map<string, string>();\n  const down = new Map<string, string>();\n  for (const segment of stackSeries([...rows.values()], categoryField, keys)) {\n    const key = `${segment.x}|${segment.series}`;\n    places.set(key, { y0: segment.y0, y1: segment.y1 });\n    if (segment.value > 0) up.set(segment.x, key);\n    else if (segment.value < 0) down.set(segment.x, key);\n  }\n  return { ends: new Set([...up.values(), ...down.values()]), places };\n}\n\n/**\n * The bars at the numbers they have right now, in the order of the slots. Side\n * by side, a bar runs from the 0 line to its value; stacked, positive segments\n * pile up from the 0 line and negative ones down from it, in the order of the\n * series, so a stack stays whole whatever its segments are worth while they\n * glide. A bar of 0 has no extent. `valueScale` maps a number to a position in\n * the plot: from the top down for vertical bars, from the left for horizontal.\n */\nexport function barsAt(\n  slots: readonly BarSlot[],\n  {\n    stacked,\n    orientation,\n    valueScale,\n  }: {\n    stacked: boolean;\n    orientation: BarOrientation;\n    valueScale: ScaleLinear<number, number>;\n  },\n): BarRect[] {\n  const stack = stacked ? stackPlaces(slots) : null;\n  return slots.map((slot) => {\n    const place = stack?.places.get(slot.key);\n    const from = valueScale(place ? place.y0 : 0);\n    const to = valueScale(place ? place.y1 : slot.value);\n    const near = hundredths(Math.min(from, to));\n    const length = hundredths(Math.abs(to - from));\n    const rounded: BarRect[\"rounded\"] = (\n      stack ? stack.ends.has(slot.key) : slot.value !== 0\n    )\n      ? \"end\"\n      : \"none\";\n    const base = {\n      key: slot.key,\n      rounded,\n      series: slot.series,\n      value: slot.value,\n      x: slot.x,\n    };\n    return orientation === \"vertical\"\n      ? { ...base, height: length, width: slot.size, x0: slot.start, y0: near }\n      : { ...base, height: slot.size, width: length, x0: near, y0: slot.start };\n  });\n}\n\n/**\n * The bars of the data at rest, in pixels: a band per category and the bars\n * inside it, side by side or stacked. The value end of a bar is rounded, the\n * outer segment of each sign when stacked; a bar takes the thickness of its band\n * (shared by the series side by side, with `BAR_GAP` between the bars of one\n * category) and is at least 1 px thick. A negative value grows from the 0 line\n * away from the positive ones.\n */\nexport function layoutBars({\n  data,\n  x,\n  seriesKeys,\n  stacked,\n  orientation,\n  categoryScale,\n  valueScale,\n}: {\n  data: readonly ChartDatum[];\n  x: string;\n  seriesKeys: readonly string[];\n  stacked: boolean;\n  orientation: BarOrientation;\n  categoryScale: ScaleBand<string>;\n  valueScale: ScaleLinear<number, number>;\n}): BarRect[] {\n  return barsAt(barSlots({ categoryScale, data, seriesKeys, stacked, x }), {\n    orientation,\n    stacked,\n    valueScale,\n  });\n}\n\ntype Rounding = readonly [\n  topLeft: number,\n  topRight: number,\n  bottomRight: number,\n  bottomLeft: number,\n];\n\n// A rectangle with its own radius at each corner. A corner of 0 draws no arc.\nfunction roundedRectangle(\n  x: number,\n  y: number,\n  width: number,\n  height: number,\n  [topLeft, topRight, bottomRight, bottomLeft]: Rounding,\n): string {\n  const arc = (radius: number, toX: number, toY: number) =>\n    radius > 0 ? ` A${radius},${radius} 0 0 1 ${toX},${toY}` : \"\";\n  return [\n    `M${hundredths(x + topLeft)},${hundredths(y)}`,\n    ` H${hundredths(x + width - topRight)}`,\n    arc(topRight, hundredths(x + width), hundredths(y + topRight)),\n    ` V${hundredths(y + height - bottomRight)}`,\n    arc(\n      bottomRight,\n      hundredths(x + width - bottomRight),\n      hundredths(y + height),\n    ),\n    ` H${hundredths(x + bottomLeft)}`,\n    arc(bottomLeft, hundredths(x), hundredths(y + height - bottomLeft)),\n    ` V${hundredths(y + topLeft)}`,\n    arc(topLeft, hundredths(x + topLeft), hundredths(y)),\n    \" Z\",\n  ].join(\"\");\n}\n\n/**\n * The outline of a bar: a rectangle with the radius at the end that is rounded,\n * which is the far side of the 0 line for `end` (the top of a positive vertical\n * bar, the bottom of a negative one, the right and the left of horizontal ones)\n * and the side at the 0 line for `start`. The radius never exceeds a quarter of\n * the thickness, so a thin bar keeps a light curve, or the length of the bar.\n */\nexport function barPath(\n  { x0, y0, width, height, rounded, value }: BarRect,\n  orientation: BarOrientation,\n  radius: number = BAR_RADIUS,\n): string {\n  const vertical = orientation === \"vertical\";\n  const r =\n    rounded === \"none\"\n      ? 0\n      : Math.max(\n          0,\n          Math.min(\n            radius,\n            (vertical ? width : height) / 4,\n            vertical ? height : width,\n          ),\n        );\n  // The side of the value end, then of the start when that is the one rounded.\n  const positive = value >= 0;\n  const end = vertical\n    ? positive\n      ? \"top\"\n      : \"bottom\"\n    : positive\n      ? \"right\"\n      : \"left\";\n  const opposite = {\n    bottom: \"top\",\n    left: \"right\",\n    right: \"left\",\n    top: \"bottom\",\n  }[end];\n  const side = rounded === \"end\" ? end : opposite;\n  const corners: Rounding =\n    side === \"top\"\n      ? [r, r, 0, 0]\n      : side === \"bottom\"\n        ? [0, 0, r, r]\n        : side === \"left\"\n          ? [r, 0, 0, r]\n          : [0, r, r, 0];\n  return roundedRectangle(x0, y0, width, height, corners);\n}\n\n/** The space of a category along the category axis, in px: where it starts and how wide it is, a whole step between two categories, so that the spaces of all categories tile the plot. Its middle is where the tooltip of a category opens from the keyboard or a tap. Null for a category the scale does not know. */\nexport function categoryBand(\n  scale: ScaleBand<string>,\n  key: string,\n): { start: number; size: number } | null {\n  const at = scale(key);\n  if (at === undefined) return null;\n  const step = scale.step();\n  return {\n    size: hundredths(step),\n    start: hundredths(at - (step - scale.bandwidth()) / 2),\n  };\n}\n\n/** The centre of every category along the category axis, in px, in the order of `keys`. */\nexport function categoryCentres(\n  scale: ScaleBand<string>,\n  keys: readonly string[],\n): number[] {\n  return keys.map((key) =>\n    hundredths((scale(key) ?? 0) + scale.bandwidth() / 2),\n  );\n}\n\n/**\n * The category under a point along the category axis: the one whose centre is\n * nearest, which is the band the point is in, for a point anywhere in the plot\n * and a little beyond its ends. -1 for a point further out, and for no category.\n */\nexport function categoryAt(\n  centres: readonly number[],\n  along: number,\n  length: number,\n): number {\n  if (along < -pointerSlack || along > length + pointerSlack) return -1;\n  return nearestIndex(centres, along);\n}\n\n/** A label of the category axis: where it sits and what it says. */\nexport type CategoryLabel = { id: string; position: number; text: string };\n\n/**\n * The labels of the category axis that are shown: every k-th label, one k for the\n * whole axis, the smallest that leaves none on top of another, so the labels keep\n * an even rhythm however many categories there are. Along the bottom they are as\n * wide as their text; down the left side as tall as a line.\n */\nexport function categoryLabels({\n  centres,\n  rows,\n  side,\n  format,\n}: {\n  centres: readonly number[];\n  rows: readonly { id: string; x: ChartXValue }[];\n  side: \"bottom\" | \"left\";\n  format: (value: ChartXValue) => string;\n}): CategoryLabel[] {\n  const labels = rows.map((row, index) => ({\n    id: row.id,\n    position: centres[index] ?? 0,\n    text: format(row.x),\n  }));\n  const step = labelStep(\n    labels.map((label) => label.position),\n    labels.map((label) =>\n      side === \"bottom\" ? estimateLabelWidth(label.text) : CHART_LABEL_HEIGHT,\n    ),\n    side === \"bottom\" ? labelGap : CHART_LABEL_COLUMN_GAP,\n  );\n  return labels.filter((_, index) => index % step === 0);\n}\n\n/** The space between the end of a bar and its value label, in px. */\nexport const BAR_VALUE_LABEL_OFFSET = 6;\n// The least space between two value labels, in px.\nconst valueLabelGap = 4;\n\n/** A value label as it is drawn: what it says and the point its text hangs from. */\nexport type BarValueLabel = {\n  /** The key of its bar, or of its stack and the sign it sums. */\n  key: string;\n  /** The key of its category. */\n  category: string;\n  text: string;\n  x: number;\n  y: number;\n  /**\n   * Where the text sits from its point: above or below the end of a column,\n   * after or before the end of a horizontal bar, by the offset.\n   */\n  side: \"above\" | \"below\" | \"after\" | \"before\";\n};\n\n/**\n * The texts of the value labels the data asks for, by sign: the value of every\n * bar, or in a stack the sum of each sign in each category that has one. A\n * value of 0 counts as positive, so it is written above the 0 line. The chart\n * keeps room for them before it lays out its bars.\n */\nexport function valueLabelTexts(\n  values: readonly (readonly (number | null)[])[],\n  stacked: boolean,\n  format: (value: number) => string,\n): { positive: string[]; negative: string[] } {\n  const positive: string[] = [];\n  const negative: string[] = [];\n  const rowCount = Math.max(0, ...values.map((own) => own.length));\n  for (let row = 0; row < rowCount; row++) {\n    const own = values\n      .map((series) => series[row])\n      .filter((value): value is number => typeof value === \"number\");\n    if (!stacked) {\n      for (const value of own)\n        (value >= 0 ? positive : negative).push(format(value));\n      continue;\n    }\n    const up = own.filter((value) => value >= 0);\n    const down = own.filter((value) => value < 0);\n    if (up.length > 0) positive.push(format(up.reduce((a, b) => a + b, 0)));\n    if (down.length > 0) negative.push(format(down.reduce((a, b) => a + b, 0)));\n  }\n  return { negative, positive };\n}\n\n/**\n * The room value labels need beyond the ends of the bars, in px, at the start\n * of the value axis (where negative bars end: the bottom of a column chart, the\n * left of a horizontal one) and at its end (where positive bars end): the\n * offset and a line of text for columns, the offset and the widest label for\n * horizontal bars. None on a side that no label is written to.\n */\nexport function valueLabelRoom(\n  texts: { positive: readonly string[]; negative: readonly string[] },\n  orientation: BarOrientation,\n): { start: number; end: number } {\n  const need = (side: readonly string[]) =>\n    side.length === 0\n      ? 0\n      : BAR_VALUE_LABEL_OFFSET +\n        (orientation === \"vertical\"\n          ? CHART_LABEL_HEIGHT\n          : Math.ceil(Math.max(...side.map(estimateLabelWidth))));\n  return { end: need(texts.positive), start: need(texts.negative) };\n}\n\n/**\n * The value labels of the bars as they are drawn: the value of each bar at its\n * end, centred on a column and beside a horizontal bar, or in a stack the sum\n * of each sign at the end of its outer segment. A positive value is written\n * beyond the far end of its bar, a negative one below or before it.\n */\nexport function barValueLabels(\n  bars: readonly BarRect[],\n  {\n    format,\n    orientation,\n    stacked,\n  }: {\n    format: (value: number) => string;\n    orientation: BarOrientation;\n    stacked: boolean;\n  },\n): BarValueLabel[] {\n  const vertical = orientation === \"vertical\";\n  const label = (\n    key: string,\n    category: string,\n    value: number,\n    end: number,\n    across: number,\n  ): BarValueLabel => {\n    const positive = value >= 0;\n    const beyond = hundredths(\n      end + (positive === vertical ? -1 : 1) * BAR_VALUE_LABEL_OFFSET,\n    );\n    return {\n      category,\n      key,\n      side: vertical\n        ? positive\n          ? \"above\"\n          : \"below\"\n        : positive\n          ? \"after\"\n          : \"before\",\n      text: format(value),\n      x: vertical ? across : beyond,\n      y: vertical ? beyond : across,\n    };\n  };\n  const middle = (bar: BarRect) =>\n    hundredths(vertical ? bar.x0 + bar.width / 2 : bar.y0 + bar.height / 2);\n  // The far end of a bar from the 0 line, along the value axis.\n  const far = (bar: BarRect) =>\n    vertical\n      ? bar.value >= 0\n        ? bar.y0\n        : bar.y0 + bar.height\n      : bar.value >= 0\n        ? bar.x0 + bar.width\n        : bar.x0;\n  if (!stacked)\n    return bars.map((bar) =>\n      label(bar.key, bar.x, bar.value, far(bar), middle(bar)),\n    );\n  const stacks = new Map<string, { down: BarRect[]; up: BarRect[] }>();\n  for (const bar of bars) {\n    const stack = stacks.get(bar.x) ?? { down: [], up: [] };\n    (bar.value >= 0 ? stack.up : stack.down).push(bar);\n    stacks.set(bar.x, stack);\n  }\n  return [...stacks].flatMap(([category, stack]) =>\n    ([\"up\", \"down\"] as const).flatMap((sign) => {\n      const own = stack[sign];\n      if (own.length === 0) return [];\n      const ends = own.map(far);\n      // The outer end of the stack: furthest from the 0 line on its side.\n      const end =\n        (sign === \"up\") === vertical ? Math.min(...ends) : Math.max(...ends);\n      const sum = own.reduce((total, bar) => total + bar.value, 0);\n      return [\n        label(`${category}|${sign}`, category, sum, end, middle(own[0]!)),\n      ];\n    }),\n  );\n}\n\n/**\n * The value labels that are drawn: every k-th category keeps its labels, one k\n * for the whole chart, the smallest for which no two labels touch, as the axis\n * keeps every k-th label. None when two labels of one category touch, as on\n * side by side bars too thin for their labels. `labels` are in the order of\n * the categories along their axis.\n */\nexport function visibleValueLabels(\n  labels: readonly BarValueLabel[],\n  orientation: BarOrientation,\n): BarValueLabel[] {\n  const vertical = orientation === \"vertical\";\n  const along = (label: BarValueLabel) => (vertical ? label.x : label.y);\n  const size = (label: BarValueLabel) =>\n    vertical ? estimateLabelWidth(label.text) : CHART_LABEL_HEIGHT;\n  const categories = new Map<string, BarValueLabel[]>();\n  for (const label of labels) {\n    const own = categories.get(label.category) ?? [];\n    own.push(label);\n    categories.set(label.category, own);\n  }\n  for (const own of categories.values())\n    for (const side of [\"above\", \"below\", \"after\", \"before\"] as const) {\n      const row = own\n        .filter((label) => label.side === side)\n        .sort((a, b) => along(a) - along(b));\n      for (let index = 1; index < row.length; index++) {\n        const before = row[index - 1]!;\n        const label = row[index]!;\n        if (\n          along(label) - along(before) <\n          (size(label) + size(before)) / 2 + valueLabelGap\n        )\n          return [];\n      }\n    }\n  // Each category as one stretch along the category axis.\n  const stretches = [...categories.values()].map((own) => {\n    const start = Math.min(\n      ...own.map((label) => along(label) - size(label) / 2),\n    );\n    const end = Math.max(...own.map((label) => along(label) + size(label) / 2));\n    return { centre: (start + end) / 2, size: end - start };\n  });\n  const step = labelStep(\n    stretches.map((stretch) => stretch.centre),\n    stretches.map((stretch) => stretch.size),\n    valueLabelGap,\n  );\n  const kept = new Set(\n    [...categories.keys()].filter((_, index) => index % step === 0),\n  );\n  return labels.filter((label) => kept.has(label.category));\n}\n\n/**\n * The ticks of a value axis that runs along the bottom, where a label takes the\n * room of its text: the ticks the axis has, or every k-th of them, the smallest\n * k for which no two labels touch. `write` gives the labels of a set of ticks.\n * The ticks that stay are still on the steps of the axis, so they keep the grid\n * lines on the places of the nice domain.\n */\nexport function fittingTicks(\n  scale: ScaleLinear<number, number>,\n  length: number,\n  write: (ticks: readonly number[]) => string[],\n): number[] {\n  const ticks = valueTicks(scale, length);\n  const step = labelStep(\n    ticks.map(scale),\n    write(ticks).map(estimateLabelWidth),\n  );\n  const kept = ticks.filter((_, index) => index % step === 0);\n  // An axis keeps its two ends when every other tick has to go.\n  return kept.length >= 2 || ticks.length < 2\n    ? kept\n    : [ticks[0]!, ticks[ticks.length - 1]!];\n}\n","type":"registry:ui","target":"~/src/siteplane/components/bar-chart/bar-layout.ts"},{"path":"packages/ui/src/components/bar-chart/index.ts","content":"export * from \"./bar-chart\";\n","type":"registry:ui","target":"~/src/siteplane/components/bar-chart/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/bar-chart.md","categories":["Charts"],"meta":{"avoidWhen":["Avoid for a trend over dozens of points in time; a Line Chart or an Area Chart reads better beyond about 30 bars.","Avoid for readers who must read exact values: the axis gives a rough scale and the tooltip one category at a time; use a Table.","Avoid for more than eight series; both palettes repeat after the eighth."],"description":"Compare values across categories as columns or horizontal bars, side by side or stacked.","knownLimitations":["The plot is one focus stop; arrow keys, Home and End step through the categories (up and down for horizontal bars). There are no click or selection props, and the legend does not toggle series: a mouse over an entry only steps the other series back.","A missing value (null, undefined or not a number) leaves its bar out and the series out of the tooltip for that category; a bar of 0 has no length.","A bar takes the whole band of its category, which is 80% of the space the category has, so a chart with few categories has wide bars. The bars of a category share the band with 4 px between them, and no bar is thinner than 1 px: in a narrow chart with many categories the gap gives way first.","Only the value end of a bar is rounded, by 8 px or, on a thin bar, by a quarter of its thickness, and in a stack only the outer segment of each sign.","A stack piles positive values up from 0 and negative values down from it in the order of the series, and a missing value adds nothing. The tooltip shows each series' own value, not the sum.","A series can name a second color for its bars below 0 (negativeColor); without it every bar keeps the color of its series.","A series that sets no color takes a shade of the brand color, from strong to light and spread over the number of series (palette brand, the default); palette categorical gives each the chart colors --chart-1 to --chart-8 in order instead. A color that a series sets always wins, and both palettes start over from the ninth series.","The value axis always includes 0 and has no props of its own for its range.","Dates are categories. They must be Date objects; the chart shows them in date order and writes them in UTC, so use dates at midnight UTC (new Date('2026-01-05')) for days. The category axis shows every k-th label, one k for the whole axis, so a narrow chart shows fewer of them.","In a horizontal chart xAxis still labels the categories and yAxis the values, now at the left and along the bottom. The labels at the left take at most 40% of the width, and a longer label ends there in an ellipsis. The tooltip and the live region that reads it to screen readers name the category in full.","When the data changes the bars glide to their new values and the value axis moves with them; a bar that is new grows from the 0 line and one that is gone shrinks away. A bar is told by its category and series, so a category must keep the same text or date from one data set to the next. When categories come, go or change order, the bars of the categories that stay glide to their new place and thickness, a new bar grows from the 0 line where it stands and a bar that is gone shrinks where it stood. Axis labels that the new data brings or takes away fade through: the old ones are out before the new ones come in. A change in the number of series, or between side by side and stacked, shows at once.","Several series share one value axis; a second axis is not provided.","valueLabels writes each bar's value at its end in the format of the tooltip, or the sum of each sign of a stack. When the labels do not all fit, every k-th category keeps its labels, one k for the whole chart; side by side bars too thin for their labels show none. The labels are hidden while the bars glide.","highlight sets one category apart, such as a period that is still running: its bars take a hatched fill of their own color, and its axis label and value labels take the text color. It is a look only: the tooltip and screen readers read the category as any other, so name it in the data, such as Today.","height=\"fill\" makes the chart as tall as its box, which takes its height from its container (a flex item that grows, a grid cell, or a box with a height) and never from the chart; minHeight sets the least height, 240 px by default. Until the box is measured, on the server too, the chart is minHeight tall."],"providerNotes":["The tooltip comes from the installed Tooltip. The chart opens and closes it itself, so it needs no TooltipProvider.","The glide uses the installed Motion component and its tokens. SiteplaneMotionProvider preset=\"reduced\" and the system setting show new data at once."],"releaseStatus":"stable","searchTerms":["bar-chart","bar","chart","column","stacked","grouped","horizontal","comparison","graph","analytics"],"useWhen":["Use when the values of a few to a few dozen categories should be compared, side by side, stacked or as a ranking, with a tooltip for each category."]}}