Repository navigation
UN-4401 [FEAT] Add v1 Line, Area, Bar, StackedBar and Sparkline charts with a token ordinal ramp #2446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
UN-4401 [FEAT] Add v1 Line, Area, Bar, StackedBar and Sparkline charts with a token ordinal ramp #2446
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 8ed588a
fix(v1): address chart self-review round 1
ritwik-g 4965a6e
fix(v1): address chart self-review round 2
ritwik-g 0d79da9
fix(v1): address chart self-review round 3
ritwik-g 8c13e10
fix(v1): address chart self-review round 4
ritwik-g 45c4e8f
test(v1): hide the bare test SVG from assistive tech (lint)
ritwik-g a9662c5
fix(v1): address chart self-review round 5
ritwik-g 0d2f1c1
fix(v1): anchor marker labels by distance to the plot edge (round 6)
ritwik-g 8720d28
fix(v1): anchor bar marker labels too; tighten label anchoring (round 7)
ritwik-g a30a61b
fix(v1): least-overflow marker label anchoring; plot area from Rechar…
ritwik-g 15796d6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 0749bfe
fix(v1): chart self-review round 9 doc and test fixes
ritwik-g df4e457
Merge remote-tracking branch 'origin/main' into UN-4401-v1-charts
ritwik-g 6dfc936
fix(v1): keep chart footprint across states; sparkline tooltip from t…
ritwik-g e04a787
fix(v1): chart self-review round 11 — footprint claim scoped, tests t…
ritwik-g f7bcfcc
fix(v1): chart self-review round 12 — ramp and empty-slot guidance, t…
ritwik-g 59e21f5
fix(v1): chart self-review round 13 — tone limits, primary vs ramp, s…
ritwik-g 992d3fb
fix(v1): chart self-review round 14 — no unchecked colour pair recomm…
ritwik-g 2185af4
docs(v1): chart Colour section — design-backed rules, no ratios
ritwik-g a75b01b
feat(v1): chart ramp is the LLMWhisperer designed --mode-* values; co…
ritwik-g 387cdc4
Merge remote-tracking branch 'origin/main' into UN-4401-v1-charts
ritwik-g 48c4c13
test(v1): pin the luminance helper's curve, weights and #rrggbb anchor
ritwik-g f5aff01
test(v1): pin every luminance weight, both curve segments and both an…
ritwik-g 75a89de
fix(v1): chart tooltip is a live region; hidden table clipped by a wr…
ritwik-g dab77d8
fix(v1): chart live region mounted with the chart, outside the tooltip
ritwik-g 794e39f
fix(v1): clear the chart live region on unmount; speak gaps as no data
ritwik-g f6e4d18
fix(v1): Sparkline honours a series' right axis
ritwik-g a09a3d5
Merge remote-tracking branch 'origin/main' into UN-4401-v1-charts
ritwik-g dc35a40
fix(v1): mixed-sign stacks, negative-bar markers, all-gap empty state
ritwik-g ae87a9f
fix(v1): bottom-edge bar labels beside the dot; unparsed values are n…
ritwik-g 05ac6c3
fix(v1): bottom-edge labels pick the side that overflows less and sta…
ritwik-g 8f5bd9a
fix(v1): warn when unparsed values leave a chart empty; pin the right…
ritwik-g e8f4830
docs(v1): the unparsed-value warning is not development-only, and fir…
ritwik-g cdaa6db
Merge remote-tracking branch 'origin/main' into UN-4401-v1-charts
ritwik-g File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" />; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" />; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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], | ||
| ); | ||
| // 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} | ||
|
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}`, | ||
|
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 | ||
|
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 ? ( | ||
|
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)} | ||
|
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> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.