mirror of
https://github.com/qdrant/landing_page.git
synced 2026-09-26 14:38:30 +02:00
Merge pull request #2790 from qdrant/codex/chart-typography-options
feat: add opt-in typography options for generated charts
This commit is contained in:
@@ -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
|
||||
|
@@ -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." >}}
|
||||
@@ -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) -}}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user