Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5306b70
feat(v1): add chart components on Recharts with a token ordinal ramp
ritwik-g Nov 5, 2026
8ed588a
fix(v1): address chart self-review round 1
ritwik-g Nov 5, 2026
4965a6e
fix(v1): address chart self-review round 2
ritwik-g Nov 5, 2026
0d79da9
fix(v1): address chart self-review round 3
ritwik-g Nov 5, 2026
8c13e10
fix(v1): address chart self-review round 4
ritwik-g Nov 5, 2026
45c4e8f
test(v1): hide the bare test SVG from assistive tech (lint)
ritwik-g Nov 5, 2026
a9662c5
fix(v1): address chart self-review round 5
ritwik-g Nov 5, 2026
0d2f1c1
fix(v1): anchor marker labels by distance to the plot edge (round 6)
ritwik-g Nov 5, 2026
8720d28
fix(v1): anchor bar marker labels too; tighten label anchoring (round 7)
ritwik-g Nov 5, 2026
a30a61b
fix(v1): least-overflow marker label anchoring; plot area from Rechar…
ritwik-g Nov 5, 2026
15796d6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 5, 2026
0749bfe
fix(v1): chart self-review round 9 doc and test fixes
ritwik-g Nov 5, 2026
df4e457
Merge remote-tracking branch 'origin/main' into UN-4401-v1-charts
ritwik-g Nov 6, 2026
6dfc936
fix(v1): keep chart footprint across states; sparkline tooltip from t…
ritwik-g Nov 6, 2026
e04a787
fix(v1): chart self-review round 11 — footprint claim scoped, tests t…
ritwik-g Nov 6, 2026
f7bcfcc
fix(v1): chart self-review round 12 — ramp and empty-slot guidance, t…
ritwik-g Nov 6, 2026
59e21f5
fix(v1): chart self-review round 13 — tone limits, primary vs ramp, s…
ritwik-g Nov 6, 2026
992d3fb
fix(v1): chart self-review round 14 — no unchecked colour pair recomm…
ritwik-g Nov 6, 2026
2185af4
docs(v1): chart Colour section — design-backed rules, no ratios
ritwik-g Nov 6, 2026
a75b01b
feat(v1): chart ramp is the LLMWhisperer designed --mode-* values; co…
ritwik-g Nov 6, 2026
387cdc4
Merge remote-tracking branch 'origin/main' into UN-4401-v1-charts
ritwik-g Nov 6, 2026
48c4c13
test(v1): pin the luminance helper's curve, weights and #rrggbb anchor
ritwik-g Nov 6, 2026
f5aff01
test(v1): pin every luminance weight, both curve segments and both an…
ritwik-g Nov 6, 2026
75a89de
fix(v1): chart tooltip is a live region; hidden table clipped by a wr…
ritwik-g Nov 6, 2026
dab77d8
fix(v1): chart live region mounted with the chart, outside the tooltip
ritwik-g Nov 6, 2026
794e39f
fix(v1): clear the chart live region on unmount; speak gaps as no data
ritwik-g Nov 6, 2026
f6e4d18
fix(v1): Sparkline honours a series' right axis
ritwik-g Nov 6, 2026
a09a3d5
Merge remote-tracking branch 'origin/main' into UN-4401-v1-charts
ritwik-g Nov 10, 2026
dc35a40
fix(v1): mixed-sign stacks, negative-bar markers, all-gap empty state
ritwik-g Nov 10, 2026
ae87a9f
fix(v1): bottom-edge bar labels beside the dot; unparsed values are n…
ritwik-g Nov 10, 2026
05ac6c3
fix(v1): bottom-edge labels pick the side that overflows less and sta…
ritwik-g Nov 10, 2026
8f5bd9a
fix(v1): warn when unparsed values leave a chart empty; pin the right…
ritwik-g Nov 10, 2026
e8f4830
docs(v1): the unparsed-value warning is not development-only, and fir…
ritwik-g Nov 10, 2026
cdaa6db
Merge remote-tracking branch 'origin/main' into UN-4401-v1-charts
ritwik-g Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions frontend/src/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ is convention.
`Spinner` sits only in buttons and running text. Errors on a screen are an
`InlineAlert` above the content, with `onRetry`.

## Charts

- Line, area, bar, stacked bar and sparkline live in `components/ui/chart/`;
its README has the props, colour ramp, marker rule and text alternative.
Colours come from `--chart-1`…`--chart-5` and the semantic tokens only.

## Code and data

- Code, JSON, diffs, Markdown, copy controls and code editors come from
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/v1/components/ui/chart/AreaChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { JSX } from "react";
import { CartesianChart } from "./CartesianChart";
import type { CartesianChartProps, ChartDatum } from "./types";

export type AreaChartProps<Row extends ChartDatum> = CartesianChartProps<Row>;

