diff --git a/docs/pages/doctor/charts/invalid-better.md b/docs/pages/doctor/charts/invalid-better.md new file mode 100644 index 000000000..40fc437ac --- /dev/null +++ b/docs/pages/doctor/charts/invalid-better.md @@ -0,0 +1,23 @@ +`better` colors a heatmap's cells from red through amber to green when its numbers rate something: `better="higher"` when a high number is good, such as a score, and `better="lower"` when a low one is, such as an error rate. Other charts have no cells to color. + +::incorrect +```md +::chart{type="heatmap" better="more" title="Score per section"} +| agent | features | auth | +| --- | --: | --: | +| first | 10 | 6.2 | +| second | 9 | 4.6 | +:: +``` +:: + +::correct +```md +::chart{type="heatmap" better="higher" title="Score per section"} +| agent | features | auth | +| --- | --: | --: | +| first | 10 | 6.2 | +| second | 9 | 4.6 | +:: +``` +:: diff --git a/docs/pages/doctor/charts/invalid-log.md b/docs/pages/doctor/charts/invalid-log.md new file mode 100644 index 000000000..bc15614dc --- /dev/null +++ b/docs/pages/doctor/charts/invalid-log.md @@ -0,0 +1,23 @@ +`log` puts numbers that span orders of magnitude on a log scale: alone for the numbers' axis, `log="x"` for the x column of a line or a scatter plot over numbers, or `log="both"`. Lines, scatter plots, and bar charts take it. A log scale has no zero, so stacks, areas, and pies, whose parts add up from zero, can't use one, and every number on a log axis must be above 0. + +::incorrect +```md +::chart{type="line" log title="Time to first reply (h)"} +| week | median | +| --- | --: | +| 2026-09-07 | 1.2 | +| 2026-09-14 | 0 | +:: +``` +:: + +::correct +```md +::chart{type="line" log title="Time to first reply (h)"} +| week | median | +| --- | --: | +| 2026-09-07 | 1.2 | +| 2026-09-14 | 0.6 | +:: +``` +:: diff --git a/docs/pages/doctor/charts/invalid-unit.md b/docs/pages/doctor/charts/invalid-unit.md new file mode 100644 index 000000000..7d3d495b5 --- /dev/null +++ b/docs/pages/doctor/charts/invalid-unit.md @@ -0,0 +1,23 @@ +`unit` prints a chart's numbers with their unit on its bars and its value axis. A gantt prints how long each bar lasts, and a sankey each node's amount, so neither takes `unit`. Put the unit in the amount's header instead. + +::incorrect +```md +::chart{type="sankey" unit="$" title="Where the snack budget went"} +| from | to | amount | +| --- | --- | --: | +| budget | coffee | 410 | +| budget | biscuits | 160 | +:: +``` +:: + +::correct +```md +::chart{type="sankey" title="Where the snack budget went"} +| from | to | amount ($) | +| --- | --- | --: | +| budget | coffee | 410 | +| budget | biscuits | 160 | +:: +``` +:: diff --git a/e2e/charts.e2e.ts b/e2e/charts.e2e.ts index 3da15b726..9af2761c8 100644 --- a/e2e/charts.e2e.ts +++ b/e2e/charts.e2e.ts @@ -297,7 +297,7 @@ test("hundreds of worst-case charts draw with no text colliding, clipped, or pas expect(problems).toEqual([]); }); -test("a chart beside a link keeps its own card, light and dark", async ({ page }) => { +test("a chart beside a link keeps its own hairline, light and dark", async ({ page }) => { const render = createHtmlRenderer({ plugins: [charts(), chips({ download: false })], components: { ...components, ...chipsComponents }, @@ -314,13 +314,14 @@ test("a chart beside a link keeps its own card, light and dark", async ({ page } await page.goto("/fixture/", { waitUntil: "domcontentloaded" }); const figure = page.locator("figure.cch"); await expect(page.locator("main a").first()).toHaveCSS("border-top-left-radius", "6px"); - for (const [scheme, background] of [ - ["light", "rgb(255, 255, 255)"], - ["dark", "rgb(10, 10, 10)"], + for (const [scheme, border] of [ + ["light", "rgb(229, 229, 229)"], + ["dark", "rgba(255, 255, 255, 0.1)"], ] as const) { await page.emulateMedia({ colorScheme: scheme }); - await expect(figure).toHaveCSS("background-color", background); - await expect(figure).toHaveCSS("border-top-left-radius", "10px"); + await expect(figure).toHaveCSS("border-top-color", border); + await expect(figure).toHaveCSS("border-left-width", "0px"); + await expect(figure).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); } }); diff --git a/packages/charts/docs/index.md b/packages/charts/docs/index.md index db10a33ba..7ccd9bbbc 100644 --- a/packages/charts/docs/index.md +++ b/packages/charts/docs/index.md @@ -1,7 +1,7 @@ --- title: Charts description: Charts from a Markdown table, drawn to SVG while parsing. -keywords: graph, plot, bar chart, line chart, pie chart, scatter plot, area chart, gantt, timeline, roadmap, schedule, sankey, flow diagram, alluvial, cashflow, box plot, heatmap, benchmark, data visualization, dataviz, Vega, Vega-Lite +keywords: graph, plot, report, dashboard, bar chart, line chart, pie chart, scatter plot, area chart, gantt, timeline, roadmap, schedule, sankey, flow diagram, alluvial, cashflow, box plot, heatmap, benchmark, data visualization, dataviz, Vega, Vega-Lite tagline: A chart drawn to SVG from a Markdown table, with the table kept under it. group: Charts and diagrams icon: chart-column @@ -103,7 +103,8 @@ credits: 'Laid out by Microsoft Research''s [!NOTE] > Lines, areas, scatter plots, and pies have nothing to print on, so the plugin reports `labels` on them. +### Heatmap + +A `heatmap` draws a cell for each row and column of its table, shaded from the faintest to the full first series color by its number, with the number printed in it. The columns' names sit over the cells, turned to read upward when they're wider than a cell. An empty cell draws gray, with a dash. + +::example +```md +::chart{type="heatmap" title="Questions per customer and type"} +| customer | bug | forecast | purchasing | alerts | +| --- | --: | --: | --: | --: | +| Northwind | 14 | 6 | 9 | 4 | +| Contoso | 8 | 9 | 5 | 6 | +| Fabrikam | 5 | 7 | 3 | | +:: +``` +:: + +Use the `better` prop when the numbers rate something, such as scores or pass rates: `better="higher"` colors the cells from red at the lowest number through amber to green at the highest, and `better="lower"` the other way round. + +::example +```md +::chart{type="heatmap" better="higher" title="Score per section, out of 10"} +| agent | features | data | auth | validation | +| --- | --: | --: | --: | --: | +| first | 10 | 10 | 10 | 6.2 | +| second | 10 | 9 | 9.6 | 4.6 | +| third | 1.9 | 7.6 | 6.7 | 0 | +:: +``` +:: + ### Gantt charts Use `type="gantt"` with a table of three columns: the bar's name, its start, and its end. Start and end hold numbers or dates. Each row is one bar, top to bottom in table order. @@ -437,7 +558,7 @@ Leave `data` out of the spec, and name the columns in its `encoding`. The spec c ```` :: -The chart gets the other types' card, colors, and typeface wherever the spec sets none, and the spec's own `config` wins over them. A spec chart takes only `title` from the props, and no notes. Draw notes with a `text` or `rule` layer in the spec. +The chart gets the other types' frame, colors, and typeface wherever the spec sets none, and the spec's own `config` wins over them. A spec chart takes only `title` and `description` from the props, and no notes. Draw notes with a `text` or `rule` layer in the spec. ### Copy @@ -461,10 +582,11 @@ To check documents without drawing them, as comark-linter does, pass `charts({ d ### Fit the column -Two charts of one table look alike anywhere: +Charts span the column, as a dashboard's do, and look alike anywhere: -- A chart keeps the size its text was set for instead of stretching to the column, so labels read the same in every chart. -- A narrower column or a printed page shrinks it to fit, like a screenshot. +- Every chart over axes is drawn 744px wide, labels included, and scales to the column's width. Its text scales with it, so labels read the same size in every chart on a page. +- Upright bars stay about 40px wide however few there are, or 22px each side by side, so a short table doesn't draw slabs. +- A pie and a heatmap keep their own size, shrinking only in a narrower column. - No row is ever dropped. A long sideways chart grows taller. ## Accessibility @@ -490,11 +612,15 @@ The Copy button is named "Copy the chart as an image", or "Copy the chart's data | Prop | Default | Description | | --- | --- | --- | -| `type` | `bar` | `bar`, `stacked`, `line`, `area`, `scatter`, `dumbbell`, `waterfall`, `pie`, `gantt`, or `sankey`. | +| `type` | `bar` | `bar`, `stacked`, `line`, `area`, `scatter`, `dumbbell`, `waterfall`, `pie`, `heatmap`, `gantt`, or `sankey`. | | `x` | The first column | The column along the x axis. | | `y` | Every column but `x` | The columns to draw, separated by commas. | | `title` | — | The chart's caption, and its name for screen readers. | -| `labels` | Off | Prints the numbers on the chart, as [Labels](#labels) shows. Takes no value. Lines, areas, scatter plots, and pies don't take it. | +| `description` | — | What to read in the chart, in a line under its title. | +| `unit` | — | The numbers' unit, as [Unit](#unit) shows: `$` before them, `%` or `ms` after. Gantts and sankeys don't take it. | +| `log` | Off | Puts an axis on a log scale, as [Log scale](#log-scale) shows: alone for the numbers' axis, `x`, or `both`. Lines, scatter plots, and bar charts take it. | +| `better` | — | `higher` or `lower`: colors a heatmap's cells from red to green, as [Heatmap](#heatmap) shows. | +| `labels` | On for bars, stacked bars, and heatmaps | Prints the numbers on the chart, as [Labels](#labels) shows. Write `labels=false` to turn it off. Lines, areas, scatter plots, and pies don't take it. | | `horizontal` | Off, or on past 40 bars | Turns bars sideways. Takes no value. Only `bar` and `stacked` charts take it. Dumbbells and gantts always run sideways. | ### Options @@ -521,6 +647,7 @@ The plugin adds these problems to `meta.issues`. Each issue names its chart by i - Content besides the table and its annotations. - Data that doesn't fit the type, such as a line or an area of one row, a scatter without numbers on x, a dumbbell with one column, a pie or a waterfall with several, and a pie with a negative number or nothing but zeros. - `labels` or `horizontal` with a value, or on a type that doesn't take it. +- `log` on a type or an axis that can't take it, or over a number that isn't above 0, `better` that isn't `higher` or `lower` or on a chart that isn't a heatmap, and `unit`, `log`, or `better` on a gantt or a sankey. - A waterfall whose last row isn't its first plus every change. - A chart that couldn't be drawn, which shows its table alone. - A sankey without a column to flow from, one to flow to, and one of amounts, a flow of 0 or less or into its own node, a loop, and a node that sends out more than it takes in. @@ -534,7 +661,7 @@ Rendering never throws. A chart with a problem draws what it can: - A cell that isn't a number is left out. - A type that doesn't fit draws bars. - A waterfall whose end total doesn't add up still draws its last row as a total. -- `labels` or `horizontal` on a type that doesn't take it is ignored. +- `labels`, `horizontal`, `log`, or `better` where it doesn't apply is ignored. - A pie or a waterfall draws only its first column. - A gantt row without a start or an end isn't drawn, and a gantt colors by its first column of names. - A sankey leaves out a flow it can't draw, and a sankey with a loop shows its table. @@ -579,7 +706,7 @@ The comark-ui agent skill knows the table shape for each chart type and how to c ## Theme -A chart is a card in the style of [shadcn/ui](https://ui.shadcn.com), so it switches with the page's dark mode. The rules sit in a `comark` CSS layer, so any plain rule of your own wins. +A chart is a section of a report, as a dashboard lays out its charts: a hairline over it, its title and description at the start, the chart, and its data folded under it, with no card around it. It switches with the page's dark mode. The rules sit in a `comark` CSS layer, so any plain rule of your own wins. ### Shared tokens @@ -588,36 +715,40 @@ Charts read these [shared tokens](/guide/theming), and their series read Nuxt UI | Token | Falls back to | Used for | | --- | --- | --- | | `--comark-foreground` | `--ui-text-highlighted`, `--foreground` | Values, the caption, the data table | -| `--comark-muted` | `--ui-text-muted`, `--muted-foreground` | Axis labels, the **Data** toggle, waterfall totals, the copy buttons' icons and the menu's hints | -| `--comark-border` | `--ui-border`, `--border` | The card, grid lines, the data table's rules, and the copy buttons' and the menu's outline | -| `--comark-background` | `--ui-bg`, `--background` | The card, the edges between slices and around points, the copy buttons, the menu, and a copied PNG's background | +| `--comark-muted` | `--ui-text-muted`, `--muted-foreground` | Axis labels, the description, the **Data** toggle, waterfall totals, the copy buttons' icons and the menu's hints | +| `--comark-border` | `--ui-border`, `--border` | The hairline over the chart, dotted grid lines, a heatmap's empty cells, the data table's rules, and the copy buttons' and the menu's outline | +| `--comark-background` | `--ui-bg`, `--background` | The edges between slices, the copy buttons, the menu, and a copied PNG's background | | `--comark-accent` | `--ui-bg-elevated`, `--accent` | A hovered copy button or menu item, and the arrow while its menu is open | | `--comark-ring` | `--ui-primary`, `--ring` | The focus ring on the **Data** toggle and the copy buttons | -| `--comark-radius` | twice `--ui-radius`, `--radius` | The card's, the copy buttons', and the menu's corners | +| `--comark-radius` | twice `--ui-radius`, `--radius` | The copy buttons' and the menu's corners | | `--comark-shadow` | A soft shadow, deeper in dark mode | The menu | | `--comark-font` | `--font-sans` | All text | | `--comark-chart-1` to `-6` | `--ui-chart-1` to `-6`, `--chart-1` to `-6` | Series, in column order | -Without either, series use six colors that read on light and dark pages: blue, orange, green, purple, rose, and yellow. A waterfall's increases and decreases are series 3 and 5. +Without either, series use twelve muted colors at one lightness, each at 3:1 on white and 6:1 on black: blue, tan, teal, violet, rose, amber, sky, green, pink, slate, olive, and indigo. A waterfall's increases and decreases are series 3 and 5, and a heatmap shades series 1. A heatmap with `better` uses its own red, amber, and green, which stay the same in both themes. ### Variables | Variable | Default | Used for | | --- | --- | --- | -| `--cch-padding` | `1.5rem` | The space inside the card's sides. The **Data** footer runs to the card's edges, so it follows. | +| `--cch-padding` | `0` | The space inside the chart's sides. Set it, with a border, to frame a chart as a card. | Marks on a chart use comark-annotate's styles, and its [variables](/annotate#variables) for their colors. ### Classes -A chart is a `figure.cch` with `data-type`, such as `data-type="bar"`, holding a `figcaption`, the SVG in `.cch-plot`, and the table in `details.cch-data` as its footer. A chart with marks adds `.cch-marked` to `.cch-plot`. `.cch-bar`, over the card's top corner, holds `.cch-status` and `.cch-copy`. `.cch-copy` holds the Copy button `.cch-copy-main`, the arrow `.cch-copy-more`, and the menu `.cch-menu`, whose items are `button[data-chart-copy]`. +A chart is a `figure.cch` with `data-type`, such as `data-type="bar"`, holding a `figcaption`, the description in `p.cch-description`, the SVG in `.cch-plot`, and the table in `details.cch-data` as its footer. A chart with marks adds `.cch-marked` to `.cch-plot`. `.cch-bar`, over the chart's top corner, holds `.cch-status` and `.cch-copy`. `.cch-copy` holds the Copy button `.cch-copy-main`, the arrow `.cch-copy-more`, and the menu `.cch-menu`, whose items are `button[data-chart-copy]`. ```css :root { --comark-chart-1: oklch(0.65 0.2 250); } +/* A chart framed as a card. */ .cch { - --cch-padding: 1rem; + --cch-padding: 1.5rem; + padding-bottom: 1rem; + border: 1px solid var(--comark-border, #e5e5e5); + border-radius: 0.75rem; } ``` diff --git a/packages/charts/docs/notes.md b/packages/charts/docs/notes.md index 585340996..ca58a2c32 100644 --- a/packages/charts/docs/notes.md +++ b/packages/charts/docs/notes.md @@ -46,7 +46,7 @@ The playground opens the editor right away, so it stays slow. :::example{label="Target line"} ```md -::chart{labels title="Minutes to the first review comment"} +::chart{title="Minutes to the first review comment"} | team | minutes | | --- | --: | | frontend | 38 | diff --git a/packages/charts/src/colors.ts b/packages/charts/src/colors.ts index 2f547e4ff..d1898cb8a 100644 --- a/packages/charts/src/colors.ts +++ b/packages/charts/src/colors.ts @@ -10,19 +10,20 @@ export const INK = { grid: "#ececec", background: "#ffffff", } as const; -// Tailwind's 500 shades, bar a brown for the tenth, read on white and on black, so the defaults work in both themes. -// Past the palette, Flint would merge series into a gray "Others", so there are enough for any table a review holds. +// Muted tones at one lightness in OKLCH (0.66), so no series shouts over another: each reads at 3:1 on white and 6:1 on +// black, so the defaults work in both themes. Past the palette, Flint would merge series into a gray "Others", so +// there are enough for any table a review holds. export const SERIES = [ - "#3b82f6", - "#f97316", - "#10b981", - "#a855f7", - "#f43f5e", - "#eab308", - "#06b6d4", - "#ec4899", - "#84cc16", - "#a8774a", - "#14b8a6", - "#f59e0b", + "#4d97de", + "#bb8558", + "#3ca694", + "#a37ecd", + "#da6a76", + "#b8892d", + "#419fc0", + "#69a45c", + "#c673a3", + "#8693a5", + "#90994e", + "#8489da", ] as const; diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index 35c53a477..9747d6357 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -70,21 +70,22 @@ const theme: ThemeSpec = { }, accent: SERIES[1], }, - // shadcn/ui's charts: no axis lines or ticks, faint horizontal grid lines, rounded bars, smooth lines without dots, and - // 12px labels. + // A report's charts: no axis lines or ticks, faint dotted grid lines, bars barely rounded, straight lines between the + // points they join, and 12px labels. structure: { axis: { categorical: { line: "omit", ticks: "omit" }, measure: { line: "omit", ticks: "omit" }, }, - grid: { measure: "quiet", category: "omit", style: "solid", zero: "omit" }, + grid: { measure: "quiet", category: "omit", style: "dotted", dash: [2, 3], zero: "omit" }, frame: "omit", baseline: "omit", }, marks: { - cornerRadius: 4, - strokeWeight: 2, - interpolation: "monotone", + cornerRadius: 2, + strokeWeight: 1.5, + // A smoothed line passes through values the table doesn't hold, and overshoots between steep steps. + interpolation: "linear", fillOpacity: 0.4, point: { presence: "omit" }, }, @@ -135,7 +136,7 @@ function clip(lines: string[], room: number) { } // A top legend is one row unless told otherwise, so it wraps into as many columns as fit the card. -function legendColumns(chart: Chart, room = 520) { +function legendColumns(chart: Chart, room = PLOT) { const names = chart.type === "pie" ? chart.rows.map((row) => row.x) @@ -148,6 +149,35 @@ function legendColumns(chart: Chart, room = 520) { return Math.max(1, Math.min(names.length, Math.floor(room / widest))); } +const NAME_LIMIT = 200; + +/** + * Whether a line chart names its series at their ends, in their colors, rather than in a legend over the plot: a + * reader finds a line by its name where it ends, without matching swatches. Past eight the names would stack; + * long names use the width-limited legend so their padding cannot consume the plot. + */ +const endLabeled = (chart: Chart) => + chart.type === "line" && + chart.series.length > 1 && + chart.series.length <= 8 && + chart.series.every((name) => guess(name, 12) <= NAME_LIMIT); + +// A line marks each of its points while they're few enough to stay apart. +const MOST_POINTS = 40; + +// A plot's widest, and the room it shares with its labels: about a report column's width, which the stylesheet then +// fills, so a chart spans the column as a dashboard's does. +const PLOT = 640; +const ROOM = 720; +// A plot's height, about half its width, so a trend's slope reads as it does on a dashboard. +const TALL = 340; +// Every chart's drawn width, padding included. +const WIDTH = ROOM + 24; + +/** The share of a band left empty so an upright bar is at most `widest` pixels, as on a dashboard, never under `least`. */ +const slim = (chart: Chart, widest: number, least = 0.35) => + Math.min(0.85, Math.max(least, 1 - widest / ((WIDTH - 80) / chart.rows.length))); + // The most bars an upright plot holds at a readable width. const MOST_UPRIGHT = 40; @@ -159,20 +189,23 @@ function layout(chart: Chart) { return { flip: true, bars: chart.rows.length, - width: Math.round(Math.max(200, Math.min(520, 600 - (7 * longest(chart) + 40)))), + width: Math.round(Math.max(200, Math.min(PLOT, ROOM - (7 * longest(chart) + 40)))), }; const bars = chart.rows.length * (type === "bar" ? chart.series.length : 1); // Bars stand upright unless `horizontal` turns them, or there are more than a plot's width holds, which would drop some. const flip = type === "dumbbell" || ((type === "bar" || type === "stacked") && (!!chart.horizontal || bars > MOST_UPRIGHT)); - // The card leaves about 670px. Labels sit beside the plot, so the plot gets what the widest of them leaves: names on + // The plot leaves about 670px for the chart. Labels sit beside the plot, so the plot gets what the widest of them leaves: names on // a sideways chart, and values on an upright one. const numbers = chart.rows.flatMap((row) => row.values.filter((value) => value !== undefined)); const valueLabel = - Math.max(...numbers.map((value) => Math.round(value).toLocaleString("en-US").length)) * 7; - const labels = flip ? 7 * longest(chart) + 40 : valueLabel + 40; - const width = Math.round(Math.max(200, Math.min(520, 600 - labels))); + Math.max(...numbers.map((value) => compact(Math.round(value), chart.unit).length)) * 7; + const ends = endLabeled(chart) + ? Math.max(...chart.series.map((name) => guess(name, 12))) + 24 + : 0; + const labels = flip ? 7 * longest(chart) + 40 : valueLabel + 40 + ends; + const width = Math.round(Math.max(200, Math.min(PLOT, ROOM - labels))); return { flip, bars, width }; } @@ -211,6 +244,8 @@ function input(chart: Chart): ChartAssemblyInput { dumbbell: "Ranged Dot Plot", waterfall: "Waterfall Chart", pie: "Pie Chart", + // Drawn apart from Flint, by `heatmap()`. + heatmap: "Heatmap", }[type]; return { data: { values }, @@ -219,7 +254,7 @@ function input(chart: Chart): ChartAssemblyInput { chart_spec: { chartType, encodings, - baseSize: { width, height: 320 }, + baseSize: { width, height: TALL }, // Flint grows a crowded chart, but only downward: the width is already what the labels leave. Past the canvas // Flint drops rows and says "items omitted", so a sideways chart grows as tall as its bars need. canvasSize: { width, height: flip ? Math.max(640, bars * 20 + 200) : 640 }, @@ -365,6 +400,7 @@ interface SceneItem { bounds?: { x1: number; y1: number; x2: number; y2: number }; datum?: { row?: unknown; series?: unknown; y?: unknown; value?: unknown }; fill?: string; + stroke?: string; mark?: { marktype?: string; role?: string }; items?: SceneItem[]; } @@ -400,7 +436,9 @@ export function boxes( const found = new Map(); // Vega draws the scene at its origin plus its padding, and origin() leaves the padding out. const [x0, y0] = view.origin(); - const [left, top] = [x0 + PADDING.left, y0 + PADDING.top]; + // SAFETY: vega-view's padding() getter normalizes every side to a number, even for numeric inputs. + const padding = view.padding() as Required>; + const [left, top] = [x0 + padding.left, y0 + padding.top]; for (const mark of chart.marks) { let [x1, y1, x2, y2] = [Infinity, Infinity, -Infinity, -Infinity]; if (mark.value !== undefined && chart.type !== "sankey") { @@ -464,6 +502,63 @@ export function boxes( export const format = (value: number) => value.toLocaleString("en-US", { maximumFractionDigits: 12 }); +// A currency sign reads before its number, a percent or a times sign right after it, and a word after a space. +function affixes(unit = ""): [string, string] { + if (/^\p{Sc}$/u.test(unit)) return [unit, ""]; + if (unit === "%" || unit === "×" || unit === "x") return ["", unit]; + return ["", unit && ` ${unit}`]; +} + +// Numbers from a thousand up read as their first three digits and a letter, as `22.2k` or `1.4M`: a reader compares +// magnitudes on a chart, and the table under it keeps every digit. +const SHORT: [number, string][] = [ + [1e9, "B"], + [1e6, "M"], + [1e3, "k"], +]; + +/** A number as a chart prints it: shortened from a thousand up, with its unit. */ +export function compact(value: number, unit?: string) { + const [before, after] = affixes(unit); + const size = Math.abs(value); + const short = SHORT.find(([from]) => size >= from); + const body = short + ? `${format(Number((size / short[0]).toPrecision(3)))}${short[1]}` + : format(size); + return `${value < 0 ? "-" : ""}${before}${body}${after}`; +} + +/** + * Ticks for a log scale: 1, 2, and 5 in every power of ten the numbers span, or the powers of ten alone when those + * would crowd the axis, so each grid line marks a round number. + */ +function logTicks(low: number, high: number) { + if (!(low > 0) || !Number.isFinite(high)) return []; + const steps = (factors: number[]) => { + const values: number[] = []; + for (let power = Math.floor(Math.log10(low)); power <= Math.ceil(Math.log10(high)); power++) + for (const factor of factors) { + const value = Number((factor * 10 ** power).toPrecision(12)); + if (value >= low && value <= high) values.push(value); + } + return values; + }; + const fine = steps([1, 2, 5]); + return fine.length <= 8 ? fine : steps([1]); +} + +/** The same as `compact`, as a Vega expression over `value`, for the labels Vega prints itself, such as an axis's. */ +function compactExpr(value: string, unit?: string) { + const [before, after] = affixes(unit); + const size = `abs(${value})`; + const body = SHORT.reduceRight( + (rest, [from, letter]) => + `${size} >= ${from} ? format(${size} / ${from}, ".3~r") + "${letter}" : ${rest}`, + `format(${size}, ",.12~r")`, + ); + return `(${value} < 0 ? "-" : "") + ${JSON.stringify(before)} + (${body}) + ${JSON.stringify(after)}`; +} + interface Label { x: number; y: number; @@ -474,9 +569,9 @@ interface Label { } /** - * Where each bar's value goes, in the root group's frame: inside its bar or segment where it fits, else just past the - * bar's end. A segment in the middle of a stack has no end to sit past, so one too thin for its value leaves it to the - * data table, as does a value that would cover another bar or label. + * Where each bar's value goes, in the root group's frame: just past a bar's end, or for a stack, its total past its end + * and each segment's value inside it where it fits. A segment too thin for its value leaves it to the data table, as + * does a value that would cover another bar or label. */ function valueLabels(chart: Chart, view: vega.View): Label[] { const { flip } = layout(chart); @@ -506,8 +601,9 @@ function valueLabels(chart: Chart, view: vega.View): Label[] { }); const HEIGHT = 12; const GAP = 6; - // Values keep this far from each other and from the axes' labels. - const MARGIN = 3; + // Values keep this far from each other and from the axes' labels, by their guessed widths, which run a little under + // the drawn ones. + const MARGIN = 6; const taken: Box[] = []; walk(view, (item, dx, dy) => { const { bounds } = item; @@ -520,34 +616,27 @@ function valueLabels(chart: Chart, view: vega.View): Label[] { }); }); const labels: Label[] = []; - for (const bar of bars) { - const text = format(bar.value); - const width = guess(text, 12); + type Bar = (typeof bars)[number]; + // `inside` prints a stacked segment's number in white in its middle, or nothing when it doesn't fit. Otherwise + // the number goes past the bar's end, as a dashboard does. + const place = (bar: Bar, inside: boolean) => { + const text = compact(bar.value, chart.unit); + const width = guess(text, 11); const up = bar.value >= 0; const [long, thick] = flip ? [bar.x2 - bar.x1, bar.y2 - bar.y1] : [bar.y2 - bar.y1, bar.x2 - bar.x1]; - const fits = flip - ? long >= width + 2 * GAP && thick >= HEIGHT + 4 - : long >= HEIGHT + 2 * GAP && thick >= width + 8; - const beyond = (other: typeof bar) => - flip - ? up - ? other.x2 > bar.x2 - : other.x1 < bar.x1 - : up - ? other.y1 < bar.y1 - : other.y2 > bar.y2; - const last = - !stacked || - !bars.some((other) => other.row === bar.row && other.value >= 0 === up && beyond(other)); - if (!fits && !last) continue; + if ( + inside && + !(flip + ? long >= width + 2 * GAP && thick >= HEIGHT + 4 + : long >= HEIGHT + 2 * GAP && thick >= width + 8) + ) + return; const [cx, cy] = [(bar.x1 + bar.x2) / 2, (bar.y1 + bar.y2) / 2]; - // Inside, a bar's value sits at its end, and a segment's in its middle. Past the end it's a gap beyond it. - const middle = fits && stacked; - const out = (fits ? -1 : 1) * (up ? 1 : -1); - const x = !flip || middle ? cx : (up ? bar.x2 : bar.x1) + out * (GAP + width / 2); - const y = flip || middle ? cy : (up ? bar.y1 : bar.y2) - out * (GAP + HEIGHT / 2); + const out = up ? 1 : -1; + const x = !flip || inside ? cx : (up ? bar.x2 : bar.x1) + out * (GAP + width / 2); + const y = flip || inside ? cy : (up ? bar.y1 : bar.y2) - out * (GAP + HEIGHT / 2); const box = { x: x - width / 2, y: y - HEIGHT / 2, w: width, h: HEIGHT }; const covers = (other: Box, margin = MARGIN) => box.x - margin < other.x + other.w && @@ -555,28 +644,63 @@ function valueLabels(chart: Chart, view: vega.View): Label[] { box.y - margin < other.y + other.h && other.y < box.y + box.h + margin; if ( - taken.some(covers) || - (!fits && - bars.some( - (other) => - other !== bar && - covers({ x: other.x1, y: other.y1, w: other.x2 - other.x1, h: other.y2 - other.y1 }, 0), + taken.some((other) => covers(other)) || + (!inside && + bars.some((other) => + covers({ x: other.x1, y: other.y1, w: other.x2 - other.x1, h: other.y2 - other.y1 }, 0), )) ) - continue; + return; taken.push(box); labels.push({ x, y, text, - fill: fits ? INK.background : INK.foreground, + fill: inside ? INK.background : INK.foreground, row: bar.row, series: bar.series, }); + }; + if (!stacked) { + for (const bar of bars) place(bar, false); + return labels; + } + // A number past a stack reads as its total, so that's what goes there: the sum of its segments on that side of + // zero. A segment's own number goes only inside it, and an empty segment has none. + const stacks = new Map(); + for (const bar of bars) { + if (bar.value === 0) continue; + place(bar, true); + // Use the rendered lane, since different timestamp spellings can share one position and stack together. + const key = `${flip ? bar.y1 : bar.x1}\u0000${flip ? bar.y2 : bar.x2}\u0000${bar.value >= 0}`; + const stack = stacks.get(key); + stacks.set( + key, + stack + ? { + ...stack, + value: stack.value + bar.value, + x1: Math.min(stack.x1, bar.x1), + y1: Math.min(stack.y1, bar.y1), + x2: Math.max(stack.x2, bar.x2), + y2: Math.max(stack.y2, bar.y2), + } + : { ...bar, series: undefined }, + ); } + for (const stack of stacks.values()) place(stack, false); return labels; } +// Charts whose series are the table's columns, one color each. +const COLUMNS = new Set(["bar", "stacked", "line", "area", "scatter", "dumbbell"]); + +const sum = (values: (number | undefined)[], side: (value: number) => boolean) => + values.reduce( + (total, value) => (value !== undefined && side(value) ? total + value : total), + 0, + ); + // The labels draw as one text mark over the chart, in the type Flint sets its own values in. const LABELS: vega.Mark = { type: "text", @@ -591,12 +715,103 @@ const LABELS: vega.Mark = { align: { value: "center" }, baseline: { value: "middle" }, font: { value: "sans-serif" }, - fontSize: { value: 12 }, + fontSize: { value: 11 }, fontWeight: { value: 500 }, }, }, }; +interface EndLabel { + x: number; + y: number; + /** Where the line ends, which a short leader joins to the name when the name moved off its height. */ + x0: number; + y0: number; + text: string; + fill: string; + series: string; +} + +// Names at the ends of lines keep a 12px line's box and a few pixels apart, so stacked names never touch. +const END_GAP = 20; + +/** + * Where each line's name goes, in the root group's frame: past the plot's right edge at the height its line ends, + * pushed apart where two ends sit closer than a line of text, and kept inside the plot's height. + */ +function endLabels(chart: Chart, view: vega.View): EndLabel[] { + const last = new Map(); + walk(view, (item, dx, dy) => { + const { datum } = item; + if ( + item.mark?.marktype !== "line" || + !datum || + item.x === undefined || + item.y === undefined || + !item.stroke + ) + return; + const series = String(datum.series); + const seen = last.get(series); + const x = item.x + dx; + if (!seen || x > seen.x) last.set(series, { x, y: item.y + dy, fill: item.stroke }); + }); + const right = view.width() + 10; + const ends = chart.series.flatMap((series) => { + const end = last.get(series); + return end ? [{ series, x0: end.x, y0: end.y, y: end.y, fill: end.fill }] : []; + }); + ends.sort((a, b) => a.y - b.y); + for (let index = 1; index < ends.length; index++) + ends[index]!.y = Math.max(ends[index]!.y, ends[index - 1]!.y + END_GAP); + // Pushed past the bottom, the stack moves back up as a whole. + const over = (ends.at(-1)?.y ?? 0) - view.height(); + if (over > 0) + for (let index = ends.length - 1; index >= 0; index--) { + const floor = index === ends.length - 1 ? view.height() : ends[index + 1]!.y - END_GAP; + ends[index]!.y = Math.min(ends[index]!.y, floor); + } + return ends.map((end) => ({ ...end, x: right, text: end.series })); +} + +// A name in its line's color, and a faint leader from where the line ends. +const END_LABELS: vega.Mark[] = [ + { + type: "rule", + from: { data: "cch_ends" }, + aria: false, + encode: { + enter: { + x: { signal: "datum.x0 + 4" }, + y: { field: "y0" }, + x2: { signal: "datum.x - 4" }, + y2: { field: "y" }, + stroke: { field: "fill" }, + strokeOpacity: { value: 0.5 }, + strokeWidth: { value: 1 }, + }, + }, + }, + { + type: "text", + from: { data: "cch_ends" }, + aria: false, + encode: { + enter: { + x: { field: "x" }, + y: { field: "y" }, + text: { field: "text" }, + fill: { field: "fill" }, + align: { value: "left" }, + baseline: { value: "middle" }, + font: { value: "sans-serif" }, + fontSize: { value: 12 }, + fontWeight: { value: 500 }, + }, + }, + }, +]; + interface Stub { x: number; y: number; @@ -730,8 +945,8 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { return { least, most, - left: (guess(format(least), 12) + 24) / width, - right: most > least ? (guess(format(most), 12) + 24) / width : 0, + left: (guess(compact(least, chart.unit), 12) + 24) / width, + right: most > least ? (guess(compact(most, chart.unit), 12) + 24) / width : 0, }; }); // On a plot too narrow for the text, padding for it would squeeze the dots into a sliver, so where the text would @@ -752,7 +967,7 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { } x.scale = { domain: [low, high], nice: false, zero: false }; // Vega thins given values down to the tick count Flint sets, so the count is the values'. - const grid = { grid: true, gridColor: INK.grid, gridWidth: 1 }; + const grid = { grid: true, gridColor: INK.grid, gridWidth: 1, gridDash: [2, 3] }; x.axis = { ...("axis" in x && x.axis), ...grid, @@ -797,6 +1012,10 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { // Series that tie at a row's end share one dot, so the row prints that value once. { window: [{ op: "row_number" as const, as: "nth" }], groupby: [rowField] }, { filter: "datum.nth === 1" }, + { + calculate: compactExpr(`datum[${JSON.stringify(valueField)}]`, chart.unit), + as: "cch_text", + }, ], mark: { type: "text" as const, @@ -806,7 +1025,7 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { fontSize: 12, fill: INK.muted, }, - encoding: { text: { field: valueField, type: "quantitative" as const, format: ",.12~g" } }, + encoding: { text: { field: "cch_text", type: "nominal" as const } }, }); if (printed) spec.layer.push(label("lo"), label("hi")); } @@ -951,6 +1170,7 @@ function gantt(chart: Chart, spec: TopLevelSpec) { grid: true, gridColor: INK.grid, gridWidth: 1, + gridDash: [2, 3], // The theme hides the value axis where values sit on the marks, but a duration doesn't say when a bar starts. labels: true, labelFlush: false, @@ -1023,7 +1243,133 @@ export async function render(compiled: vega.Spec, loader?: vega.Loader) { * Draws a chart to an SVG string at the size Flint laid it out for, so its text reads at the size it was set, and * finds where each of its marks sits. */ +// A heatmap's cells: this tall, as wide as the room allows between these, and red, amber, and green when the numbers +// rate something. Those three aren't series colors, so the stylesheet leaves them as they are in both themes, and the +// numbers on them stay dark. +const CELL = { height: 26, least: 30, most: 64 }; +const RATING = ["#d9605c", "#d9a74a", "#4fa66a"]; +const ON_RATING = "#141414"; + +/** A rating's color, from red at 0 through amber at a half to green at 1, mixed in sRGB as a hex Vega can draw. */ +function rating(share: number) { + const [from, to, at] = + share < 0.5 ? [RATING[0]!, RATING[1]!, share * 2] : [RATING[1]!, RATING[2]!, share * 2 - 1]; + const channel = (hex: string, index: number) => + Number.parseInt(hex.slice(1 + index * 2, 3 + index * 2), 16); + return `#${[0, 1, 2] + .map((index) => + Math.round(channel(from, index) + (channel(to, index) - channel(from, index)) * at) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; +} + +/** + * A heatmap, as a report lays out one table against another: a row per table row, a column per series, each cell + * shaded by its number from the faintest to the full first series color, the columns' names over them, and every number + * printed in its cell. + */ +async function heatmap(chart: Chart): Promise<{ svg: string; boxes: Map }> { + const numbers = chart.rows.flatMap((row) => row.values.filter((value) => value !== undefined)); + const [low, high] = [Math.min(...numbers), Math.max(...numbers)]; + const share = (value: number) => (high === low ? 1 : (value - low) / (high - low)); + const names = Math.max(...chart.rows.map((row) => guess(row.x, 12))) + 16; + const cell = Math.round( + Math.max(CELL.least, Math.min(CELL.most, (ROOM - names) / chart.series.length)), + ); + // A column's name turns to read upward when it's wider than its column, as a matrix's headers do. + const turned = chart.series.some((name) => guess(name, 12) > cell - 6); + const rated = chart.better !== undefined; + const values = chart.rows.flatMap((row, number) => + chart.series.map((series, index) => { + const value = row.values[index]; + const strength = value === undefined ? 0 : share(value); + const text = value === undefined ? "–" : chart.labels ? compact(value, chart.unit) : ""; + return { + x: row.x, + series, + y: value ?? null, + row: number, + text: guess(text, 12) + 6 <= cell * 0.94 ? text : "", + opacity: value === undefined ? 1 : rated ? 1 : 0.12 + 0.88 * strength, + fill: + value === undefined + ? INK.border + : rated + ? rating(chart.better === "lower" ? 1 - strength : strength) + : SERIES[0], + ink: + value === undefined + ? INK.muted + : rated + ? ON_RATING + : strength > 0.55 + ? INK.background + : INK.foreground, + }; + }), + ); + const fill = { field: "fill", type: "nominal" as const, scale: null, legend: null }; + const spec: TopLevelSpec = { + background: "transparent", + padding: PADDING, + data: { values }, + width: cell * chart.series.length, + height: CELL.height * chart.rows.length, + encoding: { + x: { + field: "series", + type: "nominal", + sort: chart.series, + scale: { paddingInner: 0.06, paddingOuter: 0 }, + axis: { + orient: "top", + title: null, + labelAngle: turned ? -40 : 0, + labelAlign: turned ? "left" : "center", + labelBaseline: turned ? "middle" : "bottom", + labelLimit: 180, + labelPadding: 6, + labelColor: INK.foreground, + }, + }, + y: { + field: "x", + type: "nominal", + sort: chart.rows.map((row) => row.x), + scale: { paddingInner: 0.08, paddingOuter: 0 }, + axis: { title: null, labelLimit: 200, labelPadding: 8, labelColor: INK.foreground }, + }, + }, + layer: [ + { + mark: { type: "rect", cornerRadius: 2 }, + encoding: { fill, opacity: { field: "opacity", type: "quantitative", scale: null } }, + }, + { + mark: { type: "text", fontSize: 12, fontWeight: 500 }, + encoding: { + text: { field: "text" }, + fill: { field: "ink", type: "nominal", scale: null, legend: null }, + }, + }, + ], + config: { + ...SPEC_CONFIG, + range: { category: [...SERIES] }, + axis: { ...SPEC_CONFIG.axis, grid: false, gridDash: [] }, + }, + }; + const { view, svg } = await render(compile(spec, { logger: quiet }).spec); + const [, width = "0", height = "0"] = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg) ?? []; + const found = boxes(chart, view, Number(width), Number(height)); + view.finalize(); + return { svg, boxes: found }; +} + export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map }> { + if (chart.type === "heatmap") return heatmap(chart); // SAFETY: Flint declares its output `any`; it is a Vega-Lite spec, and compile() checks it at runtime. const spec: TopLevelSpec = assembleVegaLite(input(chart)); spec.background = "transparent"; @@ -1032,6 +1378,36 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map mark.mark === "circle") ? { ...PADDING, bottom: PADDING.bottom + RING_ROOM } : PADDING; + // Every chart over axes is drawn exactly as wide, labels and padding included, so the stylesheet scales them all by + // the same factor to fill the column, and their text matches from chart to chart. Names at the ends of lines sit + // past the plot, in room kept on its right. + if (chart.type !== "pie") { + const ends = endLabeled(chart) + ? Math.max(...chart.series.map((name) => guess(name, 12))) + 24 + : 0; + // A bar's number prints past its end, so the side the bars grow toward keeps room for the widest one. + const { flip } = layout(chart); + const past = + chart.labels && (chart.type === "bar" || chart.type === "stacked") + ? flip + ? Math.max( + ...chart.rows.flatMap((row) => + (chart.type === "stacked" + ? [sum(row.values, (value) => value > 0), sum(row.values, (value) => value < 0)] + : row.values + ).map((value) => (value ? guess(compact(value, chart.unit), 11) : 0)), + ), + ) + 8 + : 20 + : 0; + spec.padding = { + ...spec.padding, + right: PADDING.right + ends + (flip ? past : 0), + top: PADDING.top + (flip ? 0 : past), + }; + Reflect.set(spec, "width", WIDTH); + spec.autosize = { type: "fit-x", contains: "padding" }; + } // With several series no one header names the numbers, and Flint would title the axis with the field, `y`. A // waterfall's legend sits where its axis title would, and its layers draw running totals rather than `y`. const waterfall = chart.type === "waterfall"; @@ -1049,7 +1425,9 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map clip(wrap(row.x, step - 12), step - 12)); @@ -1063,7 +1441,7 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map 1 ? lanes * 22 : 40); + // Bars over dates sit on a time scale, which has no bands: Flint sizes them instead. + const dated = band && "type" in band && band.type === "temporal"; + if (dated && "mark" in unit && unit.mark instanceof Object && "size" in unit.mark) + unit.mark = { ...unit.mark, size: Math.min(Number(unit.mark.size), lanes > 1 ? 22 : 40) }; + else if (band && "field" in band && band.field === "x") + band.scale = { + ...("scale" in band && band.scale), + paddingInner: thin, + paddingOuter: thin / 2, + }; + const offset = flip ? unit.encoding.yOffset : unit.encoding.xOffset; + if (offset && "field" in offset) + offset.scale = { ...("scale" in offset && offset.scale), paddingInner: 0.08 }; + } for (const channel of [x, y]) if (channel && "axis" in channel && channel.axis) channel.axis = { @@ -1160,6 +1593,11 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map row.values.filter((value) => value !== undefined)) + : chart.rows.map((row) => Number(row.x.replaceAll(",", ""))); + const [low, high] = [Math.min(...span), Math.max(...span)]; + // Flint pins a scatter plot's axes at 0, which a log scale has no place for. + const { + domainMin: _min, + domainMax: _max, + ...scale + } = "scale" in channel && channel.scale ? channel.scale : {}; + channel.scale = { + ...scale, + type: "log", + nice: false, + zero: false, + domain: [low / 1.25, high * 1.25], + }; + // The ticks are chosen to stand apart, so Vega mustn't thin them further. + channel.axis = { + ...channel.axis, + values: logTicks(low / 1.25, high * 1.25), + labelOverlap: false, + }; + } + } + // With every bar printing its number, Flint hides the value axis but keeps its title, which then names nothing. + if (chart.labels && (chart.type === "bar" || chart.type === "stacked")) + for (const channel of [x, y]) + if (channel && "field" in channel && channel.field === "y") channel.title = null; // Vega-Lite rounds a stack as a whole, which costs a clipped group per bar, so bars that stack nothing don't stack. // Rows that repeat an `x`, which the plugin reports, still stack rather than hide one behind the other. if (chart.type === "bar" && new Set(chart.rows.map((row) => row.x)).size === chart.rows.length) for (const channel of [x, y]) if (channel && "field" in channel && channel.field === "y") channel.stack = null; if (waterfall && x && "field" in x && x.field === "x") - x.scale = { ...("scale" in x && x.scale), paddingOuter: flat ? 0.4 : 1 }; + x.scale = { + ...("scale" in x && x.scale), + paddingInner: slim(chart, 48, 0.28), + // The first step's label centers under it, clear of the value axis's 0 in the corner, however thin the steps. + paddingOuter: flat ? Math.max(0.4, slim(chart, 48, 0.28) / 2 + 0.2) : 1, + }; } // Flint colors a waterfall's steps from its palette when the data lacks one kind, so the kinds are pinned. if (waterfall && "layer" in spec) @@ -1193,6 +1681,7 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map "mark" in layer && layer.mark instanceof Object && layer.mark.type === "text"; @@ -1210,10 +1699,11 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map 0 && !(index === values.length - 1 && finalIsTotal) && value > 0 ? "+" : ""; - return guess(`${signed}${Math.round(value).toLocaleString("en-US")}`, 12); + return guess(`${signed}${compact(value, chart.unit)}`, 12); }), ); const numbers = !!chart.labels && widest + 6 <= step; + if (numbers) waterfallLabelWidth = widest + 6; spec.layer = spec.layer.filter((layer) => !isText(layer) || (numbers && !inside(layer))); // Flint pads the value axis for the labels from levels that add the end total to the sum it closes, so it runs // to twice the top and below zero. The levels here stop at the end total, and only a side with a label is padded. @@ -1240,7 +1730,11 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map !("__themeSynthetic" in layer)); + // A sideways bar gets a row about twice a line of text tall, and grouped bars a lane each, rather than the share of a + // fixed height Flint gives them. + if ((chart.type === "bar" || chart.type === "stacked") && layout(chart).flip) { + const lanes = chart.type === "bar" ? chart.series.length : 1; + Reflect.set(spec, "height", { step: lanes > 1 ? lanes * 14 + 12 : 26 }); + } // Lines and labels are placed from the drawn chart, so a chart with them is drawn again with them in place. const compiled = compile(spec, { logger: quiet }).spec; let { view, svg } = await render(compiled); @@ -1266,22 +1766,42 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map Number(x(value))) + .sort((a: number, b: number) => a - b); + const spacing = Math.min( + ...positions.slice(1).map((position, index) => position - positions[index]!), + ); + if (waterfallLabelWidth > spacing) { + compiled.marks = compiled.marks?.filter((mark) => mark.type !== "text"); + view.finalize(); + ({ view, svg } = await render(compiled)); + } + } // Waterfalls, dumbbells, and gantts print their own numbers in their layers. const labels = chart.labels && (chart.type === "bar" || chart.type === "stacked") ? valueLabels(chart, view) : []; const zeros = zeroBars(chart, view); - if (labels.length || zeros.length) { + const ends = endLabeled(chart) ? endLabels(chart, view) : []; + if (labels.length || zeros.length || ends.length) { compiled.data = [ ...(compiled.data ?? []), ...(zeros.length ? [{ name: "cch_zeros", values: zeros }] : []), ...(labels.length ? [{ name: "cch_labels", values: labels }] : []), + ...(ends.length ? [{ name: "cch_ends", values: ends }] : []), ]; compiled.marks = [ ...(compiled.marks ?? []), ...(zeros.length ? [ZEROS] : []), ...(labels.length ? [LABELS] : []), + ...(ends.length ? END_LABELS : []), ]; view.finalize(); ({ view, svg } = await render(compiled)); @@ -1299,17 +1819,18 @@ const SPEC_CONFIG = { view: { stroke: null }, range: { category: [...SERIES] }, mark: { color: SERIES[0] }, - bar: { cornerRadiusEnd: 4 }, - line: { strokeWidth: 2 }, + bar: { cornerRadiusEnd: 2 }, + line: { strokeWidth: 1.5 }, axis: { domain: false, ticks: false, grid: true, gridColor: INK.border, + gridDash: [2, 3], labelColor: INK.muted, titleColor: INK.muted, - labelFontSize: 12, - titleFontSize: 12, + labelFontSize: 11, + titleFontSize: 11, titleFontWeight: "normal", labelPadding: 8, titlePadding: 10, diff --git a/packages/charts/src/html.ts b/packages/charts/src/html.ts index e52390386..48d860548 100644 --- a/packages/charts/src/html.ts +++ b/packages/charts/src/html.ts @@ -24,7 +24,7 @@ const styled = new WeakSet(); const chart: NodeHandler = async (node, state) => { const [, attrs, ...children] = node; // SAFETY: the parse plugin writes a DrawnChart on every chart it drew, and leaves no `svg` on the others. - const { type, title, label, svg, marks } = attrs as Partial; + const { type, title, description, label, svg, marks } = attrs as Partial; if (!svg) return state.flow(["div", {}, ...children], state); // The styles come inline before the script, so the script has nothing to add. const styles = styled.has(state.context) @@ -32,14 +32,16 @@ const chart: NodeHandler = async (node, state) => { : ``; styled.add(state.context); const data = (await Promise.all(children.map((child) => state.one(child, state)))).join(""); - const caption = title ? `
${escapeHtml(title)}
` : ""; + const caption = + (title ? `
${escapeHtml(title)}
` : "") + + (description ? `

${escapeHtml(description)}

` : ""); const [, width, height] = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg) ?? []; const aspect = width && height ? Number(height) / Number(width) : undefined; const plot = marks?.length ? `
${await annotateHtml(svg, marks, label ?? "", state, ".cch-plot", aspect, 0)}
` : `
${svg}
`; return ( - `${styles}
${caption}${plot}
${bar()}
` + + `${styles}
${caption}${plot}
${bar()}
` + `
Data${data}
` ); }; diff --git a/packages/charts/src/index.ts b/packages/charts/src/index.ts index d08673d48..34a7b83f0 100644 --- a/packages/charts/src/index.ts +++ b/packages/charts/src/index.ts @@ -6,7 +6,7 @@ import type { Issue } from "./read.ts"; import { readChart } from "./read.ts"; import { attr } from "../../../shared/html.ts"; import { childrenOf, isElement } from "../../../shared/nodes.ts"; -import { errorText, hash } from "../../../shared/text.ts"; +import { errorText, hash, text } from "../../../shared/text.ts"; export type { Chart, ChartType, FlowChart, SpecChart, XKind } from "./read.ts"; @@ -26,6 +26,8 @@ export interface ChartsOptions { export interface DrawnChart { type: string; title: string; + /** What to read in the chart, printed under its title, from `description`. */ + description?: string; /** The chart's accessible name: its title, or what it draws. */ label: string; svg: string; @@ -112,9 +114,11 @@ export default /* @__PURE__ */ defineComarkPlugin; /** The column of names a gantt colors its bars by. */ @@ -99,6 +106,7 @@ const TYPES: readonly ChartType[] = [ "dumbbell", "waterfall", "pie", + "heatmap", "gantt", "sankey", ]; @@ -108,6 +116,11 @@ const PROPS = new Set([ "x", "y", "title", + "description", + "unit", + "log", + "better", + ":log", "labels", ":labels", "horizontal", @@ -125,6 +138,7 @@ const A: Record = { dumbbell: "a dumbbell", waterfall: "a waterfall", pie: "a pie", + heatmap: "a heatmap", gantt: "a gantt", sankey: "a sankey", }; @@ -177,7 +191,7 @@ export function readChart( if (!spec && key !== "$" && !PROPS.has(key)) issues.push({ rule: "comark/charts/unknown-prop", - message: `${name}: ${code(key)} isn't a prop of \`::chart\`. Valid props: type, x, y, title, labels, horizontal.`, + message: `${name}: ${code(key)} isn't a prop of \`::chart\`. Valid props: type, x, y, title, description, unit, log, better, labels, horizontal.`, }); const content = childrenOf(node).filter(isContent); @@ -199,7 +213,7 @@ export function readChart( if (content.length > 1 + annotations.length) issues.push({ rule: "comark/charts/content-dropped", - message: `${name} drops everything inside it but its table and annotations. Name the chart with \`title\`, and write other text outside the block.`, + message: `${name} drops everything inside it but its table and annotations. Name the chart with \`title\`, say what to read in it with \`description\`, and write other text outside the block.`, }); const [head] = elements(table, "thead"); @@ -272,7 +286,15 @@ export function readChart( }); return; } - if (text(attrs.type) === "sankey") { + const own = text(attrs.type); + if (own === "sankey" || own === "gantt") + for (const prop of ["unit", "log", "better"]) + if (attrs[prop] !== undefined || attrs[`:${prop}`] !== undefined) + issues.push({ + rule: `comark/charts/invalid-${prop}`, + message: `${name}: ${A[own]} prints its own amounts, so \`${prop}\` changes nothing. Remove it, and put the unit in the header, such as \`amount ($)\`.`, + }); + if (own === "sankey") { const flow = readFlow( name, attrs, @@ -286,7 +308,7 @@ export function readChart( const flags = readFlags(name, "sankey", attrs, issues); return flow && { ...flow, ...flags }; } - if (text(attrs.type) === "gantt") { + if (own === "gantt") { const gantt = readGantt( name, attrs, @@ -393,6 +415,7 @@ export function readChart( type = "bar"; } if (type === "waterfall") readTotal(name, points, issues); + const scale = readScale(name, type, xKind, points, attrs, issues); return { type, title: text(attrs.title), @@ -400,11 +423,104 @@ export function readChart( xKind, series, rows: points.map((row) => ({ x: row.x, values: row.values.slice(0, series.length) })), - marks: readMarks(name, type, x, annotations, points, series, issues), + marks: readMarks( + name, + type, + x, + annotations, + points, + series, + issues, + undefined, + scale.log === "y" || scale.log === "both", + ), ...readFlags(name, type, attrs, issues), + ...scale, }; } +// A log scale spreads numbers that span orders of magnitude, such as costs or timings, along a line or a scatter +// plot's axes and a bar chart's values. Stacks, areas, and pies add up, and a log scale has no zero to add from. +const LOGGED = new Set(["line", "scatter", "bar"]); +const SUMMED = new Set(["area", "stacked", "pie", "waterfall"]); + +/** Reads `better`, `unit`, and `log`, and checks that a log scale has only positive numbers to place. */ +function readScale( + name: string, + type: ChartType, + xKind: XKind, + points: { x: string; values: (number | undefined)[] }[], + attrs: ElementNode[1], + issues: Issue[], +): { better?: "higher" | "lower"; unit?: string; log?: "x" | "y" | "both" } { + const better = text(attrs.better); + if (better && (type !== "heatmap" || (better !== "higher" && better !== "lower"))) + issues.push({ + rule: "comark/charts/invalid-better", + message: + type === "heatmap" + ? `${name}: \`better="${better}"\` isn't a direction. Use \`better="higher"\` or \`better="lower"\`.` + : `${name}: \`better\` colors a heatmap's cells from bad to good, and ${A[type]} has no cells. Remove it, or use \`type="heatmap"\`.`, + }); + const direction = better === "higher" ? "higher" : better === "lower" ? "lower" : undefined; + const rated: { better?: "higher" | "lower" } = + type === "heatmap" && direction ? { better: direction } : {}; + return { ...rated, ...readUnitAndLog(name, type, xKind, points, attrs, issues) }; +} + +function readUnitAndLog( + name: string, + type: ChartType, + xKind: XKind, + points: { x: string; values: (number | undefined)[] }[], + attrs: ElementNode[1], + issues: Issue[], +): { unit?: string; log?: "x" | "y" | "both" } { + const unit = text(attrs.unit); + const raw = attrs.log ?? attrs[":log"]; + if (raw === undefined) return unit ? { unit } : {}; + const value = text(raw); + if (value === "false") return unit ? { unit } : {}; + const asked = value === "" || value === "true" ? "y" : value; + const scale = (log: "x" | "y" | "both") => ({ ...(unit && { unit }), log }); + if (asked !== "x" && asked !== "y" && asked !== "both") { + issues.push({ + rule: "comark/charts/invalid-log", + message: `${name}: \`log="${value}"\` isn't an axis. Write \`log\` alone for the numbers' axis, \`log="x"\` for the x column's, or \`log="both"\`.`, + }); + return unit ? { unit } : {}; + } + let log: "x" | "y" | "both" = asked; + if (!LOGGED.has(type)) { + issues.push({ + rule: "comark/charts/invalid-log", + message: `${name}: ${A[type]} can't use a log scale${SUMMED.has(type) ? ", since its parts add up from zero" : ""}. Remove \`log\`, or use \`type="bar"\` or \`type="line"\`.`, + }); + return unit ? { unit } : {}; + } + if (log !== "y" && (xKind !== "number" || type === "bar")) { + issues.push({ + rule: "comark/charts/invalid-log", + message: `${name}: \`log="${log}"\` puts the x column on a log scale, and only a line or a scatter plot over numbers has one. Use \`log\` alone for the numbers' axis.`, + }); + if (log === "x") return unit ? { unit } : {}; + log = "y"; + } + const values = points.flatMap((point) => point.values.filter((cell) => cell !== undefined)); + const xs = points.map((point) => readNumber(point.x) ?? 0); + const below = [...(log !== "x" ? values : []), ...(log !== "y" ? xs : [])].find( + (number) => number <= 0, + ); + if (below !== undefined) { + issues.push({ + rule: "comark/charts/invalid-log", + message: `${name}: a log scale has no place for ${format(below)}, since it only holds numbers above 0. Remove \`log\`, or leave the cell empty.`, + }); + return unit ? { unit } : {}; + } + return scale(log); +} + /** * A waterfall's first row is its start total and its last row its end total, so the last must be the first plus every * change between them, as a reader checks it. @@ -463,12 +579,23 @@ function readFlag(name: string, prop: string, attrs: ElementNode[1], issues: Iss // The types with bars, steps, or dots to print a number on; lines, areas, scatter plots, and pies are read off their axis // or legend and the data table. -const LABELED = new Set(["bar", "stacked", "waterfall", "dumbbell", "gantt", "sankey"]); +const LABELED = new Set([ + "bar", + "stacked", + "waterfall", + "dumbbell", + "heatmap", + "gantt", + "sankey", +]); /** Reads `labels` and `horizontal`, each on the types that take it. */ function readFlags(name: string, type: ChartType, attrs: ElementNode[1], issues: Issue[]) { const flags: { labels?: true; horizontal?: true } = {}; - if (readFlag(name, "labels", attrs, issues)) { + // Bars print their numbers unless told not to with `labels=false`: a report's reader reads the number, not the axis. + const unset = attrs.labels === undefined && attrs[":labels"] === undefined; + if (unset && (type === "bar" || type === "stacked" || type === "heatmap")) flags.labels = true; + else if (readFlag(name, "labels", attrs, issues)) { if (LABELED.has(type)) flags.labels = true; else issues.push({ @@ -503,6 +630,7 @@ function readMarks( series: string[], issues: Issue[], groups?: { column?: string; names: string[] }, + log = false, ): ChartMark[] { const marks: ChartMark[] = []; let stamp = 1; @@ -524,11 +652,11 @@ function readMarks( const valueText = text(markAttrs.value); if (mark.mark === "line") { const value = readNumber(valueText); - const axis = type !== "pie" && type !== "gantt" && type !== "sankey"; + const axis = type !== "pie" && type !== "heatmap" && type !== "gantt" && type !== "sankey"; if (!axis) found.push({ rule: "comark/charts/invalid-line", - message: `\`mark="line"\` crosses the plot at a number on its value axis, and ${A[type]} has none. Mark a ${type === "pie" ? "slice" : type === "sankey" ? "node" : "bar"} with \`at\` and another mark, such as \`box\`.`, + message: `\`mark="line"\` crosses the plot at a number on its value axis, and ${A[type]} has none. Mark a ${type === "pie" ? "slice" : type === "sankey" ? "node" : type === "heatmap" ? "cell" : "bar"} with \`at\` and another mark, such as \`box\`.`, }); else if (!valueText) found.push({ @@ -540,6 +668,11 @@ function readMarks( rule: "comark/charts/invalid-line", message: `\`value="${valueText}"\` isn't a number. Write the number alone, such as \`200\` or \`1,200\`, in the unit of the chart's numbers.`, }); + else if (log && value <= 0) + found.push({ + rule: "comark/charts/invalid-line", + message: `\`value="${valueText}"\` has no place on a log scale. Use a value above 0, or remove \`log\` from the chart.`, + }); const extra = [at && "`at`", seriesName && "`series`"].filter(Boolean).join(" and "); if (axis && extra) found.push({ @@ -549,7 +682,7 @@ function readMarks( issues.push( ...found.map((problem) => ({ ...problem, message: `${where}: ${problem.message}` })), ); - if (axis && value !== undefined) + if (axis && value !== undefined && (!log || value > 0)) marks.push({ ...mark, n: index + 1, number: mark.side ? undefined : stamp++, value }); continue; } @@ -960,7 +1093,7 @@ function readSpec( issues: Issue[], ): SpecChart | undefined { for (const key of Object.keys(attrs)) - if (key !== "$" && key !== "title" && key !== "class" && key !== "id") + if (key !== "$" && key !== "title" && key !== "description" && key !== "class" && key !== "id") issues.push({ rule: "comark/charts/spec-with-props", message: `${name}: ${code(key)} doesn't apply to a chart drawn from a Vega-Lite spec. Name the columns in the spec's \`encoding\` instead.`, @@ -973,7 +1106,7 @@ function readSpec( if (others > annotations) issues.push({ rule: "comark/charts/content-dropped", - message: `${name} drops everything inside it but its table and its Vega-Lite spec. Name the chart with \`title\`, and write other text outside the block.`, + message: `${name} drops everything inside it but its table and its Vega-Lite spec. Name the chart with \`title\`, say what to read in it with \`description\`, and write other text outside the block.`, }); let spec: unknown; try { diff --git a/packages/charts/src/styles.ts b/packages/charts/src/styles.ts index 41d40dc59..70ae6f959 100644 --- a/packages/charts/src/styles.ts +++ b/packages/charts/src/styles.ts @@ -13,20 +13,32 @@ const swap = (hex: string, color: string, props = ["fill", "stroke"]) => export const css = `@layer comark{${[ disclosureMotion(".cch-data"), theme(".cch", "shadow"), - // The Data footer runs to the card's edges, so it spans the same side padding the card has. - ":where(.cch){--cch-padding:1.5rem}", - // A card, as shadcn/ui frames its charts: the title on top, the chart, and the data as its footer. - ".cch{margin:1.5rem 0;padding:1.25rem var(--cch-padding) 0;border:1px solid var(--_comark-border);border-radius:var(--_comark-radius);background:var(--_comark-background);color:var(--_comark-foreground);font-family:var(--_comark-font)}", - // Only the card's own caption: a marked chart's notes sit in comark-annotate's figcaption inside it. - ".cch>figcaption{margin-bottom:1rem;font-weight:600;font-size:1rem;line-height:1.5rem}", - // Every chart keeps the size its text was set for, and a narrower column shrinks it to fit, as it does a screenshot, - // since marks sit at percentages of the SVG. Only the chart's own SVG: a mark's circle and arrow are SVGs too, and - // `height:auto` would stretch them to their viewBox. A chart narrower than its card sits in the middle. - ":is(.cch-plot,.cch-marked .ca-frame)>svg{display:block;max-width:100%;height:auto;margin-inline:auto}", + // A section of a report, as a dashboard lays its charts out: a hairline over it, the title and what to read in it + // at the start, the chart, and the data folded under it. No card, so charts sit in a column or a grid like text. + ":where(.cch){--cch-padding:0}", + ".cch{margin:2rem 0;padding:1rem var(--cch-padding) 0;border-top:1px solid var(--_comark-border);color:var(--_comark-foreground);font-family:var(--_comark-font)}", + // Only the chart's own caption: a marked chart's notes sit in comark-annotate's figcaption inside it. A page's + // typeset may center captions and mute them, as it does an image's. + ".cch>figcaption{margin:0 0 1rem;color:var(--_comark-foreground);font-weight:500;font-size:.9375rem;line-height:1.375rem;text-align:start}", + ".cch>figcaption:has(+.cch-description){margin-bottom:.125rem}", + // Right under a heading, the heading already opens the section, so the chart drops its hairline. A heading may come + // wrapped with its anchor link, and the first chart of an HTML document after its styles and script. + [".cch", "style+script+.cch"] + .map((chart) => `:is(h1,h2,h3,h4,h5,h6,:has(>:is(h1,h2,h3,h4,h5,h6)))+${chart}`) + .join(",") + "{border-top:0;padding-top:0;margin-top:1rem}", + ".cch-description{max-width:46rem;margin:0 0 1rem;color:var(--_comark-muted);font-size:.8125rem;line-height:1.25rem}", + // A narrower column shrinks a chart to fit, as it does a screenshot, since marks sit at percentages of the SVG. Only + // the chart's own SVG: a mark's circle and arrow are SVGs too, and `height:auto` would stretch them to their viewBox. + // A chart narrower than the column starts where its title does. + ":is(.cch-plot,.cch-marked .ca-frame)>svg{display:block;max-width:100%;height:auto;margin-inline:0 auto}", + // A chart over axes spans the column, as a dashboard's do. Each is drawn at one width, so all scale alike and their + // text matches. A pie or a heatmap keeps its own size, since its shapes and cells read the same at any width, and so + // does a chart with notes, whose stage fits the notes' gutters around it. + ".cch:not([data-type=pie],[data-type=heatmap])>.cch-plot:not(.cch-marked)>svg{width:100%}", ".cch-plot text{font-family:var(--_comark-font)}", - // comark-annotate pins side notes to its stage's edges, so the stage is what sits in the middle, with the notes' - // gutters beside the chart rather than at the card's edges. - ".cch-marked .ca-stage{box-sizing:border-box;width:fit-content;max-width:100%;margin-inline:auto}", + // comark-annotate pins side notes to its stage's edges, so the stage is what's placed, with the notes' gutters + // beside the chart rather than at the column's edges. + ".cch-marked .ca-stage{box-sizing:border-box;width:fit-content;max-width:100%;margin-inline:0 auto}", ".cch-marked .ca-frame{width:fit-content;max-width:100%}", // A narrow column outlines a mark that draws nothing, which on a screenshot shows its area; on a chart it marks a // point, so it would be a broken ring under the letter, which marks the point alone. @@ -47,10 +59,10 @@ export const css = `@layer comark{${[ `var(--comark-chart-${index + 1},var(--ui-chart-${index + 1},var(--chart-${index + 1},${hex})))`, ), ), - // The Copy bar sits over the card's top corner and shows on hover or focus, as a screenshot's Copy button does, so + // The Copy bar sits over the section's top corner and shows on hover or focus, as a screenshot's Copy button does, so // a chart keeps its height. A screen with no hover always shows it. ".cch{position:relative}", - ".cch-bar{position:absolute;top:.75rem;right:.75rem;z-index:1;display:flex;align-items:center;gap:.25rem;font:500 .75rem/1rem var(--_comark-font);opacity:0;transition:opacity .15s}", + ".cch-bar{position:absolute;top:.75rem;right:0;z-index:1;display:flex;align-items:center;gap:.25rem;font:500 .75rem/1rem var(--_comark-font);opacity:0;transition:opacity .15s}", "html:not([data-charts-ready]) .cch-bar{display:none}", ".cch:is(:hover,:focus-within)>.cch-bar,.cch-bar:has([aria-expanded=true],.cch-status:not(:empty)){opacity:1}", "@media (hover:none){.cch-bar{opacity:1}}", @@ -72,7 +84,7 @@ export const css = `@layer comark{${[ ".cch-menu svg{margin-top:.125rem}", ".cch-menu b{display:block;font-weight:500}", ".cch-menu small{display:block;color:var(--_comark-muted);font-size:.75rem;font-weight:400;line-height:1rem}", - ".cch-data{margin:1rem calc(-1 * var(--cch-padding)) 0;padding:.75rem var(--cch-padding);border-top:1px solid var(--_comark-border);color:var(--_comark-muted);font-size:.875rem;line-height:1.25rem}", + ".cch-data{margin:.5rem 0 0;color:var(--_comark-muted);font-size:.8125rem;line-height:1.25rem}", ".cch-data summary{width:fit-content;cursor:pointer}", ".cch>table{margin-bottom:1.25rem}", ".cch-data summary:focus-visible{border-radius:calc(var(--_comark-radius) - 4px);outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--_comark-ring) 50%,transparent)}", diff --git a/packages/charts/src/svelte/Chart.svelte b/packages/charts/src/svelte/Chart.svelte index 3574374b8..5e756c109 100644 --- a/packages/charts/src/svelte/Chart.svelte +++ b/packages/charts/src/svelte/Chart.svelte @@ -3,13 +3,14 @@ import { onMount } from "svelte"; import { bar, css, start } from "comark-charts/client"; - let { type = undefined, title = undefined, svg = undefined, children } = $props(); + let { type = undefined, title = undefined, description = undefined, svg = undefined, children } = $props(); onMount(() => start(css)); {#if svg} -
+
{#if title}
{title}
{/if} + {#if description}

{description}

{/if}
{@html svg}
{@html bar()}
Data{@render children?.()}
diff --git a/packages/charts/src/svelte/index.d.ts b/packages/charts/src/svelte/index.d.ts index e7f84662d..26db44596 100644 --- a/packages/charts/src/svelte/index.d.ts +++ b/packages/charts/src/svelte/index.d.ts @@ -6,6 +6,7 @@ export type { ChartsMeta, ChartsOptions, DrawnChart } from "comark-charts"; export declare const Chart: Component<{ type?: string; title?: string; + description?: string; svg?: string; children?: Snippet; }>; diff --git a/packages/charts/src/vue.ts b/packages/charts/src/vue.ts index 99d66ac38..72ac9fb25 100644 --- a/packages/charts/src/vue.ts +++ b/packages/charts/src/vue.ts @@ -8,15 +8,16 @@ export type { ChartsMeta, ChartsOptions, DrawnChart } from "./index.ts"; export const Chart = defineComponent({ name: "Chart", inheritAttrs: false, - props: { type: String, title: String, svg: String }, + props: { type: String, title: String, description: String, svg: String }, setup(props, { slots }) { let stop = () => {}; onMounted(() => (stop = start(css))); onBeforeUnmount(() => stop()); return () => props.svg - ? h("figure", { class: "cch", "data-comark-frame": "", "data-type": props.type }, [ + ? h("figure", { class: "cch", "data-type": props.type }, [ props.title ? h("figcaption", props.title) : null, + props.description ? h("p", { class: "cch-description" }, props.description) : null, h("div", { class: "cch-plot", innerHTML: props.svg }), h("div", { class: "cch-bar", innerHTML: bar() }), h("details", { class: "cch-data" }, [h("summary", "Data"), slots.default?.()]), diff --git a/packages/charts/test/charts.test.ts b/packages/charts/test/charts.test.ts index 1ab010625..986f27d8f 100644 --- a/packages/charts/test/charts.test.ts +++ b/packages/charts/test/charts.test.ts @@ -70,7 +70,7 @@ ${sizes.trim()} expect(document.meta.issues).toEqual([]); expect(document.meta.issueRules).toEqual({}); const html = await renderHtmlFromDocument(document, { components }); - expect(html).toContain('
'); + expect(html).toContain('
'); expect(html).toContain('annotate"); }); @@ -78,11 +78,11 @@ ${sizes.trim()} it("draws an SVG over the table it came from, and inlines the styles once", async () => { const html = await render(`${chart(sizes, '{title="Bundle size"}')}\n\n${chart(months)}`); expect(html).toContain( - '
Bundle size
Bundle size
Data'); expect(html).toContain(""); @@ -149,13 +149,17 @@ ${sizes.trim()} ).toEqual(["table", "markdown"]); }); - it("takes the card's side padding from --cch-padding, which the Data footer spans too", async () => { - const html = await render(chart(sizes)); - expect(html).toContain(":where(.cch){--cch-padding:1.5rem}"); - expect(html).toMatch(/\.cch\{[^}]*padding:1\.25rem var\(--cch-padding\) 0;/); - expect(html).toMatch( - /\.cch-data\{margin:1rem calc\(-1 \* var\(--cch-padding\)\) 0;padding:\.75rem var\(--cch-padding\);/, + it("draws a section under a hairline, with its title and what to read in it at the start", async () => { + const html = await render( + chart(sizes, '{title="Bundle size" description="The two packages most pages load."}'), ); + expect(html).toContain( + '
Bundle size

The two packages most pages load.

', + ); + expect(html).toContain(":where(.cch){--cch-padding:0}"); + expect(html).toMatch(/\.cch\{[^}]*border-top:1px solid var\(--_comark-border\)/); + expect(html).not.toContain("data-comark-frame"); + expect(await issues(chart(sizes, '{description="Read the first bar."}'))).toEqual([]); }); it("labels the chart with the table's own headers, dots and all", async () => { @@ -228,7 +232,7 @@ ${sizes.trim()} expect(waterfall).toContain(">increase"); expect(waterfall).toContain(">decrease"); // With `labels`, each step prints its change and each total its value, once. - for (const value of ["412", "+38", "\u221224", "426"]) + for (const value of ["412", "+38", "-24", "426"]) expect(waterfall.split(`>${value}`)).toHaveLength(2); }); @@ -255,10 +259,10 @@ ${sizes.trim()} "300", "600", "900", - "1,200", - "1,500", - "1,800", - "2,100", + "1.2k", + "1.5k", + "1.8k", + "2.1k", ]); expect(names).toContain(">standup"); // A grid line at every tick and a guide along every row. @@ -269,7 +273,7 @@ ${sizes.trim()} .flatMap(([, inside]) => [...inside!.matchAll(/]*>([^<]+)<\/text>/g)]) .map(([, text]) => text); expect(printed.sort()).toEqual( - ["1,320", "1,450", "160", "2,100", "380", "540", "820", "980"].sort(), + ["1.32k", "1.45k", "160", "2.1k", "380", "540", "820", "980"].sort(), ); }); @@ -319,8 +323,9 @@ ${sizes.trim()} expect(html).toContain(`>${label}`); // Upright, the default, a long name wraps under its bar instead. expect(await render(chart(table))).not.toContain(`>${label}`); + // Every chart over axes is drawn as wide, so a page scales them alike. const [, width] = /viewBox="0 0 ([\d.]+) /.exec(html) ?? []; - expect(Number(width)).toBeLessThanOrEqual(700); + expect(Number(width)).toBe(744); }); it("draws comark-annotate marks on the row and series an annotation names", async () => { @@ -470,7 +475,7 @@ The first slice. ::: ::`; const html = await annotated(markdown); - expect(html).toContain('viewBox="0 0 544 397"'); + expect(html).toContain('viewBox="0 0 664 417"'); expect(html).toContain('ca-stamp" style="left:0;top:0;translate:-50% -50%"'); }); @@ -591,8 +596,8 @@ ${sizes.trim()} chart(months, '{type="waterfall"}'), ].join("\n\n"); expect(await issues(markdown)).toEqual([ - "`::chart` 1: `colour` isn't a prop of `::chart`. Valid props: type, x, y, title, labels, horizontal.", - '`::chart` 1: `type="donut"` isn\'t a chart type. Use `bar`, `stacked`, `line`, `area`, `scatter`, `dumbbell`, `waterfall`, `pie`, `gantt` or `sankey`.', + "`::chart` 1: `colour` isn't a prop of `::chart`. Valid props: type, x, y, title, description, unit, log, better, labels, horizontal.", + '`::chart` 1: `type="donut"` isn\'t a chart type. Use `bar`, `stacked`, `line`, `area`, `scatter`, `dumbbell`, `waterfall`, `pie`, `heatmap`, `gantt` or `sankey`.', '`::chart` 2: `y` names column `befor`, but its table has `month`, `before` and `after`. Separate several with commas, as in `y="before, after"`.', "`::chart` 3: row 1 has `41 kB` in `size`, which isn't a number. Write the number alone, such as `1200` or `1,200`, and put the unit in the header, such as `size (ms)`.", '`::chart` 4: a pie shows one column of numbers, and this one has `before` and `after`. Name one with `y`, or use `type="bar"`.', @@ -665,7 +670,7 @@ ${sizes.trim()} const html = await render(chart(build, '{type="gantt" labels}')); expect(await issues(chart(build, '{type="gantt" labels}'))).toEqual([]); expect(html).toContain( - '
{ + it("writes each bar's value past its end with `labels`, and a stacked segment's inside it where it fits", async () => { const value = (html: string, text: string) => new RegExp(`]*font-weight="500" fill="(#[0-9a-f]+)">${text}`).exec(html)?.[1]; const bars = await render( @@ -871,8 +876,8 @@ No such run. "{labels}", ), ); - expect(value(bars, "41")).toBe("#ffffff"); - expect(value(bars, "18")).toBe("#ffffff"); + expect(value(bars, "41")).toBe("#0a0a0a"); + expect(value(bars, "18")).toBe("#0a0a0a"); expect(value(bars, "2")).toBe("#0a0a0a"); const stacked = await render( chart( @@ -886,10 +891,25 @@ No such run. ); expect(value(stacked, "110")).toBe("#ffffff"); expect(value(stacked, "40")).toBe("#ffffff"); - // The first series stacks on top. A thin segment at the end of its stack goes past it; one at the bottom has - // nowhere to go. - expect(value(stacked, "1")).toBe("#0a0a0a"); + // Past each stack goes its total. A segment too thin for its value has no label, wherever it sits, and an empty + // one never has. + expect(value(stacked, "151")).toBe("#0a0a0a"); + expect(value(stacked, "162")).toBe("#0a0a0a"); + expect(value(stacked, "1")).toBeUndefined(); expect(value(stacked, "2")).toBeUndefined(); + const empty = await render( + chart( + ` +| week | feat | release | +| --- | --: | --: | +| Oct 12 | 0 | 2 | +| Oct 19 | 87 | 19 |`, + '{type="stacked" labels}', + ), + ); + expect(value(empty, "0")).toBeUndefined(); + expect(value(empty, "2")).toBe("#0a0a0a"); + expect(value(empty, "106")).toBe("#0a0a0a"); const sideways = await render( chart( ` @@ -900,11 +920,42 @@ No such run. "{horizontal labels}", ), ); - expect(value(sideways, "1,284")).toBe("#ffffff"); + expect(value(sideways, "1.28k")).toBe("#0a0a0a"); expect(value(sideways, "3")).toBe("#0a0a0a"); expect(await issues(chart(sizes, '{type="stacked" labels}'))).toEqual([]); }); + it("prints every bar's number however many come before it", async () => { + const rows = Array.from({ length: 30 }, (_, index) => `| file ${index + 1} | ${index + 1} |`); + const html = await render( + chart(`| file | lines |\n| --- | --: |\n${rows.join("\n")}`, "{horizontal labels}"), + ); + const printed = [...html.matchAll(/]*font-weight="500"[^>]*>(\d+)<\/text>/g)].map( + ([, text]) => Number(text), + ); + expect(printed.sort((a, b) => a - b)).toEqual( + Array.from({ length: 30 }, (_, index) => index + 1), + ); + }); + + it("keeps each series in its column's place and color when its first cell is empty", async () => { + const legend = async (first: string) => { + const html = await render( + chart( + `| week | feat | fix | docs |\n| --- | --: | --: | --: |\n| a | ${first} | 3 | 4 |\n| b | 5 | 3 | 4 |`, + '{type="stacked"}', + ), + ); + const start = html.indexOf("role-legend"); + return [...html.slice(start, start + 3000).matchAll(/fill="(#[0-9a-f]+)"|>(\w+)<\/text>/g)] + .map(([, fill, name]) => fill ?? name) + .join(" "); + }; + const full = await legend("1"); + expect(full).toMatch(/feat.*fix.*docs/); + expect(await legend("")).toBe(full); + }); + it("reports `labels` and `horizontal` on a chart that doesn't take them", async () => { expect( await issues( @@ -1053,7 +1104,7 @@ No such run. expect(await issues(chart(budget, '{type="sankey" labels}'))).toEqual([]); const html = await render(chart(budget, '{type="sankey" labels}')); expect(html).toContain( - '
2026-04"); }); + + describe("reports", () => { + // The texts a chart prints, in the order the SVG draws them. + const texts = (html: string) => + [...html.matchAll(/]*>([^<]+)<\/text>/g)].map(([, text]) => text); + + it("prints each bar's number unless `labels=false`, short past a thousand and with its `unit`", async () => { + const costs = ` +| model | cost | +| --- | --: | +| small | 63 | +| large | 22,200 |`; + expect(texts(await render(chart(costs, '{unit="$"}')))).toEqual( + expect.arrayContaining(["$63", "$22.2k"]), + ); + expect(texts(await render(chart(costs, '{unit="ms" labels=false}')))).toEqual( + expect.arrayContaining(["20k ms"]), + ); + expect(texts(await render(chart(costs, '{unit="ms" labels=false}')))).not.toContain( + "22.2k ms", + ); + expect(await issues(chart(costs, '{unit="$"}'))).toEqual([]); + }); + + it("names each line where it ends, in its color, rather than in a legend", async () => { + const html = await render(chart(months, '{type="line"}')); + expect(html).toMatch(/]*fill="#4d97de"[^>]*>before<\/text>/); + expect(html).toMatch(/]*fill="#bb8558"[^>]*>after<\/text>/); + expect(html).not.toContain("role-legend"); + }); + + it("puts the numbers or the x column on a log scale, ticking at 1, 2, and 5", async () => { + const costs = ` +| cost per task | score | +| --: | --: | +| 0.005 | 10 | +| 0.2 | 28 | +| 20 | 65 |`; + const html = await render(chart(costs, '{type="scatter" log="x"}')); + expect(texts(html)).toEqual(expect.arrayContaining(["0.01", "0.1", "1", "10"])); + expect(await issues(chart(costs, '{type="scatter" log="x"}'))).toEqual([]); + expect( + await issues( + [ + chart(costs, '{type="scatter" log="z"}'), + chart(costs, '{type="stacked" log}'), + chart(sizes, '{log="x"}'), + chart(`| x | y |\n| --- | --: |\n| a | 0 |\n| b | 4 |`, "{log}"), + chart(months, '{type="dumbbell" log}'), + ].join("\n\n"), + ), + ).toEqual([ + '`::chart` 1: `log="z"` isn\'t an axis. Write `log` alone for the numbers\' axis, `log="x"` for the x column\'s, or `log="both"`.', + '`::chart` 2: a stacked bar chart can\'t use a log scale, since its parts add up from zero. Remove `log`, or use `type="bar"` or `type="line"`.', + '`::chart` 3: `log="x"` puts the x column on a log scale, and only a line or a scatter plot over numbers has one. Use `log` alone for the numbers\' axis.', + "`::chart` 4: a log scale has no place for 0, since it only holds numbers above 0. Remove `log`, or leave the cell empty.", + '`::chart` 5: a dumbbell can\'t use a log scale. Remove `log`, or use `type="bar"` or `type="line"`.', + ]); + }); + + it("draws a heatmap: a cell per row and column, shaded by its number and printing it", async () => { + const matrix = ` +| customer | bugs | forecast | +| --- | --: | --: | +| Northwind | 14 | 6 | +| Fabrikam | 5 | |`; + const html = await render(chart(matrix, '{type="heatmap"}')); + expect(html).toContain('data-type="heatmap"'); + expect(texts(html)).toEqual( + expect.arrayContaining(["bugs", "forecast", "Northwind", "Fabrikam", "14", "6", "5", "–"]), + ); + // The largest number is the full first series color, and the smallest the faintest. + expect(html).toMatch(/fill="#4d97de" opacity="1"/); + expect(html).toMatch(/fill="#4d97de" opacity="0\.12"/); + expect(await issues(chart(matrix, '{type="heatmap"}'))).toEqual([]); + }); + + it("rates a heatmap's cells red to green with `better`", async () => { + const scores = ` +| agent | score | +| --- | --: | +| one | 9 | +| two | 2 |`; + const higher = await render(chart(scores, '{type="heatmap" better="higher"}')); + expect(higher).toMatch(/fill="#4fa66a"/); + expect(higher).toMatch(/fill="#d9605c"/); + const lower = await render(chart(scores, '{type="heatmap" better="lower"}')); + expect(lower.indexOf('fill="#d9605c"')).toBeLessThan(lower.indexOf('fill="#4fa66a"')); + expect( + await issues( + [ + chart(scores, '{type="heatmap" better="more"}'), + chart(scores, '{better="higher"}'), + ].join("\n\n"), + ), + ).toEqual([ + '`::chart` 1: `better="more"` isn\'t a direction. Use `better="higher"` or `better="lower"`.', + '`::chart` 2: `better` colors a heatmap\'s cells from bad to good, and a bar chart has no cells. Remove it, or use `type="heatmap"`.', + ]); + }); + }); }); diff --git a/packages/charts/test/frameworks.test.ts b/packages/charts/test/frameworks.test.ts index 812030284..b5709d75a 100644 --- a/packages/charts/test/frameworks.test.ts +++ b/packages/charts/test/frameworks.test.ts @@ -25,7 +25,7 @@ const normalize = (html: string) => .replace(/>\s+<") .trim(); -const markdown = `::chart{title="Bundle size"} +const markdown = `::chart{title="Bundle size" description="Annotate is twice zoom."} | package | size (kB) | | --- | --: | | annotate | 41.2 | @@ -37,7 +37,7 @@ describe("comark-charts renderers", () => { const value = await parseMarkdown(markdown, { plugins: [charts()] }); const html = normalize(await renderHtmlFromDocument(value, { components: htmlComponents })); expect(html).toContain( - '
Bundle size
Bundle size

Annotate is twice zoom.

{ const svg = //.exec(output)?.[0]; expect(svg).toBe(//.exec(html)?.[0]); expect(output).toContain( - '
Bundle size
', + '
Bundle size

Annotate is twice zoom.

', ); expect(output).toContain('
Data'); expect(output).toContain("
"); diff --git a/packages/charts/test/report-regressions.test.ts b/packages/charts/test/report-regressions.test.ts new file mode 100644 index 000000000..81b263c80 --- /dev/null +++ b/packages/charts/test/report-regressions.test.ts @@ -0,0 +1,264 @@ +import { createHtmlRenderer, renderHtmlFromDocument } from "@comark/html"; +import { parseMarkdown } from "comark"; +import { describe, expect, it } from "vite-plus/test"; +import { guess } from "../src/draw.ts"; +import charts, { components } from "../src/html.ts"; + +const render = createHtmlRenderer({ plugins: [charts()], components }); +const chart = (table: string, props: string) => `::chart${props}\n${table}\n::`; +const texts = (html: string) => + [...html.matchAll(/]*>([^<]+)<\/text>/g)].map(([, text]) => text); +const attribute = (tag: string, name: string) => + new RegExp(`(?:^| )${name}="([^"]*)"`).exec(tag)?.[1] ?? ""; +const position = (tag: string) => + /translate\(([-\d.]+),([-\d.]+)\)/.exec(tag)!.slice(1).map(Number); + +// Compare names and leaders with the actual paths and points, including Vega's color and x ordering. +function expectLineNames(html: string, namesByHeight: string[]) { + const ends = [...html.matchAll(/(]+\/>)/g)] + .map(([, path]) => { + const points = [...attribute(path!, "d").matchAll(/[ML]([-\d.]+),([-\d.]+)/g)] + .map((point) => ({ x: Number(point[1]), y: Number(point[2]) })) + .sort((a, b) => b.x - a.x); + return { ...points[0]!, color: attribute(path!, "stroke") }; + }) + .sort((a, b) => a.y - b.y); + expect(ends).toHaveLength(namesByHeight.length); + const leaders = [...html.matchAll(/]*stroke-opacity="0.5"[^>]*\/>/g)].map( + ([tag]) => tag, + ); + const points = [...html.matchAll(/ attribute(tag, "stroke") === end.color)!; + const [x, y] = position(leader); + expect(x).toBeCloseTo(end.x + 4, 1); + expect(y).toBeCloseTo(end.y, 1); + const point = points.find((tag) => { + const [x, y] = position(tag); + return Math.abs(x! - end.x) < 0.02 && Math.abs(y! - end.y) < 0.02; + })!; + expect(attribute(point, "fill")).toBe(end.color); + } +} + +describe("report chart regressions", () => { + it.each([ + ["upright", ""], + ["horizontal", "{horizontal}"], + ])( + "includes a series' outside bar value in its annotation with %s bars", + async (_kind, props) => { + for (const sign of [1, -1]) { + const value = String(sign * 400); + const document = await parseMarkdown( + chart( + `| task | before | after |\n| --- | --: | --: |\n| alpha | ${sign * 200} | ${sign * 130} |\n| beta | ${value} | ${sign * 260} |\n:::annotation{at="beta" series="before" mark="box"}\nBefore's value.\n:::`, + props, + ), + { plugins: [charts()] }, + ); + expect(document.meta.issues ?? []).toEqual([]); + const html = await renderHtmlFromDocument(document, { components }); + const svg = html.match(//)![0]; + const [width, height] = attribute(svg, "viewBox").split(" ").slice(2).map(Number); + const mark = + /class="ca-mark ca-box"[^>]*left:([\d.]+)%;top:([\d.]+)%;width:([\d.]+)%;height:([\d.]+)%/.exec( + html, + )!; + expect(mark).not.toBeNull(); + const [left, top, w, h] = mark.slice(1).map(Number); + const groups = [{ x: 0, y: 0 }]; + let label: { x: number; y: number } | undefined; + for (const [tag, close, name, text] of svg.matchAll(/<(\/?)(\w+)[^>]*>([^<]*)/g)) { + const [dx = 0, dy = 0] = (/translate\(([-\d.]+),([-\d.]+)\)/.exec(tag) ?? []) + .slice(1) + .map(Number); + const { x, y } = groups.at(-1)!; + if (name === "g" && close) groups.pop(); + else if (name === "g") groups.push({ x: x + dx, y: y + dy }); + else if (name === "text" && text === value && attribute(tag, "font-weight") === "500") + label = { x: x + dx, y: y + dy }; + } + expect(label).toBeDefined(); + const half = guess(value, 11) / 2; + expect((left! / 100) * width!).toBeLessThan(label!.x - half); + expect(((left! + w!) / 100) * width!).toBeGreaterThan(label!.x + half); + expect((top! / 100) * height!).toBeLessThan(label!.y - 5); + expect(((top! + h!) / 100) * height!).toBeGreaterThan(label!.y + 5); + } + }, + ); + + it.each([ + ["upright", '{type="stacked"}'], + ["horizontal", '{type="stacked" horizontal}'], + ])("totals the rendered stacks for equivalent timestamps with %s bars", async (_kind, props) => { + for (const [first, second, totals] of [ + ["10 | 20", "30 | 40", ["100"]], + ["-10 | -20", "-30 | -40", ["-100"]], + ["10 | -20", "30 | -40", ["-60", "40"]], + ] as const) { + // At the domain's negative end, totals meet the category labels and are suppressed. A larger control stack + // leaves room to check the negative aggregate as well, without changing the collision rule. + for (const room of [false, true]) { + const extra = room ? "\n| 2026-10-13T00:00Z | -200 | -200 |" : ""; + const document = await parseMarkdown( + chart( + `| time | a | b |\n| --- | --: | --: |\n| 2026-10-12T00:00Z | ${first} |\n| 2026-10-12T02:00+02:00 | ${second} |${extra}`, + props, + ), + { plugins: [charts()] }, + ); + expect(document.meta.issues ?? []).toEqual([]); + const html = await renderHtmlFromDocument(document, { components }); + const outside = [...html.matchAll(/]*font-weight="500"[^>]*>[^<]+<\/text>/g)] + .map(([tag]) => tag) + .filter((tag) => attribute(tag, "fill") === "#0a0a0a") + .map((tag) => />([^<]+)<\/text>/.exec(tag)![1]); + expect(outside.sort()).toEqual( + totals.filter((total) => room || !total.startsWith("-")).sort(), + ); + } + } + }); + + it("validates the effective log axis after a categorical x fallback", async () => { + const markdown = chart( + "| x | y |\n| --- | ---: |\n| a | 0 |\n| b | 10 |", + '{log="both" labels=false}', + ); + for (const draw of [false, true]) { + const document = await parseMarkdown(markdown, { plugins: [charts({ draw })] }); + expect(document.meta.issues).toHaveLength(2); + expect(document.meta.issues).toContain( + "`::chart` 1: a log scale has no place for 0, since it only holds numbers above 0. Remove `log`, or leave the cell empty.", + ); + if (draw) + expect(await renderHtmlFromDocument(document, { components })).toContain(' { + const html = await render( + chart(`| x | before | after |\n| --- | ---: | ---: |\n${rows}`, '{type="line"}'), + ); + expectLineNames(html, ["after", "before"]); + expect(html).not.toContain("role-legend"); + }); + + it.each([0, -5])("ignores a %s reference target on a log value axis", async (value) => { + const table = "| x | value |\n| --- | ---: |\n| a | 1 |\n| b | 10 |"; + const document = await parseMarkdown( + chart( + `${table}\n:::annotation{mark="line" value="${value}"}\nTarget\n:::`, + '{type="line" log}', + ), + { plugins: [charts()] }, + ); + expect(document.meta.issues).toHaveLength(1); + expect(Object.values(document.meta.issueRules ?? {})).toEqual(["comark/charts/invalid-line"]); + const html = await renderHtmlFromDocument(document, { components }); + const plain = await render(chart(table, '{type="line" log}')); + expect(html.match(//)?.[0]).toBe(plain.match(//)?.[0]); + expect(html).not.toContain("Target"); + }); + + it("keeps plotting space and a fitting legend for long line-series names", async () => { + const prefix = "apps/dashboard/src/features/organization/settings/billing/components/"; + const names = [ + `${prefix}SubscriptionPlanComparisonTable.integration.test.ts`, + `${prefix}SubscriptionPlanComparisonTable.unit.test.ts`, + ]; + const document = await parseMarkdown( + chart( + `| x | ${names.join(" | ")} |\n| --- | ---: | ---: |\n| a | 1 | 2 |\n| b | 3 | 4 |`, + '{type="line"}', + ), + { plugins: [charts()] }, + ); + expect(document.meta.issues ?? []).toEqual([]); + const html = await renderHtmlFromDocument(document, { components }); + const paths = [...html.matchAll(/(]+\/>)/g)]; + expect(paths).toHaveLength(2); + for (const [, path] of paths) { + const xs = [...attribute(path!, "d").matchAll(/[ML]([-\d.]+),/g)].map(([, x]) => Number(x)); + expect(Math.max(...xs) - Math.min(...xs)).toBeGreaterThan(300); + } + expect(html).toContain('viewBox="0 0 744 '); + expect(html).toContain("role-legend"); + for (const name of names) expect(html).toContain(`>${name}`); + }); + + it("formats waterfall totals, signed changes, and ticks with compact units", async () => { + const html = await render( + chart( + "| step | value |\n| --- | ---: |\n| start | 10000 |\n| add | 2500 |\n| drop | -500 |\n| end | 12000 |", + '{type="waterfall" labels unit="$"}', + ), + ); + expect(texts(html)).toEqual(expect.arrayContaining(["$10k", "+$2.5k", "-$500", "$12k", "$2k"])); + for (const raw of ["10,000", "+2,500", "-500", "12,000"]) + expect(texts(html)).not.toContain(raw); + expect(html).toMatch(/]*>10000<\/td>/); + }); + + it("includes the unit when deciding whether waterfall labels fit", async () => { + const html = await render( + chart( + // Eight steps leave each about 80px: room for "10k", not for "10k requests/sec". + "| step | value |\n| --- | ---: |\n| start | 10000 |\n| a | 2000 |\n| b | 1000 |\n| c | -500 |\n| d | 1500 |\n| e | 1000 |\n| f | -1000 |\n| end | 14000 |", + '{type="waterfall" labels unit="requests/sec"}', + ), + ); + expect(html).not.toMatch(/class="mark-text role-mark/); + expect(texts(html)).toContain("10k requests/sec"); + }); + + it.each([3, 7])("fits waterfall values to the rendered spacing with %s steps", async (steps) => { + const changes = Array.from( + { length: steps - 2 }, + (_, index) => `| change-with-a-long-name-${index + 1} | 1000 |`, + ); + const document = await parseMarkdown( + chart( + `| step | value |\n| --- | ---: |\n| start | 1000000 |\n${changes.join("\n")}\n| end | ${1000000 + changes.length * 1000} |`, + '{type="waterfall" labels unit="requests/s"}', + ), + { plugins: [charts()] }, + ); + expect(document.meta.issues ?? []).toEqual([]); + const html = await renderHtmlFromDocument(document, { components }); + const labels = [...html.matchAll(/([\s\S]*?)<\/g>/g)] + .flatMap(([, group]) => [...group!.matchAll(/]*>[^<]+<\/text>/g)]) + .map(([tag]) => ({ x: position(tag)[0]!, width: guess(texts(tag)[0]!, 12) })) + .sort((a, b) => a.x - b.x); + for (let index = 1; index < labels.length; index++) { + const previous = labels[index - 1]!; + const current = labels[index]!; + expect(current.x - previous.x).toBeGreaterThanOrEqual( + (current.width + previous.width) / 2 + 6, + ); + } + expect(labels).toHaveLength(steps === 3 ? steps : 0); + expect(html).toMatch(/]*>1000000<\/td>/); + }); + + it("leaves heatmap labels that cannot fit to the data table", async () => { + const table = + "| name | a | b | c |\n| --- | ---: | ---: | ---: |\n| first | 1 | 10 | 100 |\n| second | 2 | 20 | 200 |"; + const html = await render(chart(table, '{type="heatmap" unit="requests/sec"}')); + expect(texts(html).filter((text) => text?.endsWith("requests/sec"))).toEqual([]); + expect(html).toMatch(/]*>100<\/td>/); + expect(texts(await render(chart(table, '{type="heatmap"}')))).toContain("100"); + }); +}); diff --git a/packages/charts/test/spec.test.ts b/packages/charts/test/spec.test.ts index ae4237361..e23f95fa6 100644 --- a/packages/charts/test/spec.test.ts +++ b/packages/charts/test/spec.test.ts @@ -32,11 +32,11 @@ describe("comark-charts with a Vega-Lite spec", () => { expect(await issues(markdown)).toEqual([]); const html = await render(markdown); expect(html).toContain( - '
Latency per request
Latency per request
/search"); // In the house inks, which the stylesheet swaps for the page's. - expect(html).toContain('fill="#3b82f6"'); + expect(html).toContain('fill="#4d97de"'); expect(html).toContain('
Data
annotateannotate
'); expect(html).toContain(""); expect(html).not.toContain("; diff --git a/packages/metrics/docs/index.md b/packages/metrics/docs/index.md index eb347881d..73b0e4fe8 100644 --- a/packages/metrics/docs/index.md +++ b/packages/metrics/docs/index.md @@ -190,7 +190,7 @@ Use the `unit` prop to set the unit of a difference from 0, for cells written wi #### Tiles -Use the `tiles` prop to show each row as a tile, with the name above the after value and the change below it in a pill. The before value counts toward the change but doesn't show. +Use the `tiles` prop to show the rows as one strip of figures, as a dashboard's header: each tile holds the after value, its name under it, and the change below in a pill, with hairlines between the tiles. The strip wraps onto more rows in a narrow column. The before value counts toward the change but doesn't show. ::example ```md @@ -325,9 +325,9 @@ Deltas, bars, and tables read these [shared tokens](/guide/theming): | `--comark-primary` | The progress bar | | `--comark-muted` | The value before, a change with no verdict, a bar's label and percent, a tile's name | | `--comark-font` | Inherited text, with tabular numbers | -| `--comark-border` | Tile borders | +| `--comark-border` | The strip's border and the hairlines between tiles | | `--comark-background` | Tile backgrounds | -| `--comark-radius` | Tile corner radius | +| `--comark-radius` | The strip's corner radius | ### Variables @@ -347,6 +347,6 @@ Set `--cmt-better` and `--cmt-worse` on `.cmt-delta`, and `--cmt-bar` on `.cmt-p | `.cmt-from`, `.cmt-to`, `.cmt-change` | Its value before, after, and the arrow with the percent | | `.cmt-progress` | A progress bar, with `.cmt-label` before it and `.cmt-percent` after | | `.cmt-stats` | A `::stats` table | -| `.cmt-tiles` | The grid rendered by `::stats{tiles}` | +| `.cmt-tiles` | The strip rendered by `::stats{tiles}` | | `.cmt-tile` | One stat tile, containing its name, value, and optional change | | `.cmt-value` | The large value inside a tile | diff --git a/packages/metrics/src/styles.ts b/packages/metrics/src/styles.ts index 6b9caaa7d..15448707c 100644 --- a/packages/metrics/src/styles.ts +++ b/packages/metrics/src/styles.ts @@ -2,7 +2,7 @@ import { theme } from "comark-zoom/theme"; /** * Deltas colored green when better and red when worse (shadcn/ui's `--destructive` when the page has it), a progress - * bar in the primary color, a stats table with its numbers aligned, and stat tiles like shadcn/ui's Card that wrap + * bar in the primary color, a stats table with its numbers aligned, and stat tiles as one strip of figures that wraps * to the width, on the shared `--comark-*` tokens. Override `--cmt-better`, `--cmt-worse`, and `--cmt-bar`. The rules * sit in the `comark` layer, so any unlayered rule of the host's wins. */ @@ -24,11 +24,14 @@ export const css = `@layer comark{${[ ".cmt-progress progress::-moz-progress-bar{border-radius:9999px;background:var(--cmt-bar)}", ".cmt-label,.cmt-percent{color:var(--_comark-muted);font-size:.875em}", ".cmt-stats :is(td,th):not(:first-child){text-align:end;font-variant-numeric:tabular-nums}", - ".cmt-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr));gap:.75rem;margin:1.5rem 0;font-family:var(--_comark-font)}", - ".cmt-tile{display:flex;flex-direction:column;align-items:flex-start;gap:.25rem;padding:1rem 1.25rem;border:1px solid var(--_comark-border);border-radius:calc(var(--_comark-radius) + 2px);background:var(--_comark-background)}", + // One strip of figures, as a dashboard's header: hairlines between the tiles, the number over its name. The strip's + // color shows through the 1px gaps as the lines, and the tiles grow to fill a row that wraps, so no gap is left. + ".cmt-tiles{display:flex;flex-wrap:wrap;gap:1px;margin:1.5rem 0;overflow:hidden;border:1px solid var(--_comark-border);border-radius:var(--_comark-radius);background:var(--_comark-border);font-family:var(--_comark-font)}", + ".cmt-tile{box-sizing:border-box;display:flex;flex:1 1 7rem;flex-direction:column;align-items:flex-start;gap:.125rem;min-width:0;padding:.75rem 1rem;background:var(--_comark-background)}", ".cmt-tile>*{margin:0}", - ".cmt-tile>dt{color:var(--_comark-muted);font-size:.875rem;font-weight:500}", - ".cmt-value{color:var(--_comark-foreground);font-size:1.875rem;font-weight:600;line-height:1.2;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}", + ".cmt-tile>dt{order:1;color:var(--_comark-muted);font-size:.75rem;font-weight:400;line-height:1rem}", + ".cmt-value{color:var(--_comark-foreground);font-size:1.5rem;font-weight:500;line-height:1.2;letter-spacing:-.02em;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}", // The change's color is its verdict's, so the pill tints itself with it. + ".cmt-tile>dd:not(.cmt-value){order:2;margin-top:.25rem}", ".cmt-tile .cmt-change{display:inline-block;padding:.125rem .5rem;border-radius:9999px;background:color-mix(in srgb,currentColor 12%,transparent);font-size:.75rem}", ].join("")}}`; diff --git a/skills/comark-ui/references/charts.md b/skills/comark-ui/references/charts.md index dc078d396..8ed39847e 100644 --- a/skills/comark-ui/references/charts.md +++ b/skills/comark-ui/references/charts.md @@ -20,7 +20,7 @@ On the page, a Copy button over the chart's corner copies it as a PNG, and its m - Dates on x read as time when they look like `2026`, `2026-04`, `2026-04-15`, or an ISO date and time. - Inside the block, the table comes first, then any marks or Vega-Lite spec. Write explanations above or below the block. -Props, all optional: `type` (below, default `bar` whatever the table holds, so write `type="line"` for a trend), `x` (the x column, default the first), `y` (the columns to draw, comma-separated, default all the others), `title` (the caption and the chart's name for screen readers), `labels` (prints the numbers on bars, waterfall steps, dumbbell rows, sankey nodes, and gantt bars, as in `::chart{type="stacked" labels}`; without it the axis and the data table carry them), and `horizontal` (turns `bar` and `stacked` bars sideways, for long names such as file paths; past 40 bars they turn on their own). Give every chart a `title` that says what it measures and in what unit. Two or more series always get a legend over the plot. +Props, all optional: `type` (below, default `bar` whatever the table holds, so write `type="line"` for a trend), `x` (the x column, default the first), `y` (the columns to draw, comma-separated, default all the others), `title` (the caption and the chart's name for screen readers), `description` (one line under the title saying what to read in the chart), `unit` (the numbers' unit on bars and the value axis: `$` prints before, as `$22.2k`, `%` or `ms` after; numbers from a thousand up print as three digits and a letter), `log` (a log scale for numbers that span orders of magnitude: alone for the numbers' axis, `log="x"` for a line or scatter over numbers, `log="both"`; every number on it must be above 0), `better` (`higher` or `lower`, on a heatmap: colors its cells red to green), `labels` (bars, stacked bars, and heatmaps print their numbers unless `labels=false`; on waterfalls, dumbbells, sankeys, and gantts write `labels` to print them), and `horizontal` (turns `bar` and `stacked` bars sideways, for long names such as file paths; past 40 bars they turn on their own). Give every chart a `title` that says what it measures and in what unit, and a `description` that says what the reader should see. A line chart of two to eight series names each line at its end; other charts of two or more series get a legend over the plot. ## Choose the type @@ -35,14 +35,15 @@ Props, all optional: `type` (below, default `bar` whatever the table holds, so w | Before and after for several metrics | `dumbbell` | metric names, then a `before` and an `after` column | | How changes add up to a total | `waterfall` | the start total, each change (negative for a drop), then the end total, which must be the start plus every change, in one column | | Shares of one whole, five or fewer | `pie` | names, then one column of non-negative numbers | +| One table against another, such as counts per customer and type, or scores per agent and section | `heatmap` | names, then one column per column of cells; add `better="higher"` when the numbers rate something | | Steps or tasks over time, such as a build trace, a deploy, or a plan | `gantt` | the task, then a start and an end column, as numbers or dates; a column of names, such as a run or a phase, colors the bars | | Where an amount goes, such as a budget, a cashflow, or a bundle's bytes | `sankey` | where each flow comes from, where it goes, and how much; a flow can't loop back, and a node in the middle can't send out more than it takes in | -When none of these fit, such as a box plot or a heatmap, put a Vega-Lite spec after the table in a fenced `vega-lite` block. The table is its data, so leave `data` out of the spec and name the table's headers as fields; marks don't apply. +When none of these fit, such as a box plot or a histogram, put a Vega-Lite spec after the table in a fenced `vega-lite` block. The table is its data, so leave `data` out of the spec and name the table's headers as fields; marks don't apply. In a `gantt`, each row is one bar in table order, and a row that ends where it starts is a milestone, drawn as a diamond. Write dates as `2026-04-15` or `2026-04-15T09:30`; a time without a zone is UTC. To compare runs, add a column naming the run and repeat the steps in each. -For a before and after of one or two metrics, grouped `bar` reads best; past three, use `dumbbell`. Keep the rows in the order the reader should read them: charts draw them in table order. +For a before and after of one or two metrics, grouped `bar` reads best; past three, use `dumbbell`. For costs, timings, or token counts that run from cents to thousands, add `log` rather than letting the largest bar flatten the rest. Keep the rows in the order the reader should read them: charts draw them in table order. ## Mark what matters diff --git a/skills/report/SKILL.md b/skills/report/SKILL.md index 9431c4c59..2ba472bb3 100644 --- a/skills/report/SKILL.md +++ b/skills/report/SKILL.md @@ -29,8 +29,11 @@ A section opens with one sentence that says what to look at, then its picture. A | The section answers | Picture | | --- | --- | | Which items are worth it, by two scores and a size | Bubble chart: `::chart` with a `vega-lite` spec | -| How big each item is, or what each blocker would unlock | `::chart{type="bar" horizontal labels}` | +| The headline figures, before any section | `::stats{tiles}` with two columns: name, value | +| How big each item is, or what each blocker would unlock | `::chart{type="bar" horizontal}` | | How each item's size splits into parts | `::chart{type="stacked" horizontal}` | +| How two groupings cross, such as customer by type, or a score per agent and section | `::chart{type="heatmap"}`, with `better="higher"` for scores | +| A trend, or a measure against cost across orders of magnitude | `::chart{type="line"}`, with `log` or `log="x"` and `unit="$"` | | In what order, and what has to land first | `mermaid` flowchart, blockers as `{{hexagons}}` | | How a mechanism moves between states | `mermaid` `stateDiagram-v2` | | A number before and after | `::stats` or `:delta` | @@ -40,7 +43,7 @@ A section opens with one sentence that says what to look at, then its picture. A | What you left out | Bar chart of the largest, then `::accordion` grouped by reason | | How you checked | `> [!CAUTION]` for what you didn't verify, kept open; sources in `::details` | -A table cell holds a value, a badge, or a few words. The reasoning behind a cell goes in that item's accordion. A column whose cells are all the same says one thing, so say it in the section's sentence and drop the column. Two charts that compare the same unit share one scale, so draw them as one chart, or fix the axis domain in both. +Give every chart a `description` that says what to see in it, and a `unit` when its numbers have one, so its labels read `$22.2k` rather than `22200`. A table cell holds a value, a badge, or a few words. The reasoning behind a cell goes in that item's accordion. A column whose cells are all the same says one thing, so say it in the section's sentence and drop the column. Two charts that compare the same unit share one scale, so draw them as one chart, or fix the axis domain in both. [references/blocks.md](references/blocks.md) has the syntax for each, with the gotchas the renderer doesn't report. The `comark-ui` skill documents every block in full.
/login