diff --git a/qdrant-landing/assets/viz/fixtures/toggle.csv b/qdrant-landing/assets/viz/fixtures/toggle.csv
new file mode 100644
index 000000000..526b2e077
--- /dev/null
+++ b/qdrant-landing/assets/viz/fixtures/toggle.csv
@@ -0,0 +1,4 @@
+engine,config,recall_at_10,throughput_qps
+Qdrant,2 vCPU,0.951,67.2
+Qdrant,4 vCPU,0.977,111.9
+Qdrant,8 vCPU,0.988,168.4
diff --git a/qdrant-landing/assets/viz/fixtures/toggle.json b/qdrant-landing/assets/viz/fixtures/toggle.json
new file mode 100644
index 000000000..2e1c203dc
--- /dev/null
+++ b/qdrant-landing/assets/viz/fixtures/toggle.json
@@ -0,0 +1,26 @@
+{
+ "title": "Fixture chart with a view toggle",
+ "kind": "columns-2panel",
+ "height": 400,
+ "labelTop": "engine",
+ "labelBottom": "config",
+ "views": [
+ {
+ "label": "Recall",
+ "yMax": 1.1,
+ "panels": [
+ { "title": "Recall@10", "y": "recall_at_10", "axis": "recall@10",
+ "subtitle": "higher is better" }
+ ]
+ },
+ {
+ "label": "Throughput",
+ "yMax": 200,
+ "panels": [
+ { "title": "Throughput", "y": "throughput_qps", "axis": "QPS",
+ "unit": "QPS", "subtitle": "higher is better" }
+ ]
+ }
+ ],
+ "tooltip": { "recall_at_10": "recall@10", "throughput_qps": "throughput" }
+}
diff --git a/qdrant-landing/assets/viz/fixtures/toggle.svg b/qdrant-landing/assets/viz/fixtures/toggle.svg
new file mode 100644
index 000000000..e984b6d9e
--- /dev/null
+++ b/qdrant-landing/assets/viz/fixtures/toggle.svg
@@ -0,0 +1,21 @@
+Recall@10higher is betterrecall@100.00.20.40.60.81.00.9510.9770.988QdrantQdrantQdrant2 vCPU4 vCPU8 vCPUThroughputhigher is betterQPS02040608010012014016018020067.2 QPS111.9 QPS168.4 QPSQdrantQdrantQdrant2 vCPU4 vCPU8 vCPU
diff --git a/qdrant-landing/assets/viz/oversampling/recall.json b/qdrant-landing/assets/viz/oversampling/recall.json
index 3363ae53f..becf30e5f 100644
--- a/qdrant-landing/assets/viz/oversampling/recall.json
+++ b/qdrant-landing/assets/viz/oversampling/recall.json
@@ -1,5 +1,5 @@
{
- "title": "Recall@10 by quantization and rescoring",
+ "title": "Quantization quality: recall and nDCG",
"kind": "columns-2panel",
"labelTop": "variant",
"labelBottom": "setting",
@@ -13,14 +13,32 @@
0
],
"height": 400,
- "yMax": 1.0,
- "panels": [
+ "views": [
{
- "title": "Recall@10 against exact search",
- "subtitle": "higher is better",
- "y": "recall_at_10",
- "axis": "recall@10",
- "unit": ""
+ "label": "Recall@10",
+ "yMax": 1.0,
+ "panels": [
+ {
+ "title": "Recall@10 against exact search",
+ "subtitle": "higher is better",
+ "y": "recall_at_10",
+ "axis": "recall@10",
+ "unit": ""
+ }
+ ]
+ },
+ {
+ "label": "nDCG@10",
+ "yMax": 0.36,
+ "panels": [
+ {
+ "title": "nDCG@10 on 200 held-out queries",
+ "subtitle": "higher is better",
+ "y": "ndcg_at_10",
+ "axis": "nDCG@10",
+ "unit": ""
+ }
+ ]
}
],
"tooltip": {
diff --git a/qdrant-landing/assets/viz/oversampling/recall.svg b/qdrant-landing/assets/viz/oversampling/recall.svg
index cbeda7ed6..8bf40e06c 100644
--- a/qdrant-landing/assets/viz/oversampling/recall.svg
+++ b/qdrant-landing/assets/viz/oversampling/recall.svg
@@ -1,4 +1,4 @@
-Recall@10 against exact searchhigher is betterrecall@100.00.10.20.30.40.50.60.70.80.91.00.9570.9180.9930.6050.9510.9770.988float32TQ 4-bitTQ 4-bitTQ 1-bitTQ 1-bitTQ 1-bitTQ 1-bitno quantrescore offoversample 4rescore offoversample 1oversample 2oversample 4
+}0.00.10.20.30.40.50.60.70.80.91.00.9570.9180.9930.6050.9510.9770.988float32TQ 4-bitTQ 4-bitTQ 1-bitTQ 1-bitTQ 1-bitTQ 1-bitno quantrescore offoversample 4rescore offoversample 1oversample 2oversample 4nDCG@10 on 200 held-out querieshigher is betternDCG@100.000.050.100.150.200.250.300.350.31030.32180.32380.27860.31140.31280.3178float32TQ 4-bitTQ 4-bitTQ 1-bitTQ 1-bitTQ 1-bitTQ 1-bitno quantrescore offoversample 4rescore offoversample 1oversample 2oversample 4
diff --git a/qdrant-landing/content/articles/when-your-collection-outgrows-ram.md b/qdrant-landing/content/articles/when-your-collection-outgrows-ram.md
index 7afd1811a..439cc7956 100644
--- a/qdrant-landing/content/articles/when-your-collection-outgrows-ram.md
+++ b/qdrant-landing/content/articles/when-your-collection-outgrows-ram.md
@@ -108,7 +108,7 @@ The table reports how each configuration then scored on 200 held-out queries.
Quality scope: these rows run at Qdrant's default memory configuration and report no latency, because sequential query passes warmed the page cache. The latency table above reports the placements instead.
-{{< chart id="oversampling/recall" caption="Without rescoring, 1-bit quantization recalls only 0.605 of what exact search finds. Oversampling brings it back to 0.988 — nearly the float32 baseline — at 1/32 the vector size." >}}
+{{< chart id="oversampling/recall" caption="Without rescoring, 1-bit quantization recalls only 0.605 of what exact search finds. Oversampling brings it back to 0.988 — nearly the float32 baseline — at 1/32 the vector size." caption2="nDCG@10 moves far less than recall does: every quantized setting lands between 0.2786 and 0.3238 against the float32 baseline of 0.3103. Recall is where the bit depth shows." >}}
| Quantization | `rescore` | `nDCG@10` | `Recall@10` Against Exact |
|---|---|---|---|
diff --git a/qdrant-landing/content/blog/viz-fixtures.md b/qdrant-landing/content/blog/viz-fixtures.md
index 44864e5bb..6d52722fc 100644
--- a/qdrant-landing/content/blog/viz-fixtures.md
+++ b/qdrant-landing/content/blog/viz-fixtures.md
@@ -13,3 +13,5 @@ Fixture page for `node --test test/viz/`. Not linked, not published.
{{< partial-smoke >}}
{{< chart id="hybrid/fusion" caption="Fixture chart caption." >}}
+
+{{< chart id="fixtures/toggle" caption="Fixture chart, recall view." caption2="Fixture chart, throughput view." >}}
diff --git a/qdrant-landing/layouts/partials/viz-chart-spec.html b/qdrant-landing/layouts/partials/viz-chart-spec.html
new file mode 100644
index 000000000..5579a293f
--- /dev/null
+++ b/qdrant-landing/layouts/partials/viz-chart-spec.html
@@ -0,0 +1,30 @@
+{{- /*
+ viz-chart-spec — resolve a chart shortcode's arguments once.
+
+ A chart renders twice, through layouts/shortcodes/chart.html and
+ chart.markdown.md, and both need the same things: the id, the caption, the
+ parsed spec, and one caption per view. Deriving that separately in each let
+ the rules drift — the HTML output already refused a view missing its
+ caption2= while the Markdown output quietly printed an empty claim.
+
+ Takes the shortcode context. Returns a dict: id, caption, spec, views
+ (labels), captions (one per view, validated).
+*/ -}}
+{{- $sc := . -}}
+{{- $id := $sc.Get "id" -}}
+{{- if not $id }}{{ errorf "chart in %s: 'id' is required." $sc.Position }}{{ end -}}
+{{- $caption := $sc.Get "caption" -}}
+{{- if not $caption }}{{ errorf "chart in %s: 'caption' is required." $sc.Position }}{{ end -}}
+{{- $specRes := resources.Get (printf "viz/%s.json" $id) -}}
+{{- if not $specRes }}{{ errorf "chart in %s: assets/viz/%s.json missing." $sc.Position $id }}{{ end -}}
+{{- $spec := $specRes | transform.Unmarshal -}}
+{{- /* View 0 uses caption=, later views caption2=, caption3=. */ -}}
+{{- $views := slice -}}
+{{- $captions := slice -}}
+{{- range $i, $v := $spec.views -}}
+ {{- $views = $views | append $v.label -}}
+ {{- $c := cond (eq $i 0) $caption ($sc.Get (printf "caption%d" (add $i 1))) -}}
+ {{- if not $c }}{{ errorf "chart in %s: view %d (%q) needs caption%d=, or it inherits a claim about another view's numbers." $sc.Position $i $v.label (add $i 1) }}{{ end -}}
+ {{- $captions = $captions | append $c -}}
+{{- end -}}
+{{- return dict "id" $id "caption" $caption "spec" $spec "views" $views "captions" $captions -}}
diff --git a/qdrant-landing/layouts/partials/viz-figure.html b/qdrant-landing/layouts/partials/viz-figure.html
index f90a16857..63487db80 100644
--- a/qdrant-landing/layouts/partials/viz-figure.html
+++ b/qdrant-landing/layouts/partials/viz-figure.html
@@ -16,6 +16,8 @@
caption required, non-empty
id page-unique slug used to build the title element id
class optional extra class on the
+ views optional slice of view labels; renders the toggle above the chart
+ captions optional slice, one caption per view; the figcaption follows the view
*/ -}}
{{- $caption := .caption -}}
{{- $id := .id -}}
@@ -32,6 +34,14 @@
points at the visible , so the text exists exactly once.
*/ -}}
+ {{- with .views }}
+
+ {{- range $i, $label := . }}
+
+ {{- end }}
+
+ {{- end }}
- {{ $caption }}
+ {{ $caption }}
diff --git a/qdrant-landing/layouts/shortcodes/chart.html b/qdrant-landing/layouts/shortcodes/chart.html
index 4a4bd1bd4..b9085e105 100644
--- a/qdrant-landing/layouts/shortcodes/chart.html
+++ b/qdrant-landing/layouts/shortcodes/chart.html
@@ -15,22 +15,16 @@
Usage: a "chart" shortcode taking id= and caption=, where id is the path to
a chart's three files: assets/viz/.{json,csv,svg}.
*/ -}}
-{{- $id := .Get "id" -}}
-{{- if not $id }}{{ errorf "chart in %s: 'id' is required." .Position }}{{ end -}}
-{{- $caption := .Get "caption" -}}
-{{- if not $caption }}{{ errorf "chart in %s: 'caption' is required." .Position }}{{ end -}}
-{{- $specRes := resources.Get (printf "viz/%s.json" $id) -}}
-{{- if not $specRes }}{{ errorf "chart in %s: assets/viz/%s.json missing." .Position $id }}{{ end -}}
-{{- $spec := $specRes | transform.Unmarshal -}}
-{{- $res := resources.Get (printf "viz/%s.svg" $id) -}}
-{{- if not $res }}{{ errorf "chart in %s: assets/viz/%s.svg missing. Run: node scripts/viz/generate-charts.mjs" .Position $id }}{{ end -}}
-{{- /* Space the generator draws into below the plot; the viewBox must include
- it or the legend is clipped. Sizes live in data/viz.json. */ -}}
-{{- $chart := site.Data.viz.chart -}}
+{{- $c := partial "viz-chart-spec.html" . -}}
+{{- $spec := $c.spec -}}
+{{- $res := resources.Get (printf "viz/%s.svg" $c.id) -}}
+{{- if not $res }}{{ errorf "chart in %s: assets/viz/%s.svg missing. Run: node scripts/viz/generate-charts.mjs" .Position $c.id }}{{ end -}}
+{{- /* Room below the plot; the viewBox must include it or the legend clips. */ -}}
+{{- $cfg := site.Data.viz.chart -}}
{{- $room := 0 -}}
{{- if eq $spec.kind "grouped-columns" -}}
- {{- $room = $chart.legendRoom -}}
+ {{- $room = $cfg.legendRoom -}}
{{- else if eq $spec.kind "lines-facet" -}}
- {{- $room = cond (gt (len $spec.series) 1) $chart.legendRoom $chart.facetRoom -}}
+ {{- $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 $chart.width) (add (int $spec.height) (int $room))) "caption" $caption "id" (replace $id "/" "-") "class" "viz-figure--chart") -}}
+{{- 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) -}}
diff --git a/qdrant-landing/layouts/shortcodes/chart.markdown.md b/qdrant-landing/layouts/shortcodes/chart.markdown.md
index 7928b1f4a..4b309e123 100644
--- a/qdrant-landing/layouts/shortcodes/chart.markdown.md
+++ b/qdrant-landing/layouts/shortcodes/chart.markdown.md
@@ -18,15 +18,10 @@
lossy summary someone has to keep in sync with the numbers; the numbers keep
themselves in sync.
*/ -}}
-{{- $id := .Get "id" -}}
-{{- if not $id }}{{ errorf "chart in %s: 'id' is required." .Position }}{{ end -}}
-{{- $caption := .Get "caption" -}}
-{{- if not $caption }}{{ errorf "chart in %s: 'caption' is required." .Position }}{{ end -}}
-{{- $specRes := resources.Get (printf "viz/%s.json" $id) -}}
-{{- if not $specRes }}{{ errorf "chart in %s: assets/viz/%s.json missing." .Position $id }}{{ end -}}
-{{- $spec := $specRes | transform.Unmarshal -}}
-{{- $csv := resources.Get (printf "viz/%s.csv" $id) -}}
-{{- if not $csv }}{{ errorf "chart in %s: assets/viz/%s.csv missing; the Markdown output renders the chart's source table." .Position $id }}{{ end -}}
+{{- $c := partial "viz-chart-spec.html" . -}}
+{{- $spec := $c.spec -}}
+{{- $csv := resources.Get (printf "viz/%s.csv" $c.id) -}}
+{{- if not $csv }}{{ errorf "chart in %s: assets/viz/%s.csv missing; the Markdown output renders the chart's source table." .Position $c.id }}{{ end -}}
{{- $rows := $csv | transform.Unmarshal -}}
{{- $head := index $rows 0 -}}
{{- with $spec.title }}**{{ . }}**{{ end }}
@@ -36,4 +31,12 @@
|{{ range $head }} --- |{{ end }}
{{ range after 1 $rows }}| {{ delimit . " | " }} |
{{ end }}
-_{{ $caption }}_
+{{- /* The table is every view's columns at once, so it needs every view's claim,
+ not just the one the chart happens to open on. */ -}}
+{{- if $c.views -}}
+{{- range $i, $label := $c.views }}
+_{{ $label }}: {{ index $c.captions $i }}_
+{{ end -}}
+{{- else }}
+_{{ $c.caption }}_
+{{- end -}}
diff --git a/qdrant-landing/scripts/viz/check-tables.mjs b/qdrant-landing/scripts/viz/check-tables.mjs
new file mode 100644
index 000000000..55301b1f0
--- /dev/null
+++ b/qdrant-landing/scripts/viz/check-tables.mjs
@@ -0,0 +1,171 @@
+#!/usr/bin/env node
+/*
+ * Check every chart's CSV against the table its post shows the reader.
+ *
+ * The same numbers live in two places: the markdown table an author types, and
+ * the CSV the chart is drawn from. Nothing else compares them, so a re-run that
+ * corrects one and not the other publishes a chart disagreeing with the table
+ * directly above it.
+ *
+ * The CSV is not a copy of the table. It melts wide tables to long, renames
+ * labels, and drops columns the chart does not plot. So this cannot diff them.
+ * It asks a weaker question that survives all of that:
+ *
+ * does every number the chart plots still appear in the table?
+ * does every number in the table still appear in the chart's data?
+ *
+ * Numbers are compared as parsed floats, so 1.000 and 1.0 match, and units
+ * attached in the table ("32.4 QPS") do not matter.
+ *
+ * node scripts/viz/check-tables.mjs # all charts
+ * node scripts/viz/check-tables.mjs --json # machine readable
+ */
+import { readFileSync } from 'node:fs';
+
+// Which table in which post each chart was built from. `null` means the chart
+// has no source table and never should: see the note on rrf-k/weight below.
+const SOURCES = {
+ 'diskbbq/results': { post: 'content/blog/benchmark-elastic-diskbbq.md', header: 'Configuration' },
+ 'defrag/rps': { post: 'content/articles/immutable-data-structures.md', header: '% of hot subset' },
+ 'oversampling/recall': { post: 'content/articles/when-your-collection-outgrows-ram.md', header: 'Quantization' },
+ 'bits1-rescore/recovery': { post: 'content/articles/when-your-collection-outgrows-ram.md', header: 'Quantization' },
+ 'candidate-depth/sweep': { post: 'content/articles/candidate-depth.md', header: 'Milliseconds per Query' },
+ 'hybrid/fusion': { post: 'content/articles/how-to-tune-hybrid-search.md', header: 'Dense Alone' },
+ // Computed, not measured, so there is no table to check it against: the k
+ // table in that post is nDCG, a different quantity. Checked against the
+ // formula instead, below.
+ 'rrf-k/weight': null,
+};
+
+const cells = (row) =>
+ row.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim().replace(/`/g, ''));
+
+function tableFrom(post, headerMatch) {
+ const tables = [];
+ let cur = [];
+ for (const line of readFileSync(post, 'utf8').split('\n')) {
+ if (line.trim().startsWith('|')) cur.push(line);
+ else if (cur.length) { tables.push(cur); cur = []; }
+ }
+ if (cur.length) tables.push(cur);
+ const t = tables.find((x) => x[0].includes(headerMatch));
+ if (!t) throw new Error(`no table with ${JSON.stringify(headerMatch)} in ${post}`);
+ const header = cells(t[0]);
+ return { header, rows: t.slice(2).map((r) => cells(r)) };
+}
+
+/*
+ * A measurement, or null. The whole cell must be one number, optionally with a
+ * unit: "32.4 QPS", "+0.0289", "2.5%". Anything else is a label, even though it
+ * starts with a digit: "7 vCPU / 26 GB" and "3 x 7 vCPU / 26 GB RF=2" are
+ * configuration, and reading 7 or 3 out of them as data invents disagreements.
+ */
+const UNIT = /^[+-]?\d+(?:\.\d+)?\s*(?:%|ms|s|QPS|GB|GiB|MB|KB|k|M)?$/i;
+const num = (s) => {
+ const t = String(s).replace(/,/g, '').trim();
+ if (!UNIT.test(t)) return null;
+ return parseFloat(t);
+};
+
+/** Columns where most cells are measurements are data; the rest are labels. */
+function numericColumns(header, rows) {
+ return header
+ .map((_, i) => i)
+ .filter((i) => rows.filter((r) => num(r[i]) !== null).length > rows.length / 2);
+}
+
+function csvOf(path) {
+ const [head, ...rows] = readFileSync(path, 'utf8').trim().split('\n');
+ const cols = head.split(',');
+ return { cols, rows: rows.map((r) => r.split(',')) };
+}
+
+const near = (a, b) => Math.abs(a - b) < 1e-9;
+const has = (set, v) => [...set].some((x) => near(x, v));
+
+/*
+ * rrf-k/weight has no source table: `share` is the share of fused score one
+ * result contributes at a given rank. Qdrant's rank is 0-indexed, so the weight
+ * is 1/(k + rank - 1), not 1/(k + rank). The post states this itself: "The
+ * original RRF paper uses 60, which maps to k=61 in Qdrant's formula", and 60
+ * only maps to 61 under the 0-indexed form. Checked to 0.06pp, since the CSV
+ * carries one decimal.
+ */
+function checkRrfK(csv) {
+ const [ri, ki, si] = ['rank', 'k', 'share'].map((c) => csv.cols.indexOf(c));
+ const series = new Map();
+ for (const r of csv.rows) {
+ if (!series.has(r[ki])) series.set(r[ki], []);
+ series.get(r[ki]).push([parseInt(r[ri], 10), parseFloat(r[si])]);
+ }
+ const off = [];
+ for (const [label, pts] of series) {
+ const k = parseFloat(label.split('=')[1]);
+ pts.sort((a, b) => a[0] - b[0]);
+ const w = pts.map(([rank]) => 1 / (k + rank - 1));
+ const tot = w.reduce((a, b) => a + b, 0);
+ pts.forEach(([rank, got], i) => {
+ const exp = (100 * w[i]) / tot;
+ if (Math.abs(got - exp) > 0.06)
+ off.push(`${label} rank ${rank}: csv ${got}, formula ${exp.toFixed(2)}`);
+ });
+ }
+ return { formula: '1/(k + rank - 1), normalised', series: series.size, offBy: off };
+}
+
+const results = [];
+for (const [id, src] of Object.entries(SOURCES)) {
+ const csv = csvOf(`assets/viz/${id}.csv`);
+ const csvNums = new Set();
+ for (const i of numericColumns(csv.cols, csv.rows))
+ for (const r of csv.rows) { const v = num(r[i]); if (v !== null) csvNums.add(v); }
+
+ if (!src) {
+ results.push({ id, ...checkRrfK(csv), csvRows: csv.rows.length });
+ continue;
+ }
+
+ const tbl = tableFrom(src.post, src.header);
+ const tblNums = new Set();
+ for (const i of numericColumns(tbl.header, tbl.rows))
+ for (const r of tbl.rows) { const v = num(r[i]); if (v !== null) tblNums.add(v); }
+
+ results.push({
+ id,
+ post: src.post.split('/').pop(),
+ csvRows: csv.rows.length,
+ tableRows: tbl.rows.length,
+ notInTable: [...csvNums].filter((v) => !has(tblNums, v)),
+ notInCsv: [...tblNums].filter((v) => !has(csvNums, v)),
+ });
+}
+
+if (process.argv.includes('--json')) {
+ console.log(JSON.stringify(results, null, 2));
+} else {
+ let bad = 0;
+ for (const r of results) {
+ if (r.formula) {
+ const ok = r.offBy.length === 0;
+ if (!ok) bad++;
+ console.log(`\n${ok ? 'ok ' : 'XX '} ${r.id} (no table: checked against the formula)`);
+ console.log(` ${r.csvRows} csv rows, ${r.series} series, share = ${r.formula}`);
+ r.offBy.forEach((l) => console.log(` FAIL ${l}`));
+ continue;
+ }
+ // Only one direction is a failure. A chart plotting a SUBSET of its table is
+ // normal and deliberate, so unplotted table values are reported, not failed.
+ const ok = r.notInTable.length === 0;
+ if (!ok) bad++;
+ console.log(`\n${ok ? 'ok ' : 'XX '} ${r.id} (${r.post})`);
+ console.log(` ${r.csvRows} csv rows, ${r.tableRows} table rows`);
+ if (r.notInTable.length)
+ console.log(` FAIL plotted but not in the table: ${r.notInTable.join(', ')}`);
+ if (r.notInCsv.length)
+ console.log(` note in the table, not plotted: ${r.notInCsv.join(', ')}`);
+ }
+ console.log(`\n${bad === 0
+ ? 'every plotted number is present in its source table'
+ : `${bad} chart(s) plot a number their table does not contain`}`);
+ process.exitCode = bad === 0 ? 0 : 1;
+}
diff --git a/qdrant-landing/scripts/viz/generate-charts.mjs b/qdrant-landing/scripts/viz/generate-charts.mjs
index 2392e6f80..bf1735e02 100644
--- a/qdrant-landing/scripts/viz/generate-charts.mjs
+++ b/qdrant-landing/scripts/viz/generate-charts.mjs
@@ -20,8 +20,13 @@ const viz = JSON.parse(readFileSync('data/viz.json', 'utf8'));
// 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$/, '');
- return { id, data: `assets/viz/${id}.csv`, width: viz.chart.width,
- ...JSON.parse(readFileSync(p, 'utf8')) };
+ 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".`);
+ }
+ return { id, data: `assets/viz/${id}.csv`, width: viz.chart.width, ...spec };
});
const dom = new JSDOM('');
@@ -341,30 +346,33 @@ function groupedColumns(c) {
+ wrapPlot(plotted) + zones + legendRow;
}
-for (const c of manifest) {
- if (c.kind === 'grouped-columns') {
- const out = `assets/viz/${c.id}.svg`;
- mkdirSync(dirname(out), { recursive: true });
- writeFileSync(out, `${groupedColumns(c)}\n`);
- console.log(`wrote ${out}`);
- continue;
- }
- if (c.kind === 'lines-facet') {
- const out = `assets/viz/${c.id}.svg`;
- mkdirSync(dirname(out), { recursive: true });
- writeFileSync(out, `${linesFacet(c)}\n`);
- console.log(`wrote ${out}`);
- continue;
- }
+function draw(c) {
+ if (c.kind === 'grouped-columns') return groupedColumns(c);
+ if (c.kind === 'lines-facet') return linesFacet(c);
if (c.kind !== 'columns-2panel') throw new Error(`unsupported kind ${c.kind}`);
const data = readCsv(c.data);
const gap = LAYOUT.gap;
const pw = (c.width - gap * (c.panels.length - 1)) / c.panels.length;
// No frame: gridlines carry the structure, so nothing can touch a border.
- const body = c.panels.map((p, i) =>
+ return c.panels.map((p, i) =>
`${panel(c, p, data, pw, c.height)}`).join('');
+}
+
+// Every view ships in one SVG, so switching never fetches and the first view
+// still renders without JavaScript. A view is a patch over the base spec.
+function render(c) {
+ if (!c.views) return draw(c);
+ return c.views.map((v, i) => {
+ const merged = { ...c, ...v, views: undefined };
+ // `hidden` is ignored on SVG elements; inline so it holds with no CSS.
+ return ``
+ + `${draw(merged)}`;
+ }).join('');
+}
+
+for (const c of manifest) {
const out = `assets/viz/${c.id}.svg`;
mkdirSync(dirname(out), { recursive: true });
- writeFileSync(out, `${body}\n`);
+ writeFileSync(out, `${render(c)}\n`);
console.log(`wrote ${out}`);
}
diff --git a/qdrant-landing/test/viz/shortcodes.test.mjs b/qdrant-landing/test/viz/shortcodes.test.mjs
index 5ff41604a..bad9e3eb3 100644
--- a/qdrant-landing/test/viz/shortcodes.test.mjs
+++ b/qdrant-landing/test/viz/shortcodes.test.mjs
@@ -89,3 +89,34 @@ test('HTML output still renders the chart as SVG', () => {
assert.match(html, /