Merge pull request #2790 from qdrant/codex/chart-typography-options

feat: add opt-in typography options for generated charts
This commit is contained in:
Dylan Couzon
2026-09-25 17:44:53 -04:00
committed by GitHub
8 changed files with 198 additions and 11 deletions
@@ -0,0 +1,7 @@
statistic,series,total_ms,per_item_ms
p50,Method A,20,2
p50,Method B,35,3.5
p50,Method with a longer label,50,5
p99,Method A,45,4.5
p99,Method B,65,6.5
p99,Method with a longer label,90,9
1 statistic series total_ms per_item_ms
2 p50 Method A 20 2
3 p50 Method B 35 3.5
4 p50 Method with a longer label 50 5
5 p99 Method A 45 4.5
6 p99 Method B 65 6.5
7 p99 Method with a longer label 90 9
@@ -0,0 +1,31 @@
{
"title": "Illustrative fixture: readable grouped columns",
"kind": "grouped-columns",
"group": "statistic",
"series": "series",
"colors": [
0,
1,
"muted"
],
"height": 560,
"width": 640,
"legendRoom": 120,
"readableType": true,
"views": [
{
"label": "Total",
"subtitle": "Illustrative data, lower is better",
"y": "total_ms",
"yLabel": "time (ms)",
"yMax": 100
},
{
"label": "Per item",
"subtitle": "Illustrative data divided by ten items",
"y": "per_item_ms",
"yLabel": "time per item (ms)",
"yMax": 10
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
---
title: "Chart typography fixture"
draft: true
---
Synthetic data for testing opt-in typography, not a benchmark.
{{< chart id="fixtures/readable" caption="Illustrative totals range from 20 to 90 ms." caption2="Illustrative per-item times range from 2 to 9 ms." >}}
+1 -1
View File
@@ -27,4 +27,4 @@
{{- else if eq $spec.kind "lines-facet" -}}
{{- $room = cond (gt (len $spec.series) 1) $cfg.legendRoom $cfg.facetRoom -}}
{{- end -}}
{{- partial "viz-figure.html" (dict "svg" $res.Content "viewBox" (printf "0 0 %d %d" (int $cfg.width) (add (int $spec.height) (int $room))) "caption" $c.caption "id" (replace $c.id "/" "-") "class" "viz-figure--chart" "views" $c.views "captions" $c.captions) -}}
{{- partial "viz-figure.html" (dict "svg" $res.Content "viewBox" (printf "0 0 %d %d" (int ($spec.width | default $cfg.width)) (add (int $spec.height) (int ($spec.legendRoom | default $room)))) "caption" $c.caption "id" (replace $c.id "/" "-") "class" "viz-figure--chart" "views" $c.views "captions" $c.captions) -}}
+11
View File
@@ -0,0 +1,11 @@
# Chart typography options
Existing chart specs retain their current output. No theme, palette, or default style changes are needed.
For `grouped-columns` only, `readableType: true` opts into 1.8× the existing type roles, wrapped headings, a stacked legend, and more heading space. Set `height` to at least 480 and `legendRoom` to at least `34 × number of series + 18`. The generator rejects insufficient reserved space. This is a layout preset, not a promise of minimum rendered font size: inspect the actual article column.
Optional `width` is an integer of at least 320. Generator and HTML shortcode use the same width; omitting it preserves the shared width. Optional positive-integer `legendRoom` replaces the default bottom reservation. Avoid per-view overrides of these geometry options: all views share one outer SVG. `readableType` belongs at the top level too.
See `assets/viz/fixtures/readable.json` for a synthetic example and `content/blog/viz-typography-fixture.md` for its draft embed. CSV values, chart colors, view switching, and Markdown tables follow the existing pipeline. Inline chart assets remain generated SVG fragments, not standalone image exports.
Run `npm run viz:charts` and `npm run viz:test`. Regenerate twice and compare hashes; existing non-opted-in chart SVGs should be unchanged. Inspect all views and themes for long-heading or legend collisions. These options apply to generated charts, not interactive islands, static diagrams, or other chart kinds' large-type layout. A separate responsive solution is still needed where a fixed-viewBox figure falls below readable sizes.
+58 -10
View File
@@ -16,15 +16,27 @@ import { readFileSync, writeFileSync, mkdirSync, globSync } from 'node:fs';
import { dirname } from 'node:path';
const viz = JSON.parse(readFileSync('data/viz.json', 'utf8'));
const baseType = { ...viz.type };
let readableType = false;
// One spec per chart, beside its data. The id is the path.
const manifest = globSync('assets/viz/**/*.json').sort().map((p) => {
const id = p.replace(/^assets\/viz\//, '').replace(/\.json$/, '');
const spec = JSON.parse(readFileSync(p, 'utf8'));
// The shortcode builds the viewBox from the shared width, so a per-chart one
// would draw at its own size inside a 980-wide box and stretch.
if ('width' in spec) {
throw new Error(`${p}: charts share one width (data/viz.json chart.width). Remove "width".`);
// Opt-in sizes must also be used by the Hugo wrapper's viewBox.
if ('width' in spec && (!Number.isInteger(spec.width) || spec.width < 320)) {
throw new Error(`${p}: width must be an integer of at least 320`);
}
if ('legendRoom' in spec && (!Number.isInteger(spec.legendRoom) || spec.legendRoom <= 0)) {
throw new Error(`${p}: legendRoom must be a positive integer`);
}
if (spec.readableType && spec.kind !== 'grouped-columns') {
throw new Error(`${p}: readableType is supported only for grouped-columns`);
}
for (const view of spec.views ?? []) {
if (['width', 'height', 'legendRoom', 'readableType'].some(key => key in view)) {
throw new Error(`${p}: geometry and typography options must be top-level, not per-view`);
}
}
return { id, data: `assets/viz/${id}.csv`, width: viz.chart.width, ...spec };
});
@@ -39,15 +51,41 @@ const MUTED = 'var(--qi-muted)';
const GRIDC = 'var(--qi-line)';
// Shared layout, so two charts in different posts read as the same object.
// One width for the same reason: the SVG scales to the column, so a wider
// viewBox renders identical font sizes smaller.
// Default width and layout remain unchanged. Grouped-column charts can opt
// into larger type and a matching smaller viewBox for narrow article columns.
const LAYOUT = {
top: 62, right: 36, bottom: 68, left: 74,
gap: 44, titleY: 20, subtitleY: 38,
};
// Panel heading, shared so the title/subtitle block sits identically everywhere.
const panelHead = (w, title, subtitle) =>
// Larger type needs wrapped headings; keep source strings intact in the spec.
function readableHeading(w, title, subtitle) {
let y = 30;
let output = '';
for (const [value, size, weight] of [
[title, viz.type.title, 700],
[subtitle, viz.type.subtitle, 400],
]) {
if (!value) continue;
const limit = Math.floor((w - 72) / (size * 0.62));
const words = value.split(' ').flatMap((word) =>
word.length > limit ? word.match(new RegExp(`.{1,${limit}}`, 'g')) : [word]);
const lines = [''];
for (const word of words) {
const i = lines.length - 1;
if ((lines[i] + ' ' + word).trim().length > limit && lines[i]) lines.push(word);
else lines[i] = (lines[i] + ' ' + word).trim();
}
for (const line of lines) {
output += `<text x="${w / 2}" y="${y}" text-anchor="middle" font-family="${MONO}"`
+ ` font-size="${size}" font-weight="${weight}" fill="${INK}">${esc(line)}</text>`;
y += size * 1.5;
}
}
return output;
}
const panelHead = (w, title, subtitle) => readableType ? readableHeading(w,title,subtitle) :
`<text x="${w / 2}" y="${LAYOUT.titleY}" text-anchor="middle" font-family="${MONO}"`
+ ` font-size="${viz.type.title}" font-weight="700" fill="${INK}">${esc(title)}</text>`
+ (subtitle
@@ -57,7 +95,7 @@ const panelHead = (w, title, subtitle) =>
// Rotated y-axis label, shared for the same reason.
const yAxisLabel = (h, text) =>
`<text transform="translate(13,${(LAYOUT.top + (h - LAYOUT.bottom)) / 2}) rotate(-90)"`
`<text transform="translate(${readableType ? 28 : 13},${(LAYOUT.top + (h - LAYOUT.bottom)) / 2}) rotate(-90)"`
+ ` text-anchor="middle" font-family="${MONO}" font-size="${viz.type.label}"`
+ ` fill="${MUTED}">${esc(text)}</text>`;
@@ -289,8 +327,12 @@ function legend(c, w) {
// kinds cannot express: N categories x M methods.
function groupedColumns(c) {
const data = readCsv(c.data);
if (readableType && c.height < 480) throw new Error(`${c.id}: readableType needs height >= 480`);
const groups = [...new Set(data.map((d) => d[c.group]))];
const series = [...new Set(data.map((d) => d[c.series]))];
if (readableType && (c.legendRoom ?? viz.chart.legendRoom) < series.length * 34 + 18) {
throw new Error(`${c.id}: readableType needs legendRoom >= ${series.length * 34 + 18}`);
}
const colors = c.colors.map((k) => (k === 'muted' ? viz.palette.muted : viz.palette.categorical[k]));
const { top: mT, bottom: mB, left: mL, right: mR } = LAYOUT;
@@ -301,7 +343,7 @@ function groupedColumns(c) {
style: { fontFamily: MONO, fontSize: `${viz.type.axis}px`, background: 'none', color: MUTED },
x: { axis: null, domain: series },
fx: { label: null, domain: groups, tickFormat: (v) => v, tickSize: 0 },
y: { label: null, domain: [0, c.yMax], grid: true, nice: false, tickSize: 0 },
y: { label: null, ticks: readableType ? 5 : undefined, domain: [0, c.yMax], grid: true, nice: false, tickSize: 0 },
color: { domain: series, range: colors },
marks: [
Plot.barY(data, { fx: c.group, x: c.series, y: c.y, fill: c.series, rx: 1.5, inset: 2 }),
@@ -336,8 +378,9 @@ function groupedColumns(c) {
+ ` height="${c.height - mT - mB}" fill="transparent"/>`;
}).join('');
const legendWidth = readableType ? Math.max(...series.map(name => name.length)) * viz.type.label * 0.62 + 42 : 190;
const legendRow = series.map((name, i) =>
`<g transform="translate(${(c.width - series.length * 190) / 2 + i * 190},${c.height + 14})">`
`<g transform="translate(${readableType ? (c.width - (name.length * viz.type.label * 0.62 + 18)) / 2 : (c.width - series.length * legendWidth) / 2 + i * legendWidth},${c.height + 14 + (readableType ? i * 34 : 0)})">`
+ `<rect width="11" height="11" rx="2" fill="${colors[i]}"/>`
+ `<text x="18" y="10" font-family="${MONO}" font-size="${viz.type.label}"`
+ ` fill="${MUTED}">${esc(name)}</text></g>`).join('');
@@ -371,6 +414,11 @@ function render(c) {
}
for (const c of manifest) {
readableType = c.readableType === true;
viz.type = Object.fromEntries(Object.entries(baseType).map(([key,value]) => [key, typeof value === 'number' && readableType ? value * 1.8 : value]));
LAYOUT.top = readableType ? 224 : 62;
LAYOUT.left = readableType ? 100 : 74;
const out = `assets/viz/${c.id}.svg`;
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, `${render(c)}\n`);
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { csvParse } from 'd3-dsv';
import { join } from 'node:path';
import { buildSite } from './helpers.mjs';
const fixture = ext => readFileSync(join(buildSite(), 'blog/viz-typography-fixture', `index.${ext}`), 'utf8');
test('opt-in chart dimensions preserve both data views and reserve the stacked legend', () => {
const doc = new JSDOM(fixture('html')).window.document;
const svg = doc.querySelector('[aria-labelledby="viz-cap-fixtures-readable"]');
assert.equal(svg.getAttribute('viewBox'), '0 0 640 680');
const spec = JSON.parse(readFileSync('assets/viz/fixtures/readable.json', 'utf8'));
const rows = csvParse(readFileSync('assets/viz/fixtures/readable.csv', 'utf8'));
const views = [...svg.querySelectorAll('[data-viz-view]')];
assert.equal(views.length, 2);
for (const [i, view] of views.entries()) {
const actual = [...view.querySelectorAll('[data-viz-rows]')]
.flatMap(zone => JSON.parse(zone.getAttribute('data-viz-rows')).map(row => Number(row.v)));
assert.deepEqual(actual, rows.map(row => Number(row[spec.views[i].y])));
const legend = [...view.querySelectorAll('g[transform]')].filter(g => g.querySelector('text')?.textContent.startsWith('Method'));
assert.equal(legend.length, 3);
for (const item of legend) {
const [, y] = item.getAttribute('transform').match(/translate\([^,]+,([\d.]+)\)/);
assert.ok(Number(y) + 22 < 680, 'legend must fit inside the outer viewBox');
}
}
const markdown = fixture('md');
assert.match(markdown, /\| p99 \| Method with a longer label \| 90 \| 9 \|/);
assert.match(markdown, /Illustrative per-item times range from 2 to 9 ms/);
});
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { execFileSync } from 'node:child_process';
test('invalid opt-in geometry fails generation rather than shipping clipped layouts', () => {
const dir = mkdtempSync(join(tmpdir(), 'viz-invalid-'));
try {
mkdirSync(join(dir, 'assets/viz'), { recursive: true });
mkdirSync(join(dir, 'data'));
writeFileSync(join(dir, 'data/viz.json'), readFileSync('data/viz.json'));
writeFileSync(join(dir, 'assets/viz/example.csv'), readFileSync('assets/viz/fixtures/readable.csv'));
const source = JSON.parse(readFileSync('assets/viz/fixtures/readable.json', 'utf8'));
const cases = [
[{ width: 12.5 }, /width must be an integer/],
[{ legendRoom: 30 }, /needs legendRoom/],
[{ height: 200 }, /needs height/],
[{ kind: 'lines-facet' }, /supported only for grouped-columns/],
[{ views: [{ ...source.views[0], width: 400 }] }, /must be top-level/],
];
for (const [patch, message] of cases) {
writeFileSync(join(dir, 'assets/viz/example.json'), JSON.stringify({ ...source, ...patch }));
assert.throws(() => execFileSync(process.execPath, [resolve('scripts/viz/generate-charts.mjs')], { cwd: dir, stdio: 'pipe' }), err => message.test(String(err.stderr)));
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});