/** A line chart with a translucent fill under each series. Series overlap; they do not stack. */
export function AreaChart<Row extends ChartDatum>(
props: AreaChartProps<Row>,
): JSX.Element {
return <CartesianChart {...props} kind="area" slot="area-chart" />;
}
12 changes: 12 additions & 0 deletions frontend/src/v1/components/ui/chart/BarChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { JSX } from "react";
import { CartesianChart } from "./CartesianChart";
import type { CartesianChartProps, ChartDatum } from "./types";

export type BarChartProps<Row extends ChartDatum> = CartesianChartProps<Row>;

/** One bar per series per category, side by side. */
export function BarChart<Row extends ChartDatum>(
props: BarChartProps<Row>,
): JSX.Element {
return <CartesianChart {...props} kind="bar" slot="bar-chart" />;
}
325 changes: 325 additions & 0 deletions frontend/src/v1/components/ui/chart/CartesianChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
import { type JSX, useEffect, useId, useMemo, useState } from "react";
import {
Area,
Bar,
CartesianGrid,
ComposedChart,
LabelList,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
ChartFrame,
DEFAULT_EMPTY_MESSAGE,
DEFAULT_X_LABEL,
} from "./ChartFrame";
import { ChartTooltip } from "./ChartTooltip";
import {
type ChartKind,
describeChart,
type Formatters,
formatAxisValue,
formatCategory,
formatValue,
hasValues,
tableRows,
unparsedWarnings,
} from "./describe";
import {
chartRows,
droppedWarnings,
keptLabels,
keptMarkers,
markerKey,
markerLabelKey,
reservedKeyWarnings,
} from "./markers";
import { BarMarker, isolatedDot, markerDot } from "./marks";
import { seriesColors } from "./palette";
import type { CartesianChartProps, ChartDatum } from "./types";

export const DEFAULT_CHART_HEIGHT = 240;

const NO_MARKERS: readonly never[] = [];

const AXIS_TICK = { fill: "var(--muted-foreground)" };
// Room for a marker label above the highest point.
const MARGIN = { top: 20, right: 8, bottom: 0, left: 0 };
const Y_AXIS_WIDTH = 40;

type Props<Row extends ChartDatum> = CartesianChartProps<Row> &
Readonly<{ kind: Exclude<ChartKind, "sparkline">; slot: string }>;

