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, / { + const html = getFixtureHtml(); + + // One button per view, the first one pressed. + assert.match(html, /
]*role="group"/, 'toggle container missing'); + const btns = html.match(/data-viz-toggle-btn="\d"/g) || []; + assert.equal(btns.length, 2, 'expected one button per view'); + assert.match(html, /data-viz-toggle-btn="0"\s+aria-pressed="true"/, 'first view must start pressed'); + assert.match(html, /data-viz-toggle-btn="1"\s+aria-pressed="false"/, 'later views must start unpressed'); + + // Every view is in the SVG already: toggling hides and shows, it never fetches. + const views = html.match(/data-viz-view="\d"/g) || []; + assert.equal(views.length, 2, 'expected every view inlined in the svg'); + + // Hidden inline rather than by class, so extra views stay hidden with no CSS, + // and via display because browsers ignore `hidden` on SVG elements. + assert.match(html, /data-viz-view="1" style="display:none"/, + 'views after the first must be inline-hidden'); + assert.doesNotMatch(html, /data-viz-view="0" style="display:none"/, + 'the first view must render without JavaScript'); +}); + +test('the view toggle is a drawing affordance, not data', () => { + // The Markdown output carries the CSV, which already holds every view's + // columns, so a toggle there would be buttons with nothing to toggle. + const md = getFixtureMarkdown(); + assert.doesNotMatch(md, /viz-toggle|data-viz-view/, 'no toggle markup in Markdown output'); + assert.match(md, /\| engine \| config \| recall_at_10 \| throughput_qps \|/, + 'Markdown must carry every view column'); +}); diff --git a/qdrant-landing/themes/qdrant-2024/assets/css/_qi-tokens.scss b/qdrant-landing/themes/qdrant-2024/assets/css/_qi-tokens.scss index 3f40f01e8..cf706fa52 100644 --- a/qdrant-landing/themes/qdrant-2024/assets/css/_qi-tokens.scss +++ b/qdrant-landing/themes/qdrant-2024/assets/css/_qi-tokens.scss @@ -19,6 +19,8 @@ --qi-hot: #303547; // neutral-800: highlight outline --qi-cell-stroke: rgba(17, 24, 36, 0.3); // neutral-900 @ 30% --qi-accent: #dc244c; // amaranth-500: thresholds, emphasis + // On --qi-accent: neither surface reaches 4.5:1 on it, white does. + --qi-accent-ink: #ffffff; // Categorical palette (brand secondaries) available to graphics that need // hues without a status meaning. Consumers may also define their own. diff --git a/qdrant-landing/themes/qdrant-2024/assets/css/viz.scss b/qdrant-landing/themes/qdrant-2024/assets/css/viz.scss index 72ea28814..0e7be5084 100644 --- a/qdrant-landing/themes/qdrant-2024/assets/css/viz.scss +++ b/qdrant-landing/themes/qdrant-2024/assets/css/viz.scss @@ -171,3 +171,45 @@ html[data-theme='dark'] .viz-tip { font-size: 0.8125rem; } } + +// Segmented control: segments butt together, the active one lifted so its +// border is not clipped. +.viz-toggle { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + margin: 8px 0 12px; + font-family: var(--qi-mono); +} + +.viz-toggle__btn { + appearance: none; + position: relative; + padding: 5px 11px; + border: 1px solid var(--qi-border); + background: transparent; + color: var(--qi-muted); + font-family: inherit; + font-size: 11px; + line-height: 1; + font-weight: 400; + cursor: pointer; + + & + & { margin-left: -1px; } + + &:hover { color: var(--qi-fg); } + + &:focus-visible { + outline: 2px solid var(--qi-accent); + outline-offset: 1px; + z-index: 2; + } + + &[aria-pressed='true'] { + z-index: 1; + border-color: var(--qi-accent); + background: var(--qi-accent); + color: var(--qi-accent-ink); + font-weight: 600; + } +} diff --git a/qdrant-landing/themes/qdrant-2024/assets/js/viz.js b/qdrant-landing/themes/qdrant-2024/assets/js/viz.js index fc55fc5c9..c29000dce 100644 --- a/qdrant-landing/themes/qdrant-2024/assets/js/viz.js +++ b/qdrant-landing/themes/qdrant-2024/assets/js/viz.js @@ -68,6 +68,7 @@ }); function wireFigure(fig) { + wireToggle(fig); var zones = fig.querySelectorAll('[data-viz-zone]'); // Line charts carry a dashed vertical rule that snaps to the hovered @@ -130,4 +131,37 @@ fig.addEventListener('pointerleave', function () { setActive(null); setCrosshair(null); hide(); }); } + + function wireToggle(fig) { + var sw = fig.querySelector('[data-viz-toggle]'); + if (!sw) return; + var btns = sw.querySelectorAll('[data-viz-toggle-btn]'); + var views = fig.querySelectorAll('[data-viz-view]'); + if (!btns.length || !views.length) return; + + var cap = fig.querySelector('[data-viz-captions]'); + var captions = null; + if (cap) { + try { captions = JSON.parse(cap.getAttribute('data-viz-captions')); } catch (e) { captions = null; } + } + + function select(idx) { + Array.prototype.forEach.call(views, function (v) { + v.style.display = v.getAttribute('data-viz-view') === String(idx) ? '' : 'none'; + }); + // The caption states a claim about the numbers on screen. + if (captions && captions[idx]) cap.textContent = captions[idx]; + Array.prototype.forEach.call(btns, function (b) { + b.setAttribute('aria-pressed', + b.getAttribute('data-viz-toggle-btn') === String(idx) ? 'true' : 'false'); + }); + hide(); + } + + Array.prototype.forEach.call(btns, function (b) { + b.addEventListener('click', function () { + select(b.getAttribute('data-viz-toggle-btn')); + }); + }); + } })();