// ~/src/siteplane/components/bar-chart/bar-chart.tsx "use client"; import * as React from "react"; import { cn } from "../../system/utils/cn"; import { ChartActiveDot, ChartCategoryAxis, ChartCrosshair, ChartEmptyState, ChartGrid, ChartValueAxis, cartesianLayout, chartAxisLabelClassName, chartHighlightedTextClassName, useCartesianLayout, useChartBox, useGlidingLabels, useGlidingTicks, useLastValue, } from "../chart/chart-cartesian"; import { chartXKey } from "../chart/chart-data"; import { CHART_DEFAULT_LOCALE, chartLabels, chartNumberFormatter, chartTickLabels, chartXFormatter, } from "../chart/chart-format"; import { useChartInteraction } from "../chart/chart-interaction"; import { ChartLegend, useChartSeriesFocus } from "../chart/chart-legend"; import { buildCartesianModel, cartesianSummary, type CartesianModel, } from "../chart/chart-model"; import { seriesColors } from "../chart/chart-palette"; import { bandScale, valueScale, valueScaleAt, valueTicks, } from "../chart/chart-scales"; import { ChartAxisPill, useCoveredLabels } from "../chart/chart-axis-pill"; import { chartDimOpacity, seriesSteppedBack, useChartDimClassName, } from "../chart/chart-dimming"; import { ChartPlotPlaceholder, ChartTooltip, ChartTooltipContent, chartTooltipText, } from "../chart/chart-tooltip"; import type { CartesianChartLabels, CartesianChartProps, ChartXValue, } from "../chart/chart-types"; import { useTweenedRows, useTweenedValues } from "../motion"; import { barPath, barSlots, barValueLabels, barsAt, categoryAt, categoryBand, categoryCentres, categoryFractions, categoryLabels, fittingTicks, glidingSlots, hundredths, valueLabelRoom, valueLabelTexts, visibleValueLabels, type BandFraction, type BarSlot, } from "./bar-layout"; export type BarChartProps = CartesianChartProps & { /** * `vertical` draws columns over a category axis along the bottom; `horizontal` * puts the categories down the left side and the bars along them, the way a * ranking reads. `xAxis` labels the categories and `yAxis` the values in * either orientation, so a horizontal chart draws the first at the left and * the second along the bottom. */ orientation?: "vertical" | "horizontal"; /** * Stacks the bars of a category on each other, in the order of the series: * positive values up from 0 and negative ones down from it. Otherwise the * bars stand side by side. */ stacked?: boolean; /** * Writes the value of each bar at its end: above a column, below one under * 0, beside a horizontal bar. A stack shows the sum of each sign at its outer * end. The labels are muted 12 px text in the format of the tooltip, and a * chart that has no room for all of them shows every k-th category's. */ valueLabels?: boolean; /** * The x value of one category to set apart, such as a period that is still * running: its bars take a hatched fill in their colour, and its label on the * axis, and its value labels, the text colour. */ highlight?: ChartXValue; }; // The stripes of the hatched fill of a highlighted category: a soft stripe of // the series' colour every 6 px, 2 px wide, at 45 degrees, over a tint of that // colour. The tint is 40 % of the colour, and the stripe, 60 % of the colour // drawn over the tint, 76 % in all: half the step between a stripe and its gap // that full stripes on a 24 % tint had (Lukas, 15 October 2026), while the bar // as a whole stands out from the card as much as before and stays clearly // lighter than a plain bar. The tile counts in the units of the plot, so the // stripes stay put while a bar grows or shrinks across them. const hatchPitch = 6; const hatchStripe = 2; const hatchTint = 40; const hatchStripeStrength = 60; // A bar as the chart keeps it: its place and number, and the colours it keeps // even while it shrinks away after its series has gone. type BarRow = { id: string; slot: BarSlot; color: string; negativeColor: string | undefined; }; /** * One or several series of bars over categories or dates, side by side or * stacked, as columns or as horizontal bars, with a tooltip for the category * under the pointer and arrow-key navigation. The chart core draws its axes, * grid, tooltip and legend; new data glides in: a new bar grows from the 0 * line, a bar that is gone shrinks away, and the value axis moves with the * bars. The bars are drawn once the chart has measured its width: until then, * on the server too, it renders the box of the plot at its full height, the * legend and what screen readers read. A chart that fills the height of its * box measures that height too, and keeps `minHeight` until it knows it. */ export function BarChart({ stacked = false, data, x, series, label, "aria-describedby": describedBy, height: givenHeight, minHeight, formatValue, formatX, locale = CHART_DEFAULT_LOCALE, palette = "brand", legend = series.length > 1, emptyLabel = "No data", labels, className, ...plot }: BarChartProps): React.ReactElement { const root = React.useRef(null); // The chart core sizes the chart: its width, and its height in px or from // the box it fills. const box = useChartBox(root, { height: givenHeight, minHeight }); const { height, width } = box; const colors = seriesColors(series, palette); const showLegend = legend && series.length > 0; // The series whose entry the mouse is over in the legend: the chart owns it // and hands it to the legend and to its own bars. const focus = useChartSeriesFocus(series.map((item) => item.key)); const words = chartLabels(labels); // The rows of the chart and the span of its numbers, read as the Line and // the Area Chart read theirs: a row for each category or date (dates in // order), repeated ones kept apart, a missing value left out. const model = React.useMemo( () => buildCartesianModel({ data, series, stacked, x }), [data, series, stacked, x], ); const formatHeading = formatX ?? chartXFormatter(locale, "long"); const formatNumber = formatValue ?? chartNumberFormatter(locale); const description = cartesianSummary({ emptyLabel, formatValue: formatNumber, formatX: formatHeading, labels: words, model, series, }); return ( // The figure holds the plot and the legend. The focus stop inside is the // chart for assistive technology, named by the label alone.
{width === null ? ( ) : ( )} {showLegend && ( ({ color: colors[order]!, key: item.key, label: item.label, }))} layout="row" /> )}
); } // The focus stop that holds the plot, and the box of the plot before it is drawn. const plotClassName = "relative block w-full rounded-lg"; /** * The plot of a Bar Chart at its measured width: the bars, the axes, the grid, * the crosshair and the tooltip. It mounts once the chart knows its width, so * every part that glides starts at the place it has at that width. */ function BarPlot({ colors, describedBy, description, emptyLabel, focusedKey, formatHeading, formatNumber, formatValue, formatX, grid = true, height, highlight, label, locale, model, orientation = "vertical", root, series, showLegend, stacked, valueLabels = false, width, words, x, xAxis = true, yAxis = true, }: Pick< BarChartProps, "grid" | "highlight" | "orientation" | "valueLabels" | "xAxis" | "yAxis" > & { colors: readonly string[]; describedBy: string | undefined; description: string; emptyLabel: string; focusedKey: string | null; formatHeading: (value: ChartXValue) => string; formatNumber: (value: number) => string; formatValue: ((value: number) => string) | undefined; formatX: ((value: ChartXValue) => string) | undefined; height: number; label: string; locale: string; model: CartesianModel; root: React.RefObject; series: BarChartProps["series"]; showLegend: boolean; stacked: boolean; width: number; words: CartesianChartLabels; x: string; }): React.ReactElement { const svg = React.useRef(null); const horizontal = orientation === "horizontal"; const { rows } = model; const formatText = formatX ?? chartXFormatter(locale, "short"); // A horizontal chart turns the axes: its values run along the bottom and its // categories down the left side, and the layout reserves room for the labels // of the side they are on. const frame = { height, legend: showLegend, width, ...(horizontal ? { xAxis: yAxis, yAxis: xAxis } : { xAxis, yAxis }), }; // The room the value labels need beyond the ends of the bars, at the start of // the value axis (below a column under 0, before a horizontal bar under 0) // and at its end. The value axis keeps it inside the plot, less what the // margin beyond the end of the axis already gives, so that no label runs // into the axis labels or out of the chart. const labelRoom = valueLabels && !model.empty ? valueLabelRoom( valueLabelTexts(model.values, stacked, formatNumber), orientation, ) : { end: 0, start: 0 }; // The value axis the data asks for: a nice domain with ticks on its ends // (seven at most). A vertical axis is as long as the plot is tall, // whatever its labels are, so it is drawn before the layout; a horizontal one // is as long as the plot is wide, which the labels of the categories decide. // `margin` is the room beyond the end of the axis: above a vertical one, // after a horizontal one. const axisFor = (length: number, margin: number) => { const start = labelRoom.start; const end = Math.max(0, labelRoom.end - margin); const range: [number, number] = horizontal ? [start, Math.max(start, length - end)] : [Math.max(end, length - start), end]; const inner = Math.abs(range[1] - range[0]); const wanted = valueScale(model.extent, range); const values = horizontal ? fittingTicks(wanted, inner, (ticks) => chartTickLabels(ticks, locale, formatValue), ) : valueTicks(wanted, inner); return { labels: chartTickLabels(values, locale, formatValue), range, values, wanted, }; }; const base = cartesianLayout({ ...frame, yLabels: [] }); const vertical = horizontal ? null : axisFor(base.plotHeight, base.margin.top); const layout = useCartesianLayout({ ...frame, yLabels: vertical ? vertical.labels : rows.map((row) => formatText(row.x)), }); const axis = vertical ?? axisFor(layout.plotWidth, layout.margin.right); const valueLength = horizontal ? layout.plotWidth : layout.plotHeight; const categoryLength = horizontal ? layout.plotHeight : layout.plotWidth; // The bands of the categories, and the bars before they have a length. const categoryScale = React.useMemo( () => bandScale( rows.map((row) => row.id), [0, categoryLength], ), [rows, categoryLength], ); const centres = categoryCentres( categoryScale, rows.map((row) => row.id), ); const slots = React.useMemo( () => barSlots({ categoryScale, data: rows.map((row) => ({ ...row.datum, [x]: row.id })), seriesKeys: series.map((item) => item.key), stacked, x, }), [categoryScale, rows, series, stacked, x], ); // New data glides in. Each bar is told by its category and series: a bar that // is new grows from the 0 line where it stands, one that is gone shrinks away // where it stood and then leaves, the others move to their new value. The // value axis moves with them, so grid lines and labels stay with the bars. const entries = useTweenedRows( slots.map((slot): BarRow => ({ color: colors[slot.order]!, id: slot.key, negativeColor: series[slot.order]?.negativeColor, slot, })), (row) => row.slot.value, { container: root }, ); // The bars of a category that stays glide to their new place and thickness // when categories come, go or change order. The band of each category is // tweened as fractions of the axis, which say where the category is among the // others and not how wide the chart is, so a chart that is resized or measured // after it mounts does not glide. A category that is new appears in its place // and one that is gone stays in its place while its bars shrink. const bands = React.useMemo( () => rows.flatMap((row) => { const band = categoryFractions(categoryScale, row.id); return band ? [{ ...band, id: row.id }] : []; }), [categoryScale, rows], ); const starts = useTweenedRows(bands, (band) => band.start, { absent: (band) => band.start, container: root, }); const sizes = useTweenedRows(bands, (band) => band.size, { absent: (band) => band.size, container: root, }); const sizeOf = new Map(sizes.map((entry) => [entry.key, entry])); const moving = new Map(); for (const start of starts) { const size = sizeOf.get(start.key); if ( size && !start.leaving && (start.value !== start.row.start || size.value !== size.row.size) ) moving.set(start.key, { size: size.value, start: start.value }); } const [low, high] = axis.wanted.domain() as [number, number]; const domain = [low, high]; const glidingDomain = useTweenedValues(domain, { container: root }); const valueAxis = valueScaleAt( glidingDomain !== domain ? [glidingDomain[0]!, glidingDomain[1]!] : [low, high], axis.range, ); // A number as a position on the value axis, in hundredths of a pixel. const at = (value: number) => hundredths(valueAxis(value)); const bars = barsAt( glidingSlots( entries.map((entry) => ({ leaving: entry.leaving, slot: entry.row.slot, value: entry.value, })), moving, { count: series.length, length: categoryLength, stacked }, ), { orientation, stacked, valueScale: valueAxis }, ); // The labels of the axes. Each is keyed, so that one that comes or goes fades // through while the others stay: the old labels go out before the new ones // come in, and never are two legible on top of each other. The labels of the // categories are all replaced when the categories change; the labels of the // value axis stay by their value and glide with the axis, the grid lines with // them, and the ones that go fade where they stood. // The category the chart sets apart, such as a period that is still running: // the rows whose x value is the one it is given. const highlightKey = highlight === undefined ? "" : chartXKey(highlight); const highlighted = new Set( highlightKey === "" ? [] : rows .filter((row) => chartXKey(row.x) === highlightKey) .map((row) => row.id), ); const categoryTicks = useGlidingLabels({ container: root, labels: xAxis && !model.empty ? categoryLabels({ centres, format: formatText, rows, side: horizontal ? "left" : "bottom", }).map((tick) => highlighted.has(tick.id) ? { ...tick, highlighted: true } : tick, ) : [], signature: rows.map((row) => row.id).join("|"), }); const valueMarks = useGlidingTicks({ container: root, length: valueLength, place: at, ticks: (grid || yAxis) && !model.empty ? axis.values.map((value, index) => ({ text: axis.labels[index]!, value, })) : [], }); // The item under the pointer is the category whose band it is in. A chart // with no numbers has no items: nothing to point at, no focus stop. const itemAt = (clientX: number, clientY: number): string | null => { const box = svg.current?.getBoundingClientRect(); if (!box || !box.width || !box.height || model.empty) return null; const along = horizontal ? ((clientY - box.y) * layout.height) / box.height - layout.margin.top : ((clientX - box.x) * layout.width) / box.width - layout.margin.left; const index = categoryAt(centres, along, categoryLength); return index < 0 ? null : rows[index]!.id; }; const interaction = useChartInteraction({ axis: horizontal ? "y" : "x", itemAt, keys: model.empty ? [] : rows.map((row) => row.id), }); const activeIndex = rows.findIndex((row) => row.id === interaction.activeKey); const active = activeIndex >= 0 ? rows[activeIndex] : undefined; // The bars outside the active category step back, and so do the bars of every // series but the focused one while the mouse is on an entry of the legend; a // bar steps back when either rule says so. const dimClassName = useChartDimClassName("mark"); // The crosshair and the dots wait where they last were while they fade out. A // dot sits at the end of its bar, so it waits for the bars to stop gliding. const lastKey = useLastValue(interaction.activeKey); const shownKey = rows.find((row) => row.id === lastKey)?.id ?? rows[0]?.id; const gliding = moving.size > 0 || entries.some( (entry) => entry.leaving || entry.value !== entry.row.slot.value, ); // The pill sits on the axis at the active category, and the labels it would // cover fade out until it has passed. const spot = xAxis && !model.empty && active ? { position: centres[activeIndex]!, text: formatText(active.x) } : null; const covered = useCoveredLabels( categoryTicks, spot, horizontal ? "left" : "bottom", ); // The card sits beside the category: to the right of the crosshair of a column // chart, at the top of the plot, and to the right of the longest bar of a // horizontal chart, at the middle of its category. const anchorFor = (key: string) => { const slot = categoryBand(categoryScale, key); const box = svg.current?.getBoundingClientRect(); if (!slot || !box || !box.width) return null; const scale = box.width / layout.width; const own = bars.filter((bar) => bar.x === key); const middle = slot.start + slot.size / 2; return horizontal ? { x: box.x + (layout.margin.left + Math.max(at(0), ...own.map((bar) => bar.x0 + bar.width))) * scale, y: box.y + (layout.margin.top + middle) * scale, } : { x: box.x + (layout.margin.left + middle) * scale, y: box.y + layout.margin.top * scale, }; }; const heading = active ? formatHeading(active.x) : undefined; const tooltipRows = active ? series.flatMap((item, order) => { const value = model.values[order]![activeIndex]; return value === null || value === undefined ? [] : [ { color: value < 0 && item.negativeColor ? item.negativeColor : colors[order]!, key: item.key, label: item.label, value: formatNumber(value), }, ]; }) : []; const content = active && ( ); // The colour each bar is drawn in, and the hatched fill of each colour that // a bar of the highlighted category takes: one pattern per colour. const colorOf = (bar: (typeof bars)[number], row: BarRow) => bar.value < 0 && row.negativeColor ? row.negativeColor : row.color; const hatchId = React.useId(); const hatches = new Map(); bars.forEach((bar, index) => { const color = colorOf(bar, entries[index]!.row); if (highlighted.has(bar.x) && !hatches.has(color)) hatches.set(color, `${hatchId}-hatch-${hatches.size}`); }); // The value labels at the ends of the bars that stay, every k-th category's // when they do not all fit. They wait while the bars glide, as the dots do, // and fade in where the bars have come to rest. const shownLabels = valueLabels && !model.empty ? visibleValueLabels( barValueLabels( bars.filter((_, index) => !entries[index]!.leaving), { format: formatNumber, orientation, stacked }, ), orientation, ) : []; return ( {/* The plot is as tall as the chart and as wide as its box, which the chart kept at that size before it was drawn. It is a picture that the focus stop describes, so it is hidden from assistive technology. */} ); } // ~/src/siteplane/components/bar-chart/bar-layout.ts /* * Where the bars of a Bar Chart sit. Three ideas are adapted from bklit UI's * chart components (MIT): the bars of one category share its band with a fixed * gap between them (bar.tsx), a bar has a width floor (series-bar-layout.ts), * and the category under the pointer is the band the pointer is in, held to the * ends of the plot (bar-chart.tsx). Everything else is Siteplane's own, on the * chart core's scales and stacking. * * MIT License * * Copyright (c) 2026 uixmat * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import type { ScaleBand, ScaleLinear } from "d3-scale"; import { CHART_LABEL_COLUMN_GAP, CHART_LABEL_HEIGHT, } from "../chart/chart-cartesian"; import { chartValue, chartXKey } from "../chart/chart-data"; import { nearestIndex } from "../chart/chart-interaction"; import { estimateLabelWidth, labelGap, labelStep, valueTicks, } from "../chart/chart-scales"; import { stackSeries } from "../chart/chart-stack"; import type { ChartDatum, ChartXValue } from "../chart/chart-types"; /** The space between the bars of one category, in px, as long as the category has room for it. */ export const BAR_GAP = 4; /** * The radius at the value end of a bar, in px: 8, as shadcn rounds its bars. It * is the kit's control radius, `--sp-rw-radius-control`, at the default md * radius. An SVG path cannot read a token, so the number is kept here in step * with it; a bar keeps it at every provider radius. */ export const BAR_RADIUS = 8; // The room beyond the plot, in px, in which the pointer still finds the first and the last category. const pointerSlack = 16; /** A number in hundredths of a pixel, which keeps the markup short and the same on the server and the client. */ export const hundredths = (value: number) => Math.round(value * 100) / 100; export type BarOrientation = "vertical" | "horizontal"; /** One bar in pixels, in the plot: its top-left corner and its size. */ export type BarRect = { /** `x|series`: the key of the bar, which stays with it while the data around it changes. */ key: string; /** The key of its category. */ x: string; series: string; /** The number it stands for. */ value: number; x0: number; y0: number; width: number; height: number; /** * Which end is rounded: `end` is the value end, away from the 0 line, `start` * the end at the 0 line. A bar rounds only its value end, and in a stack only * the outer segment of each sign; the Bar Chart itself never asks for `start`. */ rounded: "start" | "end" | "none"; }; /** * A bar before it has a length: its place across its category, which it keeps * while it lives, and the number it has right now. The numbers glide when new * data arrives, and the bars are laid out again at every step of the glide. */ export type BarSlot = { key: string; x: string; series: string; /** The place of the series among the series of the chart, which is the order of a stack. */ order: number; value: number; /** Where the bar starts across its category, in px from the start of the category axis. */ start: number; /** How thick the bar is across its category. */ size: number; }; /** * How thick the bars of a category are and where each starts inside its band. * A lone bar and stacked segments take the whole band, and side-by-side bars * share it with a gap, so the bars fill their band the way shadcn's do. In a * band too narrow for the gap the gap gives way first; a bar is never thinner * than 1 px, and bars that the band cannot hold at that thickness are centred * on it. */ function thickness(band: number, count: number, stacked: boolean) { if (stacked || count <= 1) { const size = Math.max(1, band); return { offsets: Array.from({ length: count }, () => (band - size) / 2), size, }; } let gap = BAR_GAP; let size = (band - gap * (count - 1)) / count; if (size < 1) { size = 1; gap = Math.max(0, (band - count) / (count - 1)); } const first = (band - (size * count + gap * (count - 1))) / 2; return { offsets: Array.from( { length: count }, (_, index) => first + index * (size + gap), ), size, }; } /** * Where the bar of the series at `order` sits in the band of its category, for * `count` series side by side or stacked: where it starts across the category * axis and how thick it is. The band is where the category is, in px. */ export function barPlace( band: { start: number; size: number }, order: number, count: number, stacked: boolean, ): { start: number; size: number } { const { offsets, size } = thickness(band.size, count, stacked); return { size, start: hundredths(band.start + (offsets[order] ?? 0)) }; } /** * The bars of the data before any length: one for every finite value, in the * order of the data and then of the series. A category that the scale does not * know has none, and neither has a missing value, which leaves its slot empty. * Categories must be unique; the chart makes repeated ones unique first. */ export function barSlots({ data, x, seriesKeys, stacked, categoryScale, }: { data: readonly ChartDatum[]; x: string; seriesKeys: readonly string[]; stacked: boolean; categoryScale: ScaleBand; }): BarSlot[] { const size = categoryScale.bandwidth(); const slots: BarSlot[] = []; for (const datum of data) { const category = chartXKey(datum[x]); const start = categoryScale(category); if (start === undefined) continue; seriesKeys.forEach((series, order) => { const value = chartValue(datum, series); if (value === null) return; slots.push({ ...barPlace({ size, start }, order, seriesKeys.length, stacked), key: `${category}|${series}`, order, series, value, x: category, }); }); } return slots; } /** 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. */ export type BandFraction = { start: number; size: number }; // A fraction is kept to a billionth, so that the rounding noise of a chart that // is a little wider or narrower does not make it a different fraction. const billionths = (value: number) => Math.round(value * 1e9) / 1e9; /** * Where the band of a category lies along its axis, as fractions of the axis. The * fractions depend on how many categories there are and where this one is, and * not on how wide the chart is: a chart that is resized keeps them. Null for a * category the scale does not know. */ export function categoryFractions( scale: ScaleBand, key: string, ): BandFraction | null { const at = scale(key); if (at === undefined) return null; const [from = 0, to = 0] = scale.range(); const length = Math.abs(to - from); if (length === 0) return { size: 0, start: 0 }; return { size: billionths(scale.bandwidth() / length), start: billionths((at - Math.min(from, to)) / length), }; } /** * The slots of the bars as they are drawn at this step of a glide. A bar that is * leaving stays where it stood, and a bar in a band that is moving is placed in * the band as it is now, so its place and thickness glide with the category; any * other bar is where its slot says. `bands` holds the categories whose band is * moving, as fractions of the `length` of the category axis, and `count` is the * number of series. Each slot gets the number it has at this step. */ export function glidingSlots( entries: readonly { leaving: boolean; slot: BarSlot; value: number }[], bands: ReadonlyMap, { count, length, stacked, }: { count: number; length: number; stacked: boolean }, ): BarSlot[] { return entries.map(({ leaving, slot, value }) => { const band = leaving ? undefined : bands.get(slot.x); return band ? { ...slot, ...barPlace( { size: band.size * length, start: band.start * length }, slot.order, count, stacked, ), value, } : { ...slot, value }; }); } // A name for the category in the rows that are stacked, which no series can have. const categoryField = "\u0000category"; /** * Where each bar of a stack starts and ends on the value axis, as `stackSeries` * puts them, and which bars end a stack: the outer segment of each sign in each * category. A segment of 0 has no extent and takes the end from none. */ function stackPlaces(slots: readonly BarSlot[]) { const rows = new Map(); const orders = new Map(); for (const slot of slots) { const row = rows.get(slot.x) ?? { [categoryField]: slot.x }; row[slot.series] = slot.value; rows.set(slot.x, row); if (!orders.has(slot.series)) orders.set(slot.series, slot.order); } // The order of the series, and for series of one place the order of the list. const keys = [...orders] .sort((a, b) => a[1] - b[1]) .map(([series]) => series); const places = new Map(); const up = new Map(); const down = new Map(); for (const segment of stackSeries([...rows.values()], categoryField, keys)) { const key = `${segment.x}|${segment.series}`; places.set(key, { y0: segment.y0, y1: segment.y1 }); if (segment.value > 0) up.set(segment.x, key); else if (segment.value < 0) down.set(segment.x, key); } return { ends: new Set([...up.values(), ...down.values()]), places }; } /** * The bars at the numbers they have right now, in the order of the slots. Side * by side, a bar runs from the 0 line to its value; stacked, positive segments * pile up from the 0 line and negative ones down from it, in the order of the * series, so a stack stays whole whatever its segments are worth while they * glide. A bar of 0 has no extent. `valueScale` maps a number to a position in * the plot: from the top down for vertical bars, from the left for horizontal. */ export function barsAt( slots: readonly BarSlot[], { stacked, orientation, valueScale, }: { stacked: boolean; orientation: BarOrientation; valueScale: ScaleLinear; }, ): BarRect[] { const stack = stacked ? stackPlaces(slots) : null; return slots.map((slot) => { const place = stack?.places.get(slot.key); const from = valueScale(place ? place.y0 : 0); const to = valueScale(place ? place.y1 : slot.value); const near = hundredths(Math.min(from, to)); const length = hundredths(Math.abs(to - from)); const rounded: BarRect["rounded"] = ( stack ? stack.ends.has(slot.key) : slot.value !== 0 ) ? "end" : "none"; const base = { key: slot.key, rounded, series: slot.series, value: slot.value, x: slot.x, }; return orientation === "vertical" ? { ...base, height: length, width: slot.size, x0: slot.start, y0: near } : { ...base, height: slot.size, width: length, x0: near, y0: slot.start }; }); } /** * The bars of the data at rest, in pixels: a band per category and the bars * inside it, side by side or stacked. The value end of a bar is rounded, the * outer segment of each sign when stacked; a bar takes the thickness of its band * (shared by the series side by side, with `BAR_GAP` between the bars of one * category) and is at least 1 px thick. A negative value grows from the 0 line * away from the positive ones. */ export function layoutBars({ data, x, seriesKeys, stacked, orientation, categoryScale, valueScale, }: { data: readonly ChartDatum[]; x: string; seriesKeys: readonly string[]; stacked: boolean; orientation: BarOrientation; categoryScale: ScaleBand; valueScale: ScaleLinear; }): BarRect[] { return barsAt(barSlots({ categoryScale, data, seriesKeys, stacked, x }), { orientation, stacked, valueScale, }); } type Rounding = readonly [ topLeft: number, topRight: number, bottomRight: number, bottomLeft: number, ]; // A rectangle with its own radius at each corner. A corner of 0 draws no arc. function roundedRectangle( x: number, y: number, width: number, height: number, [topLeft, topRight, bottomRight, bottomLeft]: Rounding, ): string { const arc = (radius: number, toX: number, toY: number) => radius > 0 ? ` A${radius},${radius} 0 0 1 ${toX},${toY}` : ""; return [ `M${hundredths(x + topLeft)},${hundredths(y)}`, ` H${hundredths(x + width - topRight)}`, arc(topRight, hundredths(x + width), hundredths(y + topRight)), ` V${hundredths(y + height - bottomRight)}`, arc( bottomRight, hundredths(x + width - bottomRight), hundredths(y + height), ), ` H${hundredths(x + bottomLeft)}`, arc(bottomLeft, hundredths(x), hundredths(y + height - bottomLeft)), ` V${hundredths(y + topLeft)}`, arc(topLeft, hundredths(x + topLeft), hundredths(y)), " Z", ].join(""); } /** * The outline of a bar: a rectangle with the radius at the end that is rounded, * which is the far side of the 0 line for `end` (the top of a positive vertical * bar, the bottom of a negative one, the right and the left of horizontal ones) * and the side at the 0 line for `start`. The radius never exceeds a quarter of * the thickness, so a thin bar keeps a light curve, or the length of the bar. */ export function barPath( { x0, y0, width, height, rounded, value }: BarRect, orientation: BarOrientation, radius: number = BAR_RADIUS, ): string { const vertical = orientation === "vertical"; const r = rounded === "none" ? 0 : Math.max( 0, Math.min( radius, (vertical ? width : height) / 4, vertical ? height : width, ), ); // The side of the value end, then of the start when that is the one rounded. const positive = value >= 0; const end = vertical ? positive ? "top" : "bottom" : positive ? "right" : "left"; const opposite = { bottom: "top", left: "right", right: "left", top: "bottom", }[end]; const side = rounded === "end" ? end : opposite; const corners: Rounding = side === "top" ? [r, r, 0, 0] : side === "bottom" ? [0, 0, r, r] : side === "left" ? [r, 0, 0, r] : [0, r, r, 0]; return roundedRectangle(x0, y0, width, height, corners); } /** 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. */ export function categoryBand( scale: ScaleBand, key: string, ): { start: number; size: number } | null { const at = scale(key); if (at === undefined) return null; const step = scale.step(); return { size: hundredths(step), start: hundredths(at - (step - scale.bandwidth()) / 2), }; } /** The centre of every category along the category axis, in px, in the order of `keys`. */ export function categoryCentres( scale: ScaleBand, keys: readonly string[], ): number[] { return keys.map((key) => hundredths((scale(key) ?? 0) + scale.bandwidth() / 2), ); } /** * The category under a point along the category axis: the one whose centre is * nearest, which is the band the point is in, for a point anywhere in the plot * and a little beyond its ends. -1 for a point further out, and for no category. */ export function categoryAt( centres: readonly number[], along: number, length: number, ): number { if (along < -pointerSlack || along > length + pointerSlack) return -1; return nearestIndex(centres, along); } /** A label of the category axis: where it sits and what it says. */ export type CategoryLabel = { id: string; position: number; text: string }; /** * The labels of the category axis that are shown: every k-th label, one k for the * whole axis, the smallest that leaves none on top of another, so the labels keep * an even rhythm however many categories there are. Along the bottom they are as * wide as their text; down the left side as tall as a line. */ export function categoryLabels({ centres, rows, side, format, }: { centres: readonly number[]; rows: readonly { id: string; x: ChartXValue }[]; side: "bottom" | "left"; format: (value: ChartXValue) => string; }): CategoryLabel[] { const labels = rows.map((row, index) => ({ id: row.id, position: centres[index] ?? 0, text: format(row.x), })); const step = labelStep( labels.map((label) => label.position), labels.map((label) => side === "bottom" ? estimateLabelWidth(label.text) : CHART_LABEL_HEIGHT, ), side === "bottom" ? labelGap : CHART_LABEL_COLUMN_GAP, ); return labels.filter((_, index) => index % step === 0); } /** The space between the end of a bar and its value label, in px. */ export const BAR_VALUE_LABEL_OFFSET = 6; // The least space between two value labels, in px. const valueLabelGap = 4; /** A value label as it is drawn: what it says and the point its text hangs from. */ export type BarValueLabel = { /** The key of its bar, or of its stack and the sign it sums. */ key: string; /** The key of its category. */ category: string; text: string; x: number; y: number; /** * Where the text sits from its point: above or below the end of a column, * after or before the end of a horizontal bar, by the offset. */ side: "above" | "below" | "after" | "before"; }; /** * The texts of the value labels the data asks for, by sign: the value of every * bar, or in a stack the sum of each sign in each category that has one. A * value of 0 counts as positive, so it is written above the 0 line. The chart * keeps room for them before it lays out its bars. */ export function valueLabelTexts( values: readonly (readonly (number | null)[])[], stacked: boolean, format: (value: number) => string, ): { positive: string[]; negative: string[] } { const positive: string[] = []; const negative: string[] = []; const rowCount = Math.max(0, ...values.map((own) => own.length)); for (let row = 0; row < rowCount; row++) { const own = values .map((series) => series[row]) .filter((value): value is number => typeof value === "number"); if (!stacked) { for (const value of own) (value >= 0 ? positive : negative).push(format(value)); continue; } const up = own.filter((value) => value >= 0); const down = own.filter((value) => value < 0); if (up.length > 0) positive.push(format(up.reduce((a, b) => a + b, 0))); if (down.length > 0) negative.push(format(down.reduce((a, b) => a + b, 0))); } return { negative, positive }; } /** * The room value labels need beyond the ends of the bars, in px, at the start * of the value axis (where negative bars end: the bottom of a column chart, the * left of a horizontal one) and at its end (where positive bars end): the * offset and a line of text for columns, the offset and the widest label for * horizontal bars. None on a side that no label is written to. */ export function valueLabelRoom( texts: { positive: readonly string[]; negative: readonly string[] }, orientation: BarOrientation, ): { start: number; end: number } { const need = (side: readonly string[]) => side.length === 0 ? 0 : BAR_VALUE_LABEL_OFFSET + (orientation === "vertical" ? CHART_LABEL_HEIGHT : Math.ceil(Math.max(...side.map(estimateLabelWidth)))); return { end: need(texts.positive), start: need(texts.negative) }; } /** * The value labels of the bars as they are drawn: the value of each bar at its * end, centred on a column and beside a horizontal bar, or in a stack the sum * of each sign at the end of its outer segment. A positive value is written * beyond the far end of its bar, a negative one below or before it. */ export function barValueLabels( bars: readonly BarRect[], { format, orientation, stacked, }: { format: (value: number) => string; orientation: BarOrientation; stacked: boolean; }, ): BarValueLabel[] { const vertical = orientation === "vertical"; const label = ( key: string, category: string, value: number, end: number, across: number, ): BarValueLabel => { const positive = value >= 0; const beyond = hundredths( end + (positive === vertical ? -1 : 1) * BAR_VALUE_LABEL_OFFSET, ); return { category, key, side: vertical ? positive ? "above" : "below" : positive ? "after" : "before", text: format(value), x: vertical ? across : beyond, y: vertical ? beyond : across, }; }; const middle = (bar: BarRect) => hundredths(vertical ? bar.x0 + bar.width / 2 : bar.y0 + bar.height / 2); // The far end of a bar from the 0 line, along the value axis. const far = (bar: BarRect) => vertical ? bar.value >= 0 ? bar.y0 : bar.y0 + bar.height : bar.value >= 0 ? bar.x0 + bar.width : bar.x0; if (!stacked) return bars.map((bar) => label(bar.key, bar.x, bar.value, far(bar), middle(bar)), ); const stacks = new Map(); for (const bar of bars) { const stack = stacks.get(bar.x) ?? { down: [], up: [] }; (bar.value >= 0 ? stack.up : stack.down).push(bar); stacks.set(bar.x, stack); } return [...stacks].flatMap(([category, stack]) => (["up", "down"] as const).flatMap((sign) => { const own = stack[sign]; if (own.length === 0) return []; const ends = own.map(far); // The outer end of the stack: furthest from the 0 line on its side. const end = (sign === "up") === vertical ? Math.min(...ends) : Math.max(...ends); const sum = own.reduce((total, bar) => total + bar.value, 0); return [ label(`${category}|${sign}`, category, sum, end, middle(own[0]!)), ]; }), ); } /** * The value labels that are drawn: every k-th category keeps its labels, one k * for the whole chart, the smallest for which no two labels touch, as the axis * keeps every k-th label. None when two labels of one category touch, as on * side by side bars too thin for their labels. `labels` are in the order of * the categories along their axis. */ export function visibleValueLabels( labels: readonly BarValueLabel[], orientation: BarOrientation, ): BarValueLabel[] { const vertical = orientation === "vertical"; const along = (label: BarValueLabel) => (vertical ? label.x : label.y); const size = (label: BarValueLabel) => vertical ? estimateLabelWidth(label.text) : CHART_LABEL_HEIGHT; const categories = new Map(); for (const label of labels) { const own = categories.get(label.category) ?? []; own.push(label); categories.set(label.category, own); } for (const own of categories.values()) for (const side of ["above", "below", "after", "before"] as const) { const row = own .filter((label) => label.side === side) .sort((a, b) => along(a) - along(b)); for (let index = 1; index < row.length; index++) { const before = row[index - 1]!; const label = row[index]!; if ( along(label) - along(before) < (size(label) + size(before)) / 2 + valueLabelGap ) return []; } } // Each category as one stretch along the category axis. const stretches = [...categories.values()].map((own) => { const start = Math.min( ...own.map((label) => along(label) - size(label) / 2), ); const end = Math.max(...own.map((label) => along(label) + size(label) / 2)); return { centre: (start + end) / 2, size: end - start }; }); const step = labelStep( stretches.map((stretch) => stretch.centre), stretches.map((stretch) => stretch.size), valueLabelGap, ); const kept = new Set( [...categories.keys()].filter((_, index) => index % step === 0), ); return labels.filter((label) => kept.has(label.category)); } /** * The ticks of a value axis that runs along the bottom, where a label takes the * room of its text: the ticks the axis has, or every k-th of them, the smallest * k for which no two labels touch. `write` gives the labels of a set of ticks. * The ticks that stay are still on the steps of the axis, so they keep the grid * lines on the places of the nice domain. */ export function fittingTicks( scale: ScaleLinear, length: number, write: (ticks: readonly number[]) => string[], ): number[] { const ticks = valueTicks(scale, length); const step = labelStep( ticks.map(scale), write(ticks).map(estimateLabelWidth), ); const kept = ticks.filter((_, index) => index % step === 0); // An axis keeps its two ends when every other tick has to go. return kept.length >= 2 || ticks.length < 2 ? kept : [ticks[0]!, ticks[ticks.length - 1]!]; } // ~/src/siteplane/components/bar-chart/index.ts export * from "./bar-chart";