mirror of
https://github.com/qdrant/landing_page.git
synced 2026-09-25 14:08:30 +02:00
feat(viz): Introduce chart toggles (#2782)
* feat(viz): switch a chart between views Adds `views` to a chart spec: a segmented control above the chart that switches which measure it draws. Modelled on the charts in the author's own blog, which use the same control for the same job. Every view is rendered into the same committed SVG, one <g> each, and all but the first are inline-hidden. Switching shows one and hides the rest, so nothing re-renders and nothing is fetched on click. With JavaScript off a reader still gets a complete chart rather than dead buttons. A view is a patch over the base spec, so it can change the column plotted, the axis, the title and the y-max. Applies it to oversampling/recall, whose CSV already carried ndcg_at_10 while the chart only drew recall_at_10 -- the second measure was in the table but invisible in the picture. The default view is byte-identical to the chart published today, so the page looks the same until a reader picks the other tab. The Markdown output is unchanged and carries every view's columns, since it prints the whole CSV. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(viz): the caption has to follow the view Caught this in a screenshot: the oversampling caption cites recall going 0.605 to 0.988, and it stayed put when the reader switched to the nDCG view, where every bar sits between 0.2786 and 0.3238. A caption is a claim about the numbers on screen, so leaving it fixed makes the figure lie. Each view after the first takes caption2=, caption3= and so on, alongside the existing caption= at the call site, where the rest of the prose lives. The build fails if a view has no caption rather than letting it inherit one. The figcaption is also the figure's accessible name, so it changes for screen readers too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(viz): white label on the active toggle, and fewer comments The active segment took its colour from --qi-surface, which flips to near-black in dark mode, so the label sat dark-on-red. Neither surface clears AA on the accent anyway (4.30 light, 3.73 dark); white gives 4.77. Adds --qi-accent-ink for text sitting on the accent. Also cuts the comments this branch added from 21 lines to 9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(viz): Markdown output carries every view's claim The table is the whole CSV, so for a chart with views it holds every view's columns at once. The title and caption were still view 0's, so the .md read "Recall@10 by quantization" over a table that also carried nDCG. Now emits one caption per view, labelled, and the oversampling title no longer names a single measure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Trigger CI * refactor(viz): call the view picker a toggle, not a switch `.qi-switch` in islands.scss is already a binary on/off control with a track and a knob. The chart's control picks one of N views, so it was borrowing a name that meant something else. viz-switch -> viz-toggle, data-viz-view-btn -> data-viz-toggle-btn, wireSwitch -> wireToggle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(viz): resolve a chart's arguments in one place chart.html and chart.markdown.md both derived the id, the caption, the spec and the per-view captions, from eleven identical lines. The rules had already drifted: the HTML output refused a view missing its caption2=, while the Markdown output printed an empty claim for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(viz): refuse a per-chart width rather than stretch the chart Charts deliberately share one width: the SVG scales to the column, so a wider viewBox renders identical font sizes smaller. The manifest spread a spec over that default, so a spec setting "width" drew at its own size inside the 980-wide viewBox the shortcode builds, and came out stretched. No chart sets one today; this makes the next one a build error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(viz): check each chart's CSV against the table in its post The same numbers live twice, in the CSV a chart is drawn from and in the markdown table above it. Nothing compared them, so correcting one and not the other publishes a chart that disagrees with its own table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Abdon Pijpelink <abdon.pijpelink@qdrant.com>
This commit is contained in:
co-authored by
Claude Opus 5
Abdon Pijpelink
parent
65d3794513
commit
78beef7e01
@@ -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
|
||||
|
@@ -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" }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<g data-viz-view="0"><g transform="translate(0,0)"><text x="490" y="20" text-anchor="middle" font-family="'Geist Mono', ui-monospace, monospace" font-size="15" font-weight="700" fill="var(--qi-fg)">Recall@10</text><text x="490" y="38" text-anchor="middle" font-family="'Geist Mono', ui-monospace, monospace" font-size="13" fill="var(--qi-muted)">higher is better</text><text transform="translate(13,197) rotate(-90)" text-anchor="middle" font-family="'Geist Mono', ui-monospace, monospace" font-size="12" fill="var(--qi-muted)">recall@10</text><g fill="currentColor" style="color:var(--qi-muted)" font-family="'Geist Mono', ui-monospace, monospace" font-size="13" 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="y-grid" aria-hidden="true" stroke="currentColor" stroke-opacity="0.1"><line x1="74" x2="944" y1="332" y2="332"></line><line x1="74" x2="944" y1="282.9090909090909" y2="282.9090909090909"></line><line x1="74" x2="944" y1="233.8181818181818" y2="233.8181818181818"></line><line x1="74" x2="944" y1="184.72727272727275" y2="184.72727272727275"></line><line x1="74" x2="944" y1="135.63636363636363" y2="135.63636363636363"></line><line x1="74" x2="944" y1="86.54545454545455" y2="86.54545454545455"></line></g><g aria-label="y-axis tick label" text-anchor="end" font-variant="tabular-nums" transform="translate(-9,0)"><text y="0.32em" transform="translate(74,332)">0.0</text><text y="0.32em" transform="translate(74,282.9090909090909)">0.2</text><text y="0.32em" transform="translate(74,233.8181818181818)">0.4</text><text y="0.32em" transform="translate(74,184.72727272727275)">0.6</text><text y="0.32em" transform="translate(74,135.63636363636363)">0.8</text><text y="0.32em" transform="translate(74,86.54545454545455)">1.0</text></g><g aria-label="x-axis tick label" transform="translate(126,9)"><text y="0.71em" transform="translate(103,332)"></text><text y="0.71em" transform="translate(383,332)"></text><text y="0.71em" transform="translate(663,332)"></text></g><g aria-label="bar"><rect data-viz-key="Qdrant|2 vCPU" x="117" width="224" y="112.57272727272732" height="205.42727272727268" rx="1.5" fill="#abb1c7"></rect><rect data-viz-key="Qdrant|4 vCPU" x="397" width="224" y="106.19090909090912" height="211.80909090909088" rx="1.5" fill="#dc244c"></rect><rect data-viz-key="Qdrant|8 vCPU" x="677" width="224" y="103.49090909090913" height="214.50909090909087" rx="1.5" fill="#6047ff"></rect></g><g text-anchor="middle" aria-label="text" font-family="'Geist Mono', ui-monospace, monospace" font-size="13" font-weight="600" transform="translate(126,-9)"><text y="0.32em" transform="translate(103,98.57272727272732)" fill="var(--qi-fg)">0.951</text><text y="0.32em" transform="translate(383,92.19090909090912)" fill="var(--qi-fg)">0.977</text><text y="0.32em" transform="translate(663,89.49090909090913)" fill="var(--qi-fg)">0.988</text></g><g text-anchor="middle" aria-label="text" fill="var(--qi-fg)" font-family="'Geist Mono', ui-monospace, monospace" font-size="13" font-weight="600" transform="translate(126,24)"><text transform="translate(103,332)">Qdrant</text><text transform="translate(383,332)">Qdrant</text><text transform="translate(663,332)">Qdrant</text></g><g text-anchor="middle" aria-label="text" fill="var(--qi-muted)" font-family="'Geist Mono', ui-monospace, monospace" font-size="12" transform="translate(126,40)"><text transform="translate(103,332)">2 vCPU</text><text transform="translate(383,332)">4 vCPU</text><text transform="translate(663,332)">8 vCPU</text></g></g><rect data-viz-zone data-viz-key="Qdrant|2 vCPU" data-viz-title="Qdrant · 2 vCPU" data-viz-rows="[{"k":"recall@10","v":"0.951","c":"#abb1c7"},{"k":"throughput","v":"67.2 QPS","c":"#abb1c7"}]" tabindex="0" role="button" aria-label="Qdrant 2 vCPU" x="74" y="62" width="290" height="270" fill="transparent"/><rect data-viz-zone data-viz-key="Qdrant|4 vCPU" data-viz-title="Qdrant · 4 vCPU" data-viz-rows="[{"k":"recall@10","v":"0.977","c":"#dc244c"},{"k":"throughput","v":"111.9 QPS","c":"#dc244c"}]" tabindex="0" role="button" aria-label="Qdrant 4 vCPU" x="364" y="62" width="290" height="270" fill="transparent"/><rect data-viz-zone data-viz-key="Qdrant|8 vCPU" data-viz-title="Qdrant · 8 vCPU" data-viz-rows="[{"k":"recall@10","v":"0.988","c":"#6047ff"},{"k":"throughput","v":"168.4 QPS","c":"#6047ff"}]" tabindex="0" role="button" aria-label="Qdrant 8 vCPU" x="654" y="62" width="290" height="270" fill="transparent"/></g></g><g data-viz-view="1" style="display:none"><g transform="translate(0,0)"><text x="490" y="20" text-anchor="middle" font-family="'Geist Mono', ui-monospace, monospace" font-size="15" font-weight="700" fill="var(--qi-fg)">Throughput</text><text x="490" y="38" text-anchor="middle" font-family="'Geist Mono', ui-monospace, monospace" font-size="13" fill="var(--qi-muted)">higher is better</text><text transform="translate(13,197) rotate(-90)" text-anchor="middle" font-family="'Geist Mono', ui-monospace, monospace" font-size="12" fill="var(--qi-muted)">QPS</text><g fill="currentColor" style="color:var(--qi-muted)" font-family="'Geist Mono', ui-monospace, monospace" font-size="13" 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="y-grid" aria-hidden="true" stroke="currentColor" stroke-opacity="0.1"><line x1="74" x2="944" y1="332" y2="332"></line><line x1="74" x2="944" y1="305" y2="305"></line><line x1="74" x2="944" y1="278" y2="278"></line><line x1="74" x2="944" y1="250.99999999999997" y2="250.99999999999997"></line><line x1="74" x2="944" y1="224" y2="224"></line><line x1="74" x2="944" y1="197" y2="197"></line><line x1="74" x2="944" y1="170" y2="170"></line><line x1="74" x2="944" y1="143" y2="143"></line><line x1="74" x2="944" y1="116" y2="116"></line><line x1="74" x2="944" y1="89" y2="89"></line><line x1="74" x2="944" y1="62" y2="62"></line></g><g aria-label="y-axis tick label" text-anchor="end" font-variant="tabular-nums" transform="translate(-9,0)"><text y="0.32em" transform="translate(74,332)">0</text><text y="0.32em" transform="translate(74,305)">20</text><text y="0.32em" transform="translate(74,278)">40</text><text y="0.32em" transform="translate(74,250.99999999999997)">60</text><text y="0.32em" transform="translate(74,224)">80</text><text y="0.32em" transform="translate(74,197)">100</text><text y="0.32em" transform="translate(74,170)">120</text><text y="0.32em" transform="translate(74,143)">140</text><text y="0.32em" transform="translate(74,116)">160</text><text y="0.32em" transform="translate(74,89)">180</text><text y="0.32em" transform="translate(74,62)">200</text></g><g aria-label="x-axis tick label" transform="translate(126,9)"><text y="0.71em" transform="translate(103,332)"></text><text y="0.71em" transform="translate(383,332)"></text><text y="0.71em" transform="translate(663,332)"></text></g><g aria-label="bar"><rect data-viz-key="Qdrant|2 vCPU" x="117" width="224" y="255.27999999999997" height="62.72000000000003" rx="1.5" fill="#abb1c7"></rect><rect data-viz-key="Qdrant|4 vCPU" x="397" width="224" y="194.935" height="123.065" rx="1.5" fill="#dc244c"></rect><rect data-viz-key="Qdrant|8 vCPU" x="677" width="224" y="118.65999999999998" height="199.34000000000003" rx="1.5" fill="#6047ff"></rect></g><g text-anchor="middle" aria-label="text" font-family="'Geist Mono', ui-monospace, monospace" font-size="13" font-weight="600" transform="translate(126,-9)"><text y="0.32em" transform="translate(103,241.27999999999997)" fill="var(--qi-fg)">67.2 QPS</text><text y="0.32em" transform="translate(383,180.935)" fill="var(--qi-fg)">111.9 QPS</text><text y="0.32em" transform="translate(663,104.65999999999998)" fill="var(--qi-fg)">168.4 QPS</text></g><g text-anchor="middle" aria-label="text" fill="var(--qi-fg)" font-family="'Geist Mono', ui-monospace, monospace" font-size="13" font-weight="600" transform="translate(126,24)"><text transform="translate(103,332)">Qdrant</text><text transform="translate(383,332)">Qdrant</text><text transform="translate(663,332)">Qdrant</text></g><g text-anchor="middle" aria-label="text" fill="var(--qi-muted)" font-family="'Geist Mono', ui-monospace, monospace" font-size="12" transform="translate(126,40)"><text transform="translate(103,332)">2 vCPU</text><text transform="translate(383,332)">4 vCPU</text><text transform="translate(663,332)">8 vCPU</text></g></g><rect data-viz-zone data-viz-key="Qdrant|2 vCPU" data-viz-title="Qdrant · 2 vCPU" data-viz-rows="[{"k":"recall@10","v":"0.951","c":"#abb1c7"},{"k":"throughput","v":"67.2 QPS","c":"#abb1c7"}]" tabindex="0" role="button" aria-label="Qdrant 2 vCPU" x="74" y="62" width="290" height="270" fill="transparent"/><rect data-viz-zone data-viz-key="Qdrant|4 vCPU" data-viz-title="Qdrant · 4 vCPU" data-viz-rows="[{"k":"recall@10","v":"0.977","c":"#dc244c"},{"k":"throughput","v":"111.9 QPS","c":"#dc244c"}]" tabindex="0" role="button" aria-label="Qdrant 4 vCPU" x="364" y="62" width="290" height="270" fill="transparent"/><rect data-viz-zone data-viz-key="Qdrant|8 vCPU" data-viz-title="Qdrant · 8 vCPU" data-viz-rows="[{"k":"recall@10","v":"0.988","c":"#6047ff"},{"k":"throughput","v":"168.4 QPS","c":"#6047ff"}]" tabindex="0" role="button" aria-label="Qdrant 8 vCPU" x="654" y="62" width="290" height="270" fill="transparent"/></g></g>
|
||||
@@ -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": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 <code>memory</code> configuration and report no latency, because sequential query passes warmed the page cache. The latency table above reports the placements instead.
|
||||
</aside>
|
||||
|
||||
{{< 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 |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -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." >}}
|
||||
|
||||
@@ -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 -}}
|
||||
@@ -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 <figure>
|
||||
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 <figcaption>, so the text exists exactly once.
|
||||
*/ -}}
|
||||
<figure class="viz-figure{{ with .class }} {{ . }}{{ end }}" data-viz>
|
||||
{{- with .views }}
|
||||
<div class="viz-toggle" role="group" aria-label="Choose what the chart shows" data-viz-toggle>
|
||||
{{- range $i, $label := . }}
|
||||
<button type="button" class="viz-toggle__btn" data-viz-toggle-btn="{{ $i }}"
|
||||
aria-pressed="{{ if eq $i 0 }}true{{ else }}false{{ end }}">{{ $label }}</button>
|
||||
{{- end }}
|
||||
</div>
|
||||
{{- end }}
|
||||
<div class="viz-figure__scroll">
|
||||
<svg class="viz-figure__svg" viewBox="{{ .viewBox }}" role="img"
|
||||
aria-labelledby="viz-cap-{{ $id }}" xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -39,5 +49,6 @@
|
||||
{{ .svg | safeHTML }}
|
||||
</svg>
|
||||
</div>
|
||||
<figcaption class="viz-figure__caption" id="viz-cap-{{ $id }}">{{ $caption }}</figcaption>
|
||||
<figcaption class="viz-figure__caption" id="viz-cap-{{ $id }}"
|
||||
{{- with .captions }} data-viz-captions="{{ (jsonify .) }}"{{ end }}>{{ $caption }}</figcaption>
|
||||
</figure>
|
||||
|
||||
@@ -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/<id>.{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) -}}
|
||||
|
||||
@@ -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 -}}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) =>
|
||||
`<g transform="translate(${i * (pw + gap)},0)">${panel(c, p, data, pw, c.height)}</g>`).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 `<g data-viz-view="${i}"${i === 0 ? '' : ' style="display:none"'}>`
|
||||
+ `${draw(merged)}</g>`;
|
||||
}).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}`);
|
||||
}
|
||||
|
||||
@@ -89,3 +89,34 @@ test('HTML output still renders the chart as SVG', () => {
|
||||
assert.match(html, /<svg class="viz-figure__svg"/, 'chart SVG missing from HTML');
|
||||
assert.match(html, /data-viz-zone/, 'hover hit zones missing from HTML');
|
||||
});
|
||||
|
||||
test('a chart with views renders a toggle and ships every view', () => {
|
||||
const html = getFixtureHtml();
|
||||
|
||||
// One button per view, the first one pressed.
|
||||
assert.match(html, /<div class="viz-toggle"[^>]*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');
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user