Make chart typography and legend layout independently configurable

This commit is contained in:
Dylan Couzon
2026-09-25 18:23:21 -04:00
parent cebe99ea3a
commit 93a9a913c5
12 changed files with 336 additions and 14 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: independent chart layout",
"kind": "grouped-columns",
"group": "statistic",
"series": "series",
"colors": [
0,
1,
"muted"
],
"height": 400,
"width": 800,
"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
}
],
"textScale": 1.4,
"legendLayout": "auto"
}
@@ -0,0 +1,21 @@
<g data-viz-height="441"><g data-viz-view="0"><text x="400" y="29" text-anchor="middle" font-family="'Geist Mono', ui-monospace, monospace" font-size="21" font-weight="700" fill="var(--qi-fg)">Illustrative fixture: independent chart layout</text><text x="400" y="60.5" text-anchor="middle" font-family="'Geist Mono', ui-monospace, monospace" font-size="18.2" font-weight="400" fill="var(--qi-fg)">Illustrative data, lower is better</text><text transform="translate(28,226.5) rotate(-90)" text-anchor="middle" font-family="'Geist Mono', ui-monospace, monospace" font-size="16.799999999999997" fill="var(--qi-muted)">time (ms)</text><g fill="currentColor" style="color:var(--qi-muted)" font-family="'Geist Mono', ui-monospace, monospace" font-size="18.2" text-anchor="middle"><style>:where(.plot-d6a7b5) {
--plot-background: white;
display: block;
height: auto;
height: intrinsic;
max-width: 100%;
}
:where(.plot-d6a7b5 text),
:where(.plot-d6a7b5 tspan) {
white-space: pre;
}</style><g aria-label="fx-axis tick label" transform="translate(0,9)"><g transform="translate(1,0)"><text y="0.71em" transform="translate(243,345)">p50</text></g><g transform="translate(359,0)"><text y="0.71em" transform="translate(243,345)">p99</text></g></g><g aria-label="y-grid" aria-hidden="true"><g stroke="currentColor" stroke-opacity="0.1" transform="translate(1,0)"><line x1="82" x2="404" y1="345" y2="345"></line><line x1="82" x2="404" y1="297.6" y2="297.6"></line><line x1="82" x2="404" y1="250.2" y2="250.2"></line><line x1="82" x2="404" y1="202.8" y2="202.8"></line><line x1="82" x2="404" y1="155.39999999999998" y2="155.39999999999998"></line><line x1="82" x2="404" y1="108" y2="108"></line></g><g stroke="currentColor" stroke-opacity="0.1" transform="translate(359,0)"><line x1="82" x2="404" y1="345" y2="345"></line><line x1="82" x2="404" y1="297.6" y2="297.6"></line><line x1="82" x2="404" y1="250.2" y2="250.2"></line><line x1="82" x2="404" y1="202.8" y2="202.8"></line><line x1="82" x2="404" y1="155.39999999999998" y2="155.39999999999998"></line><line x1="82" x2="404" y1="108" y2="108"></line></g></g><g aria-label="y-axis tick label" transform="translate(-9,0)"><g text-anchor="end" font-variant="tabular-nums" transform="translate(1,0)"><text y="0.32em" transform="translate(82,345)">0</text><text y="0.32em" transform="translate(82,297.6)">20</text><text y="0.32em" transform="translate(82,250.2)">40</text><text y="0.32em" transform="translate(82,202.8)">60</text><text y="0.32em" transform="translate(82,155.39999999999998)">80</text><text y="0.32em" transform="translate(82,108)">100</text></g></g><g aria-label="bar"><g transform="translate(1,0)"><rect data-viz-key="p50" x="96" width="89" y="299.6" height="43.39999999999998" rx="1.5" fill="#dc244c"></rect><rect data-viz-key="p50" x="199" width="89" y="264.05" height="78.94999999999999" rx="1.5" fill="#6047ff"></rect><rect data-viz-key="p50" x="302" width="89" y="228.5" height="114.5" rx="1.5" fill="#abb1c7"></rect></g><g transform="translate(359,0)"><rect data-viz-key="p99" x="96" width="89" y="240.35000000000002" height="102.64999999999998" rx="1.5" fill="#dc244c"></rect><rect data-viz-key="p99" x="199" width="89" y="192.95" height="150.05" rx="1.5" fill="#6047ff"></rect><rect data-viz-key="p99" x="302" width="89" y="133.7" height="209.3" rx="1.5" fill="#abb1c7"></rect></g></g></g><rect data-viz-zone data-viz-key="p50" data-viz-title="p50" data-viz-rows="[{&quot;k&quot;:&quot;Method A&quot;,&quot;v&quot;:20,&quot;c&quot;:&quot;#dc244c&quot;},{&quot;k&quot;:&quot;Method B&quot;,&quot;v&quot;:35,&quot;c&quot;:&quot;#6047ff&quot;},{&quot;k&quot;:&quot;Method with a longer label&quot;,&quot;v&quot;:50,&quot;c&quot;:&quot;#abb1c7&quot;}]" tabindex="0" role="button" aria-label="p50" x="82" y="108" width="341" height="237" fill="transparent"/><rect data-viz-zone data-viz-key="p99" data-viz-title="p99" data-viz-rows="[{&quot;k&quot;:&quot;Method A&quot;,&quot;v&quot;:45,&quot;c&quot;:&quot;#dc244c&quot;},{&quot;k&quot;:&quot;Method B&quot;,&quot;v&quot;:65,&quot;c&quot;:&quot;#6047ff&quot;},{&quot;k&quot;:&quot;Method with a longer label&quot;,&quot;v&quot;:90,&quot;c&quot;:&quot;#abb1c7&quot;}]" tabindex="0" role="button" aria-label="p99" x="423" y="108" width="341" height="237" fill="transparent"/><g data-viz-legend transform="translate(112.15199999999999,424.8)"><rect y="-10" width="11" height="11" rx="2" fill="#dc244c"/><text x="18" y="0" font-family="'Geist Mono', ui-monospace, monospace" font-size="16.799999999999997" fill="var(--qi-muted)">Method A</text></g><g data-viz-legend transform="translate(246.856,424.8)"><rect y="-10" width="11" height="11" rx="2" fill="#6047ff"/><text x="18" y="0" font-family="'Geist Mono', ui-monospace, monospace" font-size="16.799999999999997" fill="var(--qi-muted)">Method B</text></g><g data-viz-legend transform="translate(381.56,424.8)"><rect y="-10" width="11" height="11" rx="2" fill="#abb1c7"/><text x="18" y="0" font-family="'Geist Mono', ui-monospace, monospace" font-size="16.799999999999997" fill="var(--qi-muted)">Method with a longer label</text></g></g><g data-viz-view="1" style="display:none"><text x="400" y="29" text-anchor="middle" font-family="'Geist Mono', ui-monospace, monospace" font-size="21" font-weight="700" fill="var(--qi-fg)">Illustrative fixture: independent chart layout</text><text x="400" y="60.5" text-anchor="middle" font-family="'Geist Mono', ui-monospace, monospace" font-size="18.2" font-weight="400" fill="var(--qi-fg)">Illustrative data divided by ten items</text><text transform="translate(28,226.5) rotate(-90)" text-anchor="middle" font-family="'Geist Mono', ui-monospace, monospace" font-size="16.799999999999997" fill="var(--qi-muted)">time per item (ms)</text><g fill="currentColor" style="color:var(--qi-muted)" font-family="'Geist Mono', ui-monospace, monospace" font-size="18.2" text-anchor="middle"><style>:where(.plot-d6a7b5) {
--plot-background: white;
display: block;
height: auto;
height: intrinsic;
max-width: 100%;
}
:where(.plot-d6a7b5 text),
:where(.plot-d6a7b5 tspan) {
white-space: pre;
}</style><g aria-label="fx-axis tick label" transform="translate(0,9)"><g transform="translate(1,0)"><text y="0.71em" transform="translate(243,345)">p50</text></g><g transform="translate(359,0)"><text y="0.71em" transform="translate(243,345)">p99</text></g></g><g aria-label="y-grid" aria-hidden="true"><g stroke="currentColor" stroke-opacity="0.1" transform="translate(1,0)"><line x1="82" x2="404" y1="345" y2="345"></line><line x1="82" x2="404" y1="297.6" y2="297.6"></line><line x1="82" x2="404" y1="250.2" y2="250.2"></line><line x1="82" x2="404" y1="202.8" y2="202.8"></line><line x1="82" x2="404" y1="155.39999999999998" y2="155.39999999999998"></line><line x1="82" x2="404" y1="108" y2="108"></line></g><g stroke="currentColor" stroke-opacity="0.1" transform="translate(359,0)"><line x1="82" x2="404" y1="345" y2="345"></line><line x1="82" x2="404" y1="297.6" y2="297.6"></line><line x1="82" x2="404" y1="250.2" y2="250.2"></line><line x1="82" x2="404" y1="202.8" y2="202.8"></line><line x1="82" x2="404" y1="155.39999999999998" y2="155.39999999999998"></line><line x1="82" x2="404" y1="108" y2="108"></line></g></g><g aria-label="y-axis tick label" transform="translate(-9,0)"><g text-anchor="end" font-variant="tabular-nums" transform="translate(1,0)"><text y="0.32em" transform="translate(82,345)">0</text><text y="0.32em" transform="translate(82,297.6)">2</text><text y="0.32em" transform="translate(82,250.2)">4</text><text y="0.32em" transform="translate(82,202.8)">6</text><text y="0.32em" transform="translate(82,155.39999999999998)">8</text><text y="0.32em" transform="translate(82,108)">10</text></g></g><g aria-label="bar"><g transform="translate(1,0)"><rect data-viz-key="p50" x="96" width="89" y="299.6" height="43.39999999999998" rx="1.5" fill="#dc244c"></rect><rect data-viz-key="p50" x="199" width="89" y="264.05" height="78.94999999999999" rx="1.5" fill="#6047ff"></rect><rect data-viz-key="p50" x="302" width="89" y="228.5" height="114.5" rx="1.5" fill="#abb1c7"></rect></g><g transform="translate(359,0)"><rect data-viz-key="p99" x="96" width="89" y="240.35000000000002" height="102.64999999999998" rx="1.5" fill="#dc244c"></rect><rect data-viz-key="p99" x="199" width="89" y="192.95" height="150.05" rx="1.5" fill="#6047ff"></rect><rect data-viz-key="p99" x="302" width="89" y="133.7" height="209.3" rx="1.5" fill="#abb1c7"></rect></g></g></g><rect data-viz-zone data-viz-key="p50" data-viz-title="p50" data-viz-rows="[{&quot;k&quot;:&quot;Method A&quot;,&quot;v&quot;:2,&quot;c&quot;:&quot;#dc244c&quot;},{&quot;k&quot;:&quot;Method B&quot;,&quot;v&quot;:3.5,&quot;c&quot;:&quot;#6047ff&quot;},{&quot;k&quot;:&quot;Method with a longer label&quot;,&quot;v&quot;:5,&quot;c&quot;:&quot;#abb1c7&quot;}]" tabindex="0" role="button" aria-label="p50" x="82" y="108" width="341" height="237" fill="transparent"/><rect data-viz-zone data-viz-key="p99" data-viz-title="p99" data-viz-rows="[{&quot;k&quot;:&quot;Method A&quot;,&quot;v&quot;:4.5,&quot;c&quot;:&quot;#dc244c&quot;},{&quot;k&quot;:&quot;Method B&quot;,&quot;v&quot;:6.5,&quot;c&quot;:&quot;#6047ff&quot;},{&quot;k&quot;:&quot;Method with a longer label&quot;,&quot;v&quot;:9,&quot;c&quot;:&quot;#abb1c7&quot;}]" tabindex="0" role="button" aria-label="p99" x="423" y="108" width="341" height="237" fill="transparent"/><g data-viz-legend transform="translate(112.15199999999999,424.8)"><rect y="-10" width="11" height="11" rx="2" fill="#dc244c"/><text x="18" y="0" font-family="'Geist Mono', ui-monospace, monospace" font-size="16.799999999999997" fill="var(--qi-muted)">Method A</text></g><g data-viz-legend transform="translate(246.856,424.8)"><rect y="-10" width="11" height="11" rx="2" fill="#6047ff"/><text x="18" y="0" font-family="'Geist Mono', ui-monospace, monospace" font-size="16.799999999999997" fill="var(--qi-muted)">Method B</text></g><g data-viz-legend transform="translate(381.56,424.8)"><rect y="-10" width="11" height="11" rx="2" fill="#abb1c7"/><text x="18" y="0" font-family="'Geist Mono', ui-monospace, monospace" font-size="16.799999999999997" fill="var(--qi-muted)">Method with a longer label</text></g></g></g>
@@ -6,3 +6,5 @@ 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." >}}
{{< chart id="fixtures/flexible" caption="Illustrative totals range from 20 to 90 ms." caption2="Illustrative per-item times range from 2 to 9 ms." >}}
+7 -1
View File
@@ -27,4 +27,10 @@
{{- 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 ($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) -}}
{{- $height := add (int $spec.height) (int ($spec.legendRoom | default $room)) -}}
{{- if or (isset $spec "textScale") (isset $spec "legendLayout") -}}
{{- $metadata := findRE `data-viz-height="[0-9]+"` $res.Content 1 -}}
{{- if not $metadata -}}{{ errorf "chart %s: generated layout metadata missing; run npm run viz:charts" $c.id }}{{ end -}}
{{- range $metadata -}}{{ $height = int (replaceRE `[^0-9]` "" .) }}{{ end -}}
{{- end -}}
{{- partial "viz-figure.html" (dict "svg" $res.Content "viewBox" (printf "0 0 %d %d" (int ($spec.width | default $cfg.width)) $height) "caption" $c.caption "id" (replace $c.id "/" "-") "class" "viz-figure--chart" "views" $c.views "captions" $c.captions) -}}
+17
View File
@@ -9,3 +9,20 @@ Optional `width` is an integer of at least 320. Generator and HTML shortcode use
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.
## Independent sizing and legends
New grouped-column charts can use `textScale` (a number from 0.75 to 2.5) and `legendLayout` (`auto`, `row`, or `stacked`) independently. `auto` uses one row when the complete labels fit; otherwise it stacks and wraps them. An explicit `row` fails generation if it would clip. These settings currently apply only to grouped-column charts; other chart kinds reject them instead of silently ignoring them. Islands retain their own layout and typography.
```json
{
"textScale": 1.4,
"legendLayout": "auto",
"width": 800,
"height": 400
}
```
These are additional top-level fields on a normal chart spec, not a complete spec. Text scale multiplies the existing named type roles. It defaults to 1, although `readableType: true` still supplies 1.8 when no explicit scale is set. An explicit scale overrides that legacy preset's scale. Either new field opts into calculated heading and legend spacing. The tallest heading across all views reserves one consistent plot area; legend space is calculated and stored in the generated SVG for Hugo to consume. `height` includes the heading and plot, excluding the automatically reserved legend. Leave `legendRoom` out unless you need extra whitespace; a value smaller than the required space is rejected. At least 140 SVG units must remain for the plot. Overlong axis labels fail generation with a sizing hint. Alternate views must share the same data source, chart kind, and series field. Regenerate after changing any layout option. Existing specs without either new field remain byte-identical, including the original `readableType` preset.
These dimensions are SVG units, not guaranteed on-screen font sizes. Inspect actual article widths and all views. These options do not add responsive plot reflow or remove the existing narrow-screen scrolling policy. Blog chart chrome and tooltips now follow the blog's light-only surface; theme-aware pages retain their existing explicit/system theme behavior. Series colors and values do not change with either policy.
@@ -0,0 +1,74 @@
// SVG units, before the host scales the chart. No browser-only measurement.
// Conservative monospace advances leave space for font substitution.
const advance = (text, size) => [...text].length * size * 0.66;
export function wrapText(text, size, width) {
const limit = Math.max(1, Math.floor(width / (size * 0.66)));
const words = String(text || '').split(/\s+/).flatMap(word => {
const chunks = [];
while (word.length > limit) {
chunks.push(word.slice(0, limit));
word = word.slice(limit);
}
if (word) chunks.push(word);
return chunks;
});
const lines = [];
for (const word of words) {
const last = lines.length - 1;
if (last >= 0 && advance(`${lines[last]} ${word}`, size) <= width) lines[last] += ` ${word}`;
else lines.push(word);
}
return lines;
}
export function chartLayout(spec, series, type, groups = []) {
const width = spec.width;
const views = spec.views?.length ? spec.views.map(view => ({ ...spec, ...view })) : [spec];
const headings = views.map(view => {
let y = type.title + 8;
const lines = [];
for (const [text, size, weight] of [[view.title, type.title, 700], [view.subtitle, type.subtitle, 400]]) {
for (const line of wrapText(text, size, width - 72)) {
lines.push({ text: line, size, weight, y });
y += size * 1.5;
}
}
return { lines, bottom: y };
});
const top = Math.ceil(Math.max(...headings.map(h => h.bottom)) + 20);
const bottom = Math.ceil(Math.max(48, type.axis * 3));
if (spec.height - top - bottom < 140) throw new Error(`${spec.id}: height leaves less than 140 units for the plot`);
const left = Math.ceil(Math.max(74, type.axis * 4.5));
const axisCenter = (top + spec.height - bottom) / 2;
for (const view of views) {
const halfLabel = advance(String(view.yLabel || ''), type.label) / 2;
if (axisCenter - halfLabel < 8 || axisCenter + halfLabel > spec.height - 8) {
throw new Error(`${spec.id}: y-axis label does not fit; shorten the label or increase height`);
}
}
const groupWidth = (width - left - 36) / groups.length;
if (groups.some(group => advance(String(group), type.axis) > groupWidth - 8)) {
throw new Error(`${spec.id}: group labels do not fit; shorten the labels or increase width`);
}
const gap = 28;
const widths = series.map(name => 18 + advance(name, type.label));
const total = widths.reduce((a, b) => a + b, 0) + gap * (series.length - 1);
const mode = spec.legendLayout ?? 'auto';
const row = mode === 'row' || (mode === 'auto' && total <= width - 48);
if (row && total > width - 48) throw new Error(`${spec.id}: legend row does not fit; use auto or stacked, or increase width`);
let x = (width - total) / 2;
let y = spec.height + type.label + 8;
const legend = series.map((name, i) => {
const lines = row ? [name] : wrapText(name, type.label, width - 84);
const itemWidth = 18 + Math.max(...lines.map(line => advance(line, type.label)));
const item = { x: row ? x : (width - itemWidth) / 2, y, lines };
if (row) x += widths[i] + gap;
else y += lines.length * type.label * 1.5 + 8;
return item;
});
const requiredRoom = Math.ceil((row ? type.label + 8 : y - spec.height) + 16);
if (spec.legendRoom !== undefined && spec.legendRoom < requiredRoom) throw new Error(`${spec.id}: legendRoom must be at least ${requiredRoom}`);
return { headings, legend, top, bottom, left, height: spec.height + (spec.legendRoom ?? requiredRoom) };
}
+46 -8
View File
@@ -9,6 +9,7 @@
// - title + muted subtitle, centred
// - plain rotated y-axis label, no arrow
// Colours stay Qdrant's (data/viz.json), not that blog's terminal-green.
import { chartLayout } from './chart-layout.mjs';
import * as Plot from '@observablehq/plot';
import { JSDOM } from 'jsdom';
import { csvParse } from 'd3-dsv';
@@ -18,6 +19,8 @@ import { dirname } from 'node:path';
const viz = JSON.parse(readFileSync('data/viz.json', 'utf8'));
const baseType = { ...viz.type };
let readableType = false;
let flexibleLayout = null;
let viewIndex = 0;
// One spec per chart, beside its data. The id is the path.
const manifest = globSync('assets/viz/**/*.json').sort().map((p) => {
@@ -33,8 +36,16 @@ const manifest = globSync('assets/viz/**/*.json').sort().map((p) => {
if (spec.readableType && spec.kind !== 'grouped-columns') {
throw new Error(`${p}: readableType is supported only for grouped-columns`);
}
const flexible = 'textScale' in spec || 'legendLayout' in spec;
if (flexible && spec.kind !== 'grouped-columns') throw new Error(`${p}: textScale and legendLayout are supported only for grouped-columns`);
if ('textScale' in spec && (!Number.isFinite(spec.textScale) || spec.textScale < 0.75 || spec.textScale > 2.5)) throw new Error(`${p}: textScale must be a number from 0.75 to 2.5`);
if ('legendLayout' in spec && !['auto', 'row', 'stacked'].includes(spec.legendLayout)) throw new Error(`${p}: legendLayout must be auto, row, or stacked`);
if (flexible && (!Number.isInteger(spec.height) || spec.height <= 0)) throw new Error(`${p}: height must be a positive integer`);
for (const view of spec.views ?? []) {
if (['width', 'height', 'legendRoom', 'readableType'].some(key => key in view)) {
if (flexible && (view.data !== undefined || (view.series !== undefined && view.series !== spec.series) || (view.kind !== undefined && view.kind !== spec.kind))) {
throw new Error(`${p}: flexible views must share the data, chart kind, and series field`);
}
if (['width', 'height', 'legendRoom', 'readableType', 'textScale', 'legendLayout'].some(key => key in view)) {
throw new Error(`${p}: geometry and typography options must be top-level, not per-view`);
}
}
@@ -85,7 +96,9 @@ function readableHeading(w, title, subtitle) {
}
return output;
}
const panelHead = (w, title, subtitle) => readableType ? readableHeading(w,title,subtitle) :
const panelHead = (w, title, subtitle) => flexibleLayout
? flexibleLayout.headings[viewIndex].lines.map(line => `<text x="${w / 2}" y="${line.y}" text-anchor="middle" font-family="${MONO}" font-size="${line.size}" font-weight="${line.weight}" fill="${INK}">${esc(line.text)}</text>`).join('')
: 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
@@ -95,7 +108,7 @@ const panelHead = (w, title, subtitle) => readableType ? readableHeading(w,title
// Rotated y-axis label, shared for the same reason.
const yAxisLabel = (h, text) =>
`<text transform="translate(${readableType ? 28 : 13},${(LAYOUT.top + (h - LAYOUT.bottom)) / 2}) rotate(-90)"`
`<text transform="translate(${readableType || flexibleLayout ? 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>`;
@@ -327,10 +340,10 @@ 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`);
if (readableType && !flexibleLayout && 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) {
if (readableType && !flexibleLayout && (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]));
@@ -343,7 +356,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, ticks: readableType ? 5 : undefined, domain: [0, c.yMax], grid: true, nice: false, tickSize: 0 },
y: { label: null, ticks: readableType || flexibleLayout ? 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 }),
@@ -378,6 +391,15 @@ function groupedColumns(c) {
+ ` height="${c.height - mT - mB}" fill="transparent"/>`;
}).join('');
if (flexibleLayout) {
const legendMarks = flexibleLayout.legend.map((item, i) =>
`<g data-viz-legend transform="translate(${item.x},${item.y})">`
+ `<rect y="-10" width="11" height="11" rx="2" fill="${colors[i]}"/>`
+ item.lines.map((line, j) => `<text x="18" y="${j * viz.type.label * 1.5}" font-family="${MONO}" font-size="${viz.type.label}" fill="${MUTED}">${esc(line)}</text>`).join('')
+ '</g>').join('');
return panelHead(c.width, c.title, c.subtitle) + yAxisLabel(c.height, c.yLabel) + wrapPlot(plotted) + zones + legendMarks;
}
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(${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)})">`
@@ -406,6 +428,7 @@ function draw(c) {
function render(c) {
if (!c.views) return draw(c);
return c.views.map((v, i) => {
viewIndex = i;
const merged = { ...c, ...v, views: undefined };
// `hidden` is ignored on SVG elements; inline so it holds with no CSS.
return `<g data-viz-view="${i}"${i === 0 ? '' : ' style="display:none"'}>`
@@ -415,12 +438,27 @@ 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]));
const scale = c.textScale ?? (readableType ? 1.8 : 1);
flexibleLayout = null;
viewIndex = 0;
viz.type = Object.fromEntries(Object.entries(baseType).map(([key,value]) => [key, typeof value === 'number' ? value * scale : value]));
LAYOUT.top = readableType ? 224 : 62;
LAYOUT.left = readableType ? 100 : 74;
LAYOUT.bottom = 68;
if ('textScale' in c || 'legendLayout' in c) {
const rows = readCsv(c.data);
const series = [...new Set(rows.map(row => row[c.series]))];
const groups = [...new Set(rows.map(row => row[c.group]))];
flexibleLayout = chartLayout(c, series, viz.type, groups);
LAYOUT.top = flexibleLayout.top;
LAYOUT.bottom = flexibleLayout.bottom;
LAYOUT.left = flexibleLayout.left;
}
const out = `assets/viz/${c.id}.svg`;
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, `${render(c)}\n`);
const content = render(c);
// The wrapper consumes the generated height, so layout is calculated once.
writeFileSync(out, flexibleLayout ? `<g data-viz-height="${flexibleLayout.height}">${content}</g>\n` : `${content}\n`);
console.log(`wrote ${out}`);
}
@@ -0,0 +1,101 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { chartLayout } from '../../scripts/viz/chart-layout.mjs';
import { buildSite } from './helpers.mjs';
const type = { title: 21, subtitle: 18.2, axis: 18.2, label: 16.8 };
const base = { id: 'example', width: 800, height: 400, title: 'Example', subtitle: 'Measured time', legendLayout: 'auto' };
const series = ['Method A', 'Method B', 'Method with a longer label'];
test('automatic legends keep whole labels in one row when they fit and stack otherwise', () => {
const wide = chartLayout(base, series, type);
assert.equal(new Set(wide.legend.map(item => item.y)).size, 1);
const narrow = chartLayout({ ...base, width: 320 }, series, type);
assert.equal(new Set(narrow.legend.map(item => item.y)).size, 3);
assert.ok(narrow.legend[2].lines.length > 1);
assert.ok(narrow.height > wide.height);
for (const layout of [wide, narrow]) {
for (const item of layout.legend) {
assert.ok(item.x >= 0);
assert.ok(item.y + (item.lines.length - 1) * type.label * 1.5 < layout.height);
}
}
});
test('heading space accommodates the longest view without changing plot geometry between views', () => {
const layout = chartLayout({ ...base, views: [{ subtitle: 'Short' }, { subtitle: 'Longer heading '.repeat(14) }] }, series, type);
assert.ok(layout.headings[1].lines.length > layout.headings[0].lines.length);
assert.ok(layout.top > layout.headings[1].bottom);
assert.throws(() => chartLayout({ ...base, height: 100 }, series, type), /less than 140/);
assert.throws(() => chartLayout({ ...base, width: 320, legendLayout: 'row' }, series, type), /row does not fit/);
assert.throws(() => chartLayout({ ...base, legendRoom: 1 }, series, type), /legendRoom/);
assert.throws(() => chartLayout({ ...base, yLabel: 'A very long measurement label '.repeat(8) }, series, type), /y-axis label does not fit/);
assert.throws(() => chartLayout(base, series, type, ['A category name '.repeat(10), 'Other']), /group labels do not fit/);
});
test('Hugo uses generated height and retains view captions and agent-readable data', () => {
const root = buildSite();
const html = readFileSync(`${root}/blog/viz-typography-fixture/index.html`, 'utf8');
const doc = new JSDOM(html).window.document;
const svg = doc.querySelector('[aria-labelledby="viz-cap-fixtures-flexible"]');
const height = svg.querySelector('[data-viz-height]').getAttribute('data-viz-height');
assert.equal(svg.getAttribute('viewBox'), `0 0 800 ${height}`);
for (const view of svg.querySelectorAll('[data-viz-view]')) {
const positions = [...view.querySelectorAll('[data-viz-legend]')].map(g => g.getAttribute('transform').split(',')[1]);
assert.equal(positions.length, 3);
assert.equal(new Set(positions).size, 1);
assert.equal(view.querySelectorAll('[data-viz-zone]').length, 2);
}
const md = readFileSync(`${root}/blog/viz-typography-fixture/index.md`, 'utf8');
assert.match(md, /Method with a longer label/);
assert.doesNotMatch(md, /data-viz-height|<svg/);
});
test('tooltip inherits a light blog host and clears that override on theme-aware pages', () => {
const figure = '<figure data-viz><svg><rect data-viz-zone data-viz-key="a" data-viz-title="Example" data-viz-rows="[]" tabindex="0" /></svg></figure>';
const dom = new JSDOM(`<section class="qdrant-blog-post">${figure}</section><main>${figure}</main>`, { runScripts: 'outside-only' });
dom.window.eval(readFileSync('themes/qdrant-2024/assets/js/viz.js', 'utf8'));
const zones = dom.window.document.querySelectorAll('[data-viz-zone]');
const tip = dom.window.document.querySelector('.viz-tip');
zones[0].dispatchEvent(new dom.window.Event('focus'));
assert.equal(tip.getAttribute('data-viz-theme'), 'light');
zones[1].dispatchEvent(new dom.window.Event('focus'));
assert.equal(tip.hasAttribute('data-viz-theme'), false);
dom.window.close();
});
// Golden hashes protect default charts and the legacy typography preset.
// Changing one requires an explicit compatibility decision, not regeneration.
import { createHash } from 'node:crypto';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { execFileSync } from 'node:child_process';
test('generation preserves default chart kinds and the legacy readable preset', () => {
const expected = {
"fixtures/readable": "4b16e40ff6a1d4003a4f41dfaacb528029b7ec02e93bacd1744c52ccd8035143",
"oversampling/recall": "da2ca3d7c1482d23582308e448ff82e75bb6b167966331a1b8460e37fbfeae68",
"defrag/rps": "2a7aef9eee6a0394487ede104c45b645ab793b8c98fb1db5076b4659c4873dfc"
};
const dir = mkdtempSync(join(tmpdir(), 'viz-compatibility-'));
try {
mkdirSync(join(dir, 'data'));
writeFileSync(join(dir, 'data/viz.json'), readFileSync('data/viz.json'));
for (const id of Object.keys(expected)) {
for (const ext of ['json', 'csv']) {
const path = `assets/viz/${id}.${ext}`;
mkdirSync(dirname(join(dir, path)), { recursive: true });
writeFileSync(join(dir, path), readFileSync(path));
}
}
execFileSync(process.execPath, [resolve('scripts/viz/generate-charts.mjs')], { cwd: dir, stdio: 'pipe' });
for (const [id, hash] of Object.entries(expected)) {
assert.equal(createHash('sha256').update(readFileSync(join(dir, `assets/viz/${id}.svg`))).digest('hex'), hash, id);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
@@ -50,6 +50,13 @@ test('invalid opt-in geometry fails generation rather than shipping clipped layo
[{ height: 200 }, /needs height/],
[{ kind: 'lines-facet' }, /supported only for grouped-columns/],
[{ views: [{ ...source.views[0], width: 400 }] }, /must be top-level/],
[{ textScale: '1.4' }, /textScale must be a number/],
[{ textScale: 0 }, /textScale must be a number/],
[{ textScale: 3 }, /textScale must be a number/],
[{ legendLayout: 'horizontal' }, /legendLayout must be/],
[{ textScale: 1.4, views: [{ ...source.views[0], series: 'other' }] }, /must share/],
[{ textScale: 1.4, views: [{ ...source.views[0], data: 'other.csv' }] }, /must share/],
[{ views: [{ ...source.views[0], textScale: 1.4 }] }, /must be top-level/],
];
for (const [patch, message] of cases) {
writeFileSync(join(dir, 'assets/viz/example.json'), JSON.stringify({ ...source, ...patch }));
@@ -32,6 +32,22 @@ html[data-theme='dark'] .viz-tip {
@include qi-dark-tokens;
}
// Blog posts have a light-only surface, independent of OS preference.
// Keep this policy local; documentation and articles retain their theme.
html .qdrant-blog-post .viz-figure,
html .viz-tip[data-viz-theme='light'] {
@include qi-light-tokens;
}
html .viz-tip[data-viz-theme='light'] {
background: var(--qi-surface);
color: var(--qi-fg);
border-color: var(--qi-border);
.viz-tip__title { border-color: var(--qi-border); }
.viz-tip__k { color: var(--qi-muted); }
}
.viz-figure {
margin: 2rem 0;
padding: 1.25rem 1.25rem 1rem;
@@ -101,8 +117,8 @@ html[data-theme='dark'] .viz-tip {
pointer-events: none;
}
// The tooltip stays dark in both themes: it floats above the page rather than
// sitting in it, so it reads as an overlay rather than a second surface.
// Preserve the original dark overlay on theme-aware pages. Light-only blog
// hosts opt into their surface colors through data-viz-theme above.
.viz-tip {
position: fixed;
z-index: 1000;
@@ -36,7 +36,9 @@
tip.style.top = Math.max(8, y) + 'px';
}
function show(e, title, rows) {
function show(e, title, rows, fig) {
if (fig.closest('.qdrant-blog-post')) tip.setAttribute('data-viz-theme', 'light');
else tip.removeAttribute('data-viz-theme');
var html = '<div class="viz-tip__title">' + title + '</div>';
for (var i = 0; i < rows.length; i++) {
html += '<div class="viz-tip__row">'
@@ -115,7 +117,7 @@
var rows;
try { rows = JSON.parse(z.getAttribute('data-viz-rows') || '[]'); } catch (err) { rows = []; }
function enter(e) { setActive(key); setCrosshair(z); show(e, title, rows); }
function enter(e) { setActive(key); setCrosshair(z); show(e, title, rows, fig); }
z.addEventListener('pointerenter', enter);
z.addEventListener('pointermove', move);
z.addEventListener('pointerleave', function () { setActive(null); setCrosshair(null); hide(); });
@@ -124,7 +126,7 @@
setActive(key);
setCrosshair(z);
var r = z.getBoundingClientRect();
show({ clientX: r.left + r.width / 2, clientY: r.top }, title, rows);
show({ clientX: r.left + r.width / 2, clientY: r.top }, title, rows, fig);
});
z.addEventListener('blur', function () { setActive(null); setCrosshair(null); hide(); });
}