/** The shared body of LineChart, AreaChart, BarChart and StackedBarChart. */
export function CartesianChart<Row extends ChartDatum>({
kind,
slot,
data,
xKey,
xLabel = DEFAULT_X_LABEL,
series,
title,
summary,
state = "ready",
emptySlot,
emptyMessage = DEFAULT_EMPTY_MESSAGE,
height = DEFAULT_CHART_HEIGHT,
width,
valueFormat = formatValue,
xFormat = formatCategory,
axisFormat = formatAxisValue,
rightAxisFormat = axisFormat,
markers = NO_MARKERS,
legend = "bottom",
grid = true,
className,
}: Props<Row>): JSX.Element {
const summaryId = useId();
// The active point's sentence, for the live region (see ChartTooltip).
const [live, setLive] = useState("");
const fmt: Formatters = { value: valueFormat, x: xFormat };
const colors = seriesColors(series);
const stacked = kind === "stacked-bar";
const bars = kind === "bar" || stacked;
const resolved =
state === "ready" && !hasValues(data, series) ? "empty" : state;
// Recharts resets its data store whenever the rows it gets change
// identity, so the rows are `data` itself unless something is marked, and
// are memoised on what the series and markers say rather than on their
// identity: pages pass both as inline literals, new on every render.
const seriesKeys = series.map((s) => s.key).join("\n");
const markerSpec = markers
.map((m) => `${m.index}\t${m.series}\t${m.label}`)
.join("\n");
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the content of series and markers (seriesKeys, markerSpec), not their identity.
const { kept, dropped } = useMemo(
() => keptMarkers(data, series, markers),
[data, seriesKeys, markerSpec],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: series only matters through its keys, and kept changes whenever they do.
const rows = useMemo(
() => chartRows(data, series, kept, bars),
[data, kept, bars],
);
Comment thread
ritwik-g marked this conversation as resolved.
Resolved
// Logged whenever the set of problems changes (and, as with every effect,
// twice on mount under StrictMode in development). A reserved series key
// or an unparsed value is a mistake in any state (the latter is why a
// chart with data may show its empty state); a dropped marker only while
// showing data, since a marker computed for rows still loading is not one.
const warnings = JSON.stringify([
...reservedKeyWarnings(series),
...unparsedWarnings(data, series),
...(resolved === "ready" ? droppedWarnings(dropped) : []),
]);
useEffect(() => {
for (const warning of JSON.parse(warnings) as string[]) {
console.warn(warning);
}
}, [warnings]);
const hasRight = series.some((s) => s.axis === "right");
const text =
summary ??
describeChart({
kind,
title,
data,
xKey,
series,
markerLabels: keptLabels(kept),
fmt,
});
const tooltipSeries = series.map((s, i) => ({
key: s.key,
label: s.label,
color: colors[i],
format: s.format ?? valueFormat,
}));

const marks = series.map((s, i) => {
const axis = s.axis ?? "left";
const common = {
dataKey: s.key,
name: s.label,
yAxisId: axis,
isAnimationActive: false,
};
switch (kind) {
case "line":
return (
<Line
key={s.key}
{...common}
type="linear"
stroke={colors[i]}
strokeWidth={2}
dot={isolatedDot(colors[i])}
activeDot={{ r: 4, stroke: "var(--card)", strokeWidth: 2 }}
connectNulls={false}
Comment thread
Deepak-Kesavan marked this conversation as resolved.
Resolved
/>
);
case "area":
return (
<Area
key={s.key}
{...common}
type="linear"
stroke={colors[i]}
strokeWidth={2}
fill={colors[i]}
fillOpacity={0.12}
dot={isolatedDot(colors[i])}
activeDot={{ r: 4, stroke: "var(--card)", strokeWidth: 2 }}
/>
);
default:
// Stacked segments are split by a 1px card-coloured stroke, so
// neighbouring ramp steps read apart.
return (
<Bar
key={s.key}
{...common}
fill={colors[i]}
maxBarSize={32}
{...(stacked
? {
stackId: `stack-${axis}`,
Comment thread
ritwik-g marked this conversation as resolved.
Resolved
stroke: "var(--card)",
strokeWidth: 1,
}
: { radius: [2, 2, 0, 0] as [number, number, number, number] })}
>
{kept.has(i) ? (
<LabelList
dataKey={markerLabelKey(s.key)}
content={<BarMarker color={colors[i]} />}
/>
) : null}
</Bar>
);
}
});

const markerMarks = bars
? []
: [...kept.keys()].map((seriesIndex) => (
<Line
key={markerKey(series[seriesIndex].key)}
dataKey={markerKey(series[seriesIndex].key)}
yAxisId={series[seriesIndex].axis ?? "left"}
stroke="none"
legendType="none"
tooltipType="none"
isAnimationActive={false}
activeDot={false}
dot={markerDot(
colors[seriesIndex],
markerLabelKey(series[seriesIndex].key),
)}
/>
));

const chart = (
<ComposedChart
Comment thread
Deepak-Kesavan marked this conversation as resolved.
Resolved
data={rows as ChartDatum[]}
margin={MARGIN}
width={width}
height={width === undefined ? undefined : height}
aria-label={title}
aria-describedby={summaryId}
barCategoryGap="20%"
// Positives stack up from zero and negatives down, so a mixed-sign
// stack (credits and refunds) is not drawn inside its positive segment.
{...(stacked ? { stackOffset: "sign" } : {})}
>
{grid ? (
Comment thread
Deepak-Kesavan marked this conversation as resolved.
Resolved
<CartesianGrid vertical={false} stroke="var(--border)" yAxisId="left" />
) : null}
<XAxis
dataKey={xKey}
tickFormatter={(value: string | number) => xFormat(value)}
Comment thread
Deepak-Kesavan marked this conversation as resolved.
Resolved
tick={AXIS_TICK}
axisLine={false}
tickLine={false}
minTickGap={16}
/>
<YAxis
yAxisId="left"
tickFormatter={(value: number) => axisFormat(value)}
tick={AXIS_TICK}
axisLine={false}
tickLine={false}
width={Y_AXIS_WIDTH}
/>
{hasRight ? (
<YAxis
yAxisId="right"
orientation="right"
tickFormatter={(value: number) => rightAxisFormat(value)}
tick={AXIS_TICK}
axisLine={false}
tickLine={false}
width={Y_AXIS_WIDTH}
/>
) : null}
<Tooltip
cursor={
kind === "bar" || kind === "stacked-bar"
? { fill: "var(--muted)" }
: { stroke: "var(--border)" }
}
isAnimationActive={false}
// Keep gap entries, so a category where every series is a gap still
// shows its tooltip (Recharts hides one with an empty payload).
filterNull={false}
content={
<ChartTooltip series={tooltipSeries} fmt={fmt} onAnnounce={setLive} />
}
/>
{marks}
{markerMarks}
</ComposedChart>
);

return (
<ChartFrame
slot={slot}
live={live}
title={title}
state={resolved}
height={height}
width={width}
emptySlot={emptySlot}
emptyMessage={emptyMessage}
summary={text}
summaryId={summaryId}
table={{
head: [xLabel, ...series.map((s) => s.label)],
rows: tableRows(data, xKey, series, fmt),
}}
legend={
legend === "none"
? undefined
: {
position: legend,
items: series.map((s, i) => ({
key: s.key,
label: s.label,
color: colors[i],
})),
}
}
className={className}
>
{width === undefined ? (
<ResponsiveContainer width="100%" height={height}>
{chart}
</ResponsiveContainer>
) : (
chart
)}
</ChartFrame>
);
}
Loading
Loading