Repository navigation
feat(design): DS-X-33 sparkline, bar list, chart frame #1717
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
Changes from all commits
c0177e1
53235f4
61606e2
ef66e3b
2ef20e6
e2199bb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { chartColour, chartEmpty, chartValue, known, type ChartProps } from "./charts"; | ||
| import "./charts.css"; | ||
|
|
||
| export function BarList({ label, data }: ChartProps) { | ||
| if (!data?.length) return <p>{chartEmpty(data)}</p>; | ||
| const max = Math.max(...data.map(row => known(row.value) ? Math.abs(row.value) : 0)) || 1; | ||
| return <ul className="g-bars" aria-label={label}> | ||
| {data.map((row, i) => { | ||
| const width = known(row.value) ? row.value / max * 48 : null; | ||
| return <li key={i}> | ||
| <span>{row.label}</span><span className="g-bars__value">{chartValue(row.value)}</span> | ||
| <svg viewBox="0 0 100 8" preserveAspectRatio="none" aria-hidden="true"> | ||
| {width !== null && <rect x={50 + Math.min(0, width)} y="1" width={Math.abs(width)} height="6" fill={chartColour(i)} />} | ||
| </svg> | ||
| </li>; | ||
| })} | ||
| </ul>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { useId } from "react"; | ||
| import { de } from "../../i18n/de"; | ||
| import { BarList } from "./BarList"; | ||
| import { Sparkline } from "./Sparkline"; | ||
| import "./data.css"; | ||
| import { chartColour, chartEmpty, chartValue, type ChartProps } from "./charts"; | ||
| import "./charts.css"; | ||
|
|
||
| export interface ChartFrameProps { | ||
| title: string; | ||
| caption: string; | ||
| kind: "sparkline" | "bars"; | ||
| data: ChartProps["data"]; | ||
| } | ||
|
|
||
| /** One dataset drives both the graphic and its mandatory table twin. */ | ||
| export function ChartFrame({ title, caption, kind, data }: ChartFrameProps) { | ||
| const id = useId(); | ||
| const legend = kind === "sparkline" ? [title] : (data ?? []).map(row => row.label); | ||
| return <figure className="g-chart" aria-labelledby={id}> | ||
| <figcaption><h3 id={id}>{title}</h3><p>{caption}</p></figcaption> | ||
| {!!data?.length && <ul className="g-chart__legend" aria-label={de["chart.legend"]}> | ||
| {legend.map((label, i) => <li key={i}><i aria-hidden="true" style={{ background: chartColour(i) }} />{label}</li>)} | ||
| </ul>} | ||
| {kind === "sparkline" ? <Sparkline label={title} data={data} /> : <BarList label={title} data={data} />} | ||
| <details> | ||
| <summary>{de["chart.table"]}</summary> | ||
| {data?.length ? <div className="g-tw" role="region" aria-label={title} tabIndex={0}> | ||
| <table className="g-tbl" aria-label={title}> | ||
| <thead><tr><th scope="col">{de["chart.label"]}</th><th scope="col" className="g-tbl__n">{de["chart.value"]}</th></tr></thead> | ||
| <tbody>{data.map((row, i) => <tr key={i}><th scope="row">{row.label}</th><td className="g-tbl__n">{chartValue(row.value)}</td></tr>)}</tbody> | ||
| </table> | ||
| </div> : <p>{chartEmpty(data)}</p>} | ||
| </details> | ||
| </figure>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { de } from "../../i18n/de"; | ||
| import { chartEmpty, known, type ChartProps } from "./charts"; | ||
| import "./charts.css"; | ||
|
|
||
| export function Sparkline({ label, data }: ChartProps) { | ||
| const values = (data ?? []).map(row => row.value).filter(known); | ||
| if (!values.length) return <p>{data?.length ? de["state.unknown"] : chartEmpty(data)}</p>; | ||
| // Normalize first so even opposite finite extremes cannot overflow the range. | ||
| const scale = Math.max(...values.map(Math.abs)) || 1; | ||
| const min = Math.min(...values) / scale, max = Math.max(...values) / scale; | ||
| const points = data!.map((row, i) => known(row.value) | ||
| ? [data!.length === 1 ? 60 : 2 + i * 116 / (data!.length - 1), max === min ? 16 : 30 - (row.value / scale - min) / (max - min) * 28] | ||
| : null); | ||
| const path = points.map((p, i) => p ? `${i && points[i - 1] ? "L" : "M"}${p.join(",")}` : "").filter(Boolean).join(" "); | ||
| return <span className="g-spark"> | ||
| <svg viewBox="0 0 120 32" role="img" aria-label={label}> | ||
| <path d={path} /> | ||
| {points.map((p, i) => p && <circle key={i} cx={p[0]} cy={p[1]} r="1.5" />)} | ||
| </svg> | ||
| {values.length < data!.length && <span>{de["state.unknown"]}</span>} | ||
| </span>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| .g-chart { margin: 0; display: grid; align-content: start; gap: var(--g-space-3); color: var(--g-ink); } | ||
| .g-chart h3, .g-chart p { margin: 0; } | ||
| .g-chart figcaption p { color: var(--g-muted); } | ||
| .g-chart__legend, .g-bars { list-style: none; padding: 0; margin: 0; } | ||
| .g-chart__legend { display: flex; flex-wrap: wrap; gap: var(--g-space-3); } | ||
| .g-chart__legend li { display: flex; align-items: center; gap: var(--g-space-2); } | ||
| .g-chart__legend i { width: .75em; height: .75em; border-radius: var(--g-r-sm); } | ||
| .g-chart summary { cursor: pointer; width: fit-content; } | ||
| .g-chart summary:focus-visible { outline: var(--g-focus); outline-offset: var(--g-focus-offset); } | ||
| .g-spark { display: inline-flex; align-items: center; gap: var(--g-space-2); } | ||
| .g-spark svg { width: 120px; height: 32px; color: var(--g-viz-1); } | ||
| .g-spark path { fill: none; stroke: currentColor; stroke-width: 2; } | ||
| .g-spark circle { fill: currentColor; } | ||
| .g-bars { display: grid; gap: var(--g-space-3); } | ||
| .g-bars li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--g-space-1) var(--g-space-3); overflow-wrap: anywhere; } | ||
| .g-bars__value { font-variant-numeric: tabular-nums; } | ||
| .g-bars svg { grid-column: 1 / -1; width: 100%; height: 8px; } | ||
| .g-chart .g-tbl tbody th { position: static; font: inherit; font-size: 13px; color: var(--g-ink); background: none; border-bottom: 1px solid var(--g-hair); white-space: normal; } | ||
| .g-chart .g-tbl tbody tr:last-child th { border-bottom: 0; } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import { cleanup, render, screen, within, fireEvent } from "@testing-library/react"; | ||
| import { afterEach, expect, it } from "vitest"; | ||
| import { ChartFrame } from "./ChartFrame"; | ||
| import { Sparkline } from "./Sparkline"; | ||
| import { BarList } from "./BarList"; | ||
|
|
||
| afterEach(cleanup); | ||
| const data = [{ label: "Mo", value: -2 }, { label: "Di", value: 0 }, { label: "Mi", value: 4 }]; | ||
|
|
||
| it("the table twin shows the same values as the chart", () => { | ||
| render(<ChartFrame title="Aufträge" caption="Diese Woche" kind="bars" data={data} />); | ||
| expect(screen.getByRole("heading", { name: "Aufträge" })).toBeTruthy(); | ||
| expect(screen.getByText("Diese Woche")).toBeTruthy(); | ||
| const table = screen.getByRole("table", { name: "Aufträge" }); | ||
| expect(within(table).getAllByRole("row").map(row => row.textContent)).toEqual(["BezeichnungWert", "Mo-2", "Di0", "Mi4"]); | ||
| expect(screen.getByRole("list", { name: "Aufträge" }).textContent).toBe("Mo-2Di0Mi4"); | ||
| expect(screen.getByRole("list", { name: "Legende" }).textContent).toBe("MoDiMi"); | ||
| }); | ||
|
|
||
| it("the table disclosure opens only after clicking its summary", () => { | ||
| render(<ChartFrame title="Aufträge" caption="Diese Woche" kind="bars" data={data} />); | ||
| const summary = screen.getByText("Als Tabelle"); | ||
| const details = summary.closest("details")!; | ||
| expect(details.open).toBe(false); | ||
| summary.focus(); | ||
| expect(document.activeElement).toBe(summary); | ||
| fireEvent.click(summary); | ||
| expect(details.open).toBe(true); | ||
| }); | ||
|
|
||
| it("bars and table twins format fractions and thousands in German", () => { | ||
| render(<ChartFrame title="Messung" caption="Dezimalwerte" kind="bars" data={[{ label: "A", value: 1234.5 }, { label: "B", value: 0.1 + 0.2 }]} />); | ||
| const table = screen.getByRole("table", { name: "Messung" }); | ||
| expect(within(table).getAllByRole("cell").map(cell => cell.textContent)).toEqual(["1.234,5", "0,3"]); | ||
| expect(screen.getByRole("list", { name: "Messung" }).textContent).toBe("A1.234,5B0,3"); | ||
| }); | ||
|
|
||
| it("each bar has a text label and value beyond colour", () => { | ||
| const { container } = render(<BarList label="Messung" data={[...data, { label: "Do", value: NaN }]} />); | ||
| expect(screen.getByRole("list", { name: "Messung" }).textContent).toBe("Mo-2Di0Mi4DoUnbekannt"); | ||
| const rects = container.querySelectorAll("rect"); | ||
| expect([...rects].map(r => [r.getAttribute("x"), r.getAttribute("width")])).toEqual([["26", "24"], ["50", "0"], ["50", "48"]]); | ||
| }); | ||
|
|
||
| it("sparkline preserves gaps and renders single and constant samples", () => { | ||
| const { container, rerender } = render(<Sparkline label="Verlauf" data={[{ label: "A", value: 1 }, { label: "B", value: null }, { label: "C", value: 3 }]} />); | ||
| expect(screen.getByRole("img", { name: "Verlauf" })).toBeTruthy(); | ||
| expect(container.querySelector("path")?.getAttribute("d")).toBe("M2,30 M118,2"); | ||
| for (const values of [[0], [2, 2], [-Number.MAX_VALUE, Number.MAX_VALUE]]) { | ||
| rerender(<Sparkline label="Verlauf" data={values.map((value, i) => ({ label: String(i), value }))} />); | ||
| expect(container.querySelectorAll("circle")).toHaveLength(values.length); | ||
| expect(container.innerHTML).not.toMatch(/NaN|Infinity/); | ||
| } | ||
| }); | ||
|
|
||
| it("empty and unknown data are words in graphics and table twins", () => { | ||
| for (const kind of ["sparkline", "bars"] as const) { | ||
| for (const [data, word] of [[[], "Keine Daten"], [null, "Unbekannt"], [[{ label: "Mo", value: Infinity }], "Unbekannt"]] as const) { | ||
| const { unmount } = render(<ChartFrame title="Messung" caption="Beispiel" kind={kind} data={data} />); | ||
| expect(screen.getAllByText(word).length).toBeGreaterThanOrEqual(2); | ||
| expect(screen.getByText("Als Tabelle").tagName).toBe("SUMMARY"); | ||
| expect(screen.queryByRole("list", { name: "Legende" }) !== null).toBe(Boolean(data?.length)); | ||
| unmount(); | ||
| } | ||
| } | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { de } from "../../i18n/de"; | ||
|
|
||
| export interface ChartDatum { label: string; value: number | null } | ||
| export interface ChartProps { data: readonly ChartDatum[] | null; label: string } | ||
| const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a dataset contains legitimate values below the fixed three-decimal precision, such as Useful? React with 👍 / 👎. |
||
| export const known = (value: number | null): value is number => value !== null && Number.isFinite(value); | ||
| export const chartValue = (value: number | null) => known(value) ? numberFormat.format(value) : de["state.unknown"]; | ||
| export const chartEmpty = (data: ChartProps["data"]) => data === null ? de["state.unknown"] : de["chart.empty"]; | ||
| export const chartColour = (index: number) => `var(--g-viz-${index % 6 + 1})`; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This candidate's report records completed independent reviews and
review-ok, but the added row will still say that the draft is under review and the non-OpenAI review is outstanding after merge. Update the row to the delivered/reviewed state so the canonical plan is not immediately stale.AGENTS.md reference: AGENTS.md:L121-L121
Useful? React with 👍 / 👎.