#!/usr/bin/env python3
"""Regenerate examples/viz/gallery.html from the current qsv binary.

`gallery.html` is a lightweight, CDN-plotly page rendering every `qsv viz`
chart type from the sample datasets in this directory. It is a checked-in
*artifact*; this script is the source of truth for how it's produced.

For each figure it runs the documented `qsv viz` command, extracts the figure
JSON object that plotly-rs emits (`Plotly.newPlot(graph_div, {...})`) from the
output, and reassembles them into one page that loads plotly from the CDN (so the
committed file stays small). The static scaffold (head / style / header) is reused
verbatim from the existing gallery, so re-running this only changes figure content
and order.

Every `qsv viz` run here sets QSV_VIZ_CDN=1, so viz itself emits the CDN
`<script src>` tag and the smart-Data Schematic iframes are committable as-is.

Usage (from the repo root), after changing viz output or the datasets:

    cargo build --bin qsv -F all_features
    python3 examples/viz/gen_gallery.py

Set QSV_BIN to point at a specific binary; otherwise target/{debug,release}/qsv
or a `qsv` on PATH is used. Re-run and commit gallery.html if the diff is what
you expect. README.md documents `viz` usage in tutorial form; its commands are
# illustrative and deliberately NOT a verbatim mirror of the gallery's argv (the gallery
# pins things like --max-charts that would only distract a reader). What README.md IS
# required to keep in step with this file is checked by `check_readme_claims` below.
"""
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
from html import escape as html_escape

VIZ_DIR = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(VIZ_DIR))
GALLERY = os.path.join(VIZ_DIR, "gallery.html")
MARKER = "Plotly.newPlot(graph_div, "

# Smart dashboards are embedded as iframes of the *genuine* `qsv viz smart` HTML output (so the
# full-width overview panels, themes and map buttons render exactly as the CLI produces them),
# rather than reconstructed as a lossy uniform sub-grid. Keyed by figure title -> iframe filename.
SMART_IFRAME = {
    "smart Data Schematic":                          "smart_sales.html",
    "smart Data Schematic (KPI gauges & target delta)": "smart_sales_kpi.html",
    "smart Data Schematic (--smarter)":              "smart_smarter.html",
    "smart Data Schematic (--smarter, geospatial)":  "smart_geospatial.html",
    "smart Data Schematic (geographic outliers)":    "smart_geo_outliers.html",
    "smart Data Schematic (time-series)":            "smart_timeseries.html",
    "smart Data Schematic (quarterly cadence)":      "smart_quarterly_cadence.html",
    "smart Data Schematic (per-US-state choropleth)":      "smart_us_choropleth.html",
    "smart Data Schematic (--dictionary infer, treemap)":  "smart_dict_treemap.html",
    "smart Data Schematic (--dictionary infer, sunburst)": "smart_dict_sunburst.html",
    "smart Data Schematic (--dictionary infer, world choropleth)": "smart_world_choropleth.html",
    "smart Data Schematic (--smarter, curated --dictionary, region-code zip choropleth)": "smart_allegheny_dogs.html",
    "smart Data Schematic (animated geo, world events)":   "smart_world_events.html",
    "smart Data Schematic (Gapminder bubble, regions growth)": "smart_regions_growth.html",
    "smart Data Schematic (--smarter, Gini/Lorenz inequality + log-skew boxes)": "smart_cms_medicare.html",
    "smart Data Schematic (dictionary-declared pipeline funnel)": "smart_onboarding_funnel.html",
    "smart Data Schematic (--smarter, zero-inflated capital pipeline)": "smart_cpdb.html",
}

# Iframe artifacts that depend on a live LLM (`--dictionary infer` calls describegpt against a
# local LM Studio / Ollama endpoint). Their committed HTML is REUSED as-is rather than regenerated,
# so a normal `gen_gallery.py` run stays LLM-free and deterministic. To refresh them in-place, run
# with QSV_VIZ_REGEN_LLM=1 and your local LLM up (LM Studio / Ollama) — main() then treats this set
# as empty so these `--dictionary infer` figures are regenerated and re-cdnified like the others.
#
# LEVEL 1 IS NOT ENOUGH TO ACTUALLY REACH THE LLM. Two caches short-circuit it independently, so a
# =1 run can rebuild these dashboards from a dictionary no LLM was consulted for:
#   1. `viz smart --dictionary infer` REUSES an existing `<stem>.schema.json` sidecar beside the
#      input, skipping describegpt entirely; and
#   2. describegpt replays its own disk-cached completion (~/.qsv-cache/describegpt, 28-day TTL).
# QSV_VIZ_REGEN_LLM=2 defeats both: it deletes these four dashboards' inferred sidecars (see
# `llm_dictionary_sidecars`) and exports QSV_VIZ_DICT_FRESH=1, which makes viz skip sidecar reuse
# AND pass `--fresh` to describegpt. Use =2 when the dictionaries themselves must be re-inferred
# (a model/prompt change); =1 remains right when only the Data Schematic rendering changed.
# NOTE: =2 makes real LLM calls, so these four figures are NOT byte-stable across runs — review the
# diff by hand before committing, and never wire =2 into an automated/CI regen.
PREGENERATED = {
    "smart_dict_treemap.html",
    "smart_dict_sunburst.html",
    "smart_world_choropleth.html",
    # smart_geospatial.html (Japan): --bivariate now implies --dictionary infer here (no
    # explicit --dictionary is passed), so it needs a live LLM like the other infer figures.
    "smart_geospatial.html",
}

# CSS for the smart-Data Schematic iframes. `scrolling="no"` + `overflow:hidden` plus the postMessage
# auto-sizing below mean each iframe ends up exactly as tall as its Data Schematic — no inner scrollbar,
# no trailing whitespace. The height here is just an initial value before the first height message.
DASH_CSS = ("figure.full iframe.dash{width:100%;border:0;height:600px;display:block;"
            "border-radius:6px;overflow:hidden}")

# The gallery's closing entries are clickable SCREENSHOTS (not plotly figures): scaled preview
# images that open a full, standalone `qsv viz smart` Data Schematic in a new popup window (see
# SCREENSHOTS below — those pages are far heavier than the embedded smart_*.html iframes). Portrait
# images, so cap their rendered height and center them; the border + hover lift mirror the
# surrounding figure chrome.
SHOT_CSS = ("figure a.shot{display:block;text-align:center;text-decoration:none;cursor:pointer}"
            "figure a.shot img{max-width:100%;max-height:900px;height:auto;border:1px solid #e6e9f0;"
            "border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.06)}"
            "figure a.shot:hover img{border-color:#c3ccdb;box-shadow:0 2px 8px rgba(0,0,0,.12)}")

# GitHub-style copy icons (Octicons, 16x16, fill=currentColor). The button shows the "copy" icon
# (two overlapping squares) and swaps to a green "check" on success — both are present in the
# button and toggled by the `.ok` class (no innerHTML churn, no SVG strings in JS).
COPY_ICON_SVG = ('<svg class="ci-copy" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">'
                 '<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25'
                 '.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.'
                 '75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path>'
                 '<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 '
                 '0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.11'
                 '2.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg>')
CHECK_ICON_SVG = ('<svg class="ci-check" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">'
                  '<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L1.72 9.78a.'
                  '751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 11.94l6.72-6.72a.75.75 0 0 1 1.'
                  '06 0Z"></path></svg>')

# Styling for the per-figure, copy-pasteable `qsv viz` command block rendered under each
# description. The block wraps long commands (with a ` \` shell continuation) and `<pre>` preserves
# those newlines; overflow-x:auto still scrolls any stray over-wide line. `.cmdbox` is the relative
# anchor for the GitHub-style icon-only Copy button (top-right); pre.cmd's right padding keeps text
# clear of it. The button is subtle by default, darker on hover, and shows a green check on success.
CMD_CSS = ("figure .cmdbox{position:relative;margin:6px 4px 0}"
           "figure pre.cmd{background:#f3f5f9;border-radius:6px;padding:8px 40px 8px 10px;"
           "margin:0;overflow-x:auto;font:11.5px/1.4 SFMono-Regular,Consolas,Menlo,monospace;"
           # pre-wrap (not plain `pre`) so a long single-line command wraps before the absolutely
           # positioned Copy button instead of scrolling underneath it; the 40px right padding above
           # reserves the button gutter, and overflow-wrap:anywhere lets a stray over-wide token
           # break on very narrow (mobile) widths rather than clip.
           "color:#2A3F5F;white-space:pre-wrap;overflow-wrap:anywhere}"
           "figure button.copy{position:absolute;top:6px;right:6px;display:inline-flex;"
           "align-items:center;justify-content:center;width:28px;height:28px;padding:0;"
           "border:1px solid transparent;background:transparent;color:#57606a;border-radius:6px;"
           "cursor:pointer}"
           "figure button.copy:hover{color:#24292f;background:#eef1f6;border-color:#d4d9e3}"
           "figure button.copy svg{width:16px;height:16px;fill:currentColor;display:block}"
           "figure button.copy .ci-check{display:none}"
           "figure button.copy.ok{color:#1a7f37;border-color:transparent;background:transparent}"
           "figure button.copy.ok .ci-copy{display:none}"
           "figure button.copy.ok .ci-check{display:block}")

# Navigation + responsive layout, injected once (between /*qsv-nav*/.../*end-qsv-nav*/ markers so a
# regen strips and re-adds it cleanly). Three jobs: (1) a sticky, collapsed-by-default "Jump to a
# chart" Table of Contents (the page is ~35 figures / very tall, so a jump list is the only sane way
# to navigate) — each link targets a per-figure id; scroll-margin-top keeps the sticky bar from
# covering a jumped-to figure's title, and scroll-behavior:smooth animates the jump. (2) a
# max-width:760px breakpoint that collapses the 2-col grid (set in the verbatim head) to a single
# column so charts and command blocks stop being crushed/clipped on phones. (3) the TOC's own chrome.
NAV_CSS = (
    "html{scroll-behavior:smooth}"
    "figure{scroll-margin-top:64px}"
    "details.toc{position:sticky;top:0;z-index:30;max-width:1200px;margin:0 auto 16px;background:#fff;"
    "border:1px solid #e6e9f0;border-radius:10px;box-shadow:0 1px 3px rgba(0,0,0,.05)}"
    "details.toc>summary{cursor:pointer;list-style:none;user-select:none;font-weight:600;font-size:13px;"
    "color:#2A3F5F;padding:10px 14px}"
    "details.toc>summary::-webkit-details-marker{display:none}"
    'details.toc>summary::after{content:"\\25be";float:right;color:#6b7a94;font-weight:400}'
    'details.toc[open]>summary::after{content:"\\25b4"}'
    "details.toc .toc-count{color:#8a97ad;font-weight:400;font-size:12px;margin-left:6px}"
    "details.toc .toc-links{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));"
    "gap:1px 16px;padding:6px 14px 12px;max-height:54vh;overflow:auto;border-top:1px solid #eef1f6}"
    "details.toc .toc-links a{display:block;font-size:12.5px;color:#3056d3;text-decoration:none;"
    "padding:3px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border-radius:4px}"
    "details.toc .toc-links a:hover{text-decoration:underline;background:#f3f5f9}"
    "@media (max-width:760px){body{padding:14px}"
    ".grid{grid-template-columns:1fr;gap:16px}"
    "figure.full .plot{height:520px}.plot{height:320px}"
    "details.toc .toc-links{grid-template-columns:1fr;max-height:60vh}}")

# Collapse the sticky "Jump to a chart" panel as soon as one of its links is clicked, so the open
# Table of Contents doesn't sit over the figure the reader just jumped to. The collapse happens
# before the browser's default in-page hash navigation, and figure{scroll-margin-top} keeps the
# target clear of the now-collapsed (thin) sticky bar.
TOC_JS = (
    "<script>document.addEventListener(\"click\",function(e){"
    "var a=e.target.closest&&e.target.closest(\"details.toc .toc-links a\");if(!a)return;"
    "var d=a.closest(\"details.toc\");if(d)d.open=false;});</script>")

# Injected once into the gallery: keeps a jumped-to figure in view while the page height is still
# settling. The Data Schematic iframes are lazy-loaded and grow from 600px to their real height on load,
# so iframes ABOVE a jump target load mid-scroll and push the target out of view — the reason
# "Jump to a chart" felt unreliable. On a TOC click / hashchange we arm the target for a short window
# and re-align it (instantly, respecting figure{scroll-margin-top}) on every Data Schematic resize report
# and on a coarse timer, so drift from iframes growing above it is corrected. Any manual scroll input
# ends the window immediately so this never fights the reader.
JUMP_JS = (
    "<script>(function(){var target=null,until=0,iv=null,moved=false;"
    "function stop(){if(iv){clearInterval(iv);iv=null;}}"
    # instant (not smooth): the page sets scroll-behavior:smooth, but a correcting re-align must be
    # instant. A smooth scrollIntoView restarted every tick as iframes above grow would perpetually
    # re-ease and never actually reach the target.
    "function snap(){if(target&&Date.now()<until){target.scrollIntoView("
    "{block:\"start\",behavior:\"instant\"});}else{stop();}}"
    "function arm(el){if(!el)return;target=el;until=Date.now()+2500;snap();"
    "if(!iv)iv=setInterval(snap,120);}"
    "document.addEventListener(\"click\",function(e){"
    "var a=e.target.closest&&e.target.closest(\"details.toc .toc-links a\");if(!a)return;"
    "var href=a.getAttribute(\"href\");if(!href||href.charAt(0)!==\"#\")return;"
    "arm(document.getElementById(href.slice(1)));});"
    "addEventListener(\"hashchange\",function(){"
    "if(location.hash.length>1)arm(document.getElementById(location.hash.slice(1)));});"
    # Data Schematic iframes report their height (qsvVizHeight -> re-snap) and forward the reader's own
    # scroll input (qsvUserScroll -> the reader took over, cancel and stop re-aligning).
    # a data viewer reveal request is also reader takeover: without this the stabilizer would
    # snap the jumped-to figure right back and undo the drawer reveal scroll.
    "addEventListener(\"message\",function(e){var d=e.data;if(!d)return;"
    "if(typeof d.qsvVizHeight===\"number\")snap();"
    "else if(d.qsvUserScroll||d.qsvVizReveal){until=0;moved=true;stop();}});"
    # scrolls over the non-iframe margins land on the parent window directly.
    "[\"wheel\",\"touchstart\",\"keydown\"].forEach(function(t){"
    "addEventListener(t,function(){until=0;moved=true;},{passive:true});});"
    # also arm on initial load with an existing fragment (a shared/bookmarked deep link like
    # gallery.html#fig-...), so lazy iframes settling after the browser's initial hash scroll don't
    # push the target out of view. Re-arm on load once the many heavy iframes have settled — but not
    # if the reader already scrolled away (moved), so a late load never yanks them back. Explicit TOC
    # clicks / hashchange still arm unconditionally.
    "function armFromHash(){if(!moved&&location.hash.length>1)"
    "arm(document.getElementById(location.hash.slice(1)));}"
    "armFromHash();addEventListener(\"load\",armFromHash);})();</script>")

# Injected once into the gallery: copies a command block's single-line form (data-cmd) to the
# clipboard. Uses the async Clipboard API when available (https / localhost) and falls back to a
# hidden-textarea + execCommand("copy") for file:// where the API is absent. Flips the button label
# to "Copied!" for ~1.2s on success.
COPY_JS = (
    "<script>document.addEventListener(\"click\",function(e){"
    "var b=e.target.closest&&e.target.closest(\"button.copy\");if(!b)return;"
    "var cmd=b.getAttribute(\"data-cmd\");"
    "function ok(){if(b._t)clearTimeout(b._t);b.classList.add(\"ok\");b.title=\"Copied!\";"
    "b._t=setTimeout(function(){b.classList.remove(\"ok\");b.title=\"Copy\";b._t=null;},1200);}"
    "if(navigator.clipboard&&navigator.clipboard.writeText){"
    "navigator.clipboard.writeText(cmd).then(ok,function(){fallback();});}else{fallback();}"
    "function fallback(){var t=document.createElement(\"textarea\");t.value=cmd;"
    "t.style.position=\"fixed\";t.style.opacity=\"0\";document.body.appendChild(t);t.select();"
    "try{if(document.execCommand(\"copy\"))ok();}catch(err){}document.body.removeChild(t);}"
    "});</script>")

# Injected into each smart_*.html so the Data Schematic reports its real rendered height to the parent
# gallery. postMessage works cross-origin (e.g. when the gallery is opened over file://), unlike
# reading iframe.contentWindow.document; the ResizeObserver re-reports after plotly's async relayout.
#
# The reported height is CONTENT-derived (body border-box + body vertical margins), NOT
# documentElement.scrollHeight: root scrollHeight is max(content, viewport), so once the iframe has
# grown it can never report smaller and the iframe could only ratchet upward — the data viewer
# drawer (issue #4283) reserves its space via body margin-bottom while open, and on close the
# iframe must SHRINK back. Body margins are counted because that reserved drawer space is exactly
# a body margin; fixed-position chrome (the drawer, theme toggle, logo) is deliberately excluded.
RESIZE_REPORTER_JS = (
    "<script>(function(){function r(){var b=document.body,c=getComputedStyle(b);"
    "parent.postMessage({qsvVizHeight:Math.ceil(b.offsetHeight+"
    "(parseFloat(c.marginTop)||0)+(parseFloat(c.marginBottom)||0))},\"*\");}"
    "addEventListener(\"load\",r);addEventListener(\"resize\",r);"
    "if(window.ResizeObserver)new ResizeObserver(r).observe(document.body);"
    "setTimeout(r,200);setTimeout(r,800);"
    # a deep-linked Data Schematic fills the viewport, so the reader's scroll input lands INSIDE this
    # iframe, not the parent gallery window. Forward it so the parent's jump stabilizer can tell the
    # reader has taken over and stop re-aligning / cancel its pending on-load re-arm (see JUMP_JS).
    # capture phase: the gl3d scroll-fix installs a capture-phase wheel handler on the plot div that
    # stopPropagation()s (so a 3D panel scrolls the page instead of being eaten); a bubble-phase
    # listener here would never see that wheel. Capturing at the window fires before the plot div, so
    # the reader's scroll over a 3D panel still cancels the parent's jump stabilizer.
    # pointerdown too: a CLICK inside the Data Schematic (e.g. the data viewer's Explore link) is
    # just as much reader takeover as a scroll, and the jump stabilizer must not fight the
    # drawer's reveal scroll after a TOC jump.
    "var us=function(){parent.postMessage({qsvUserScroll:1},\"*\");};"
    "[\"wheel\",\"touchstart\",\"keydown\",\"pointerdown\"].forEach(function(t){"
    "addEventListener(t,us,{capture:true,passive:true});});})();</script>")

# Added to the gallery once: sizes each iframe to the height its Data Schematic reports (matched by
# comparing window references, which is allowed cross-origin). The reported height is
# content-derived (see RESIZE_REPORTER_JS) and therefore viewport-independent, so applying it can
# never echo back into the next report; the >1px guard just suppresses no-op churn. Both
# directions apply: the iframe SHRINKS back when the data viewer drawer closes and its reserved
# body margin is released.
# It also honors qsvVizReveal messages from a Data Schematic's data viewer drawer (issue #4283): the
# drawer is position:fixed, which inside a full-height, never-scrolling iframe pins it to the
# iframe's BOTTOM edge — often far outside the parent viewport — and neither focus() nor
# scrollIntoView can reveal it (both are no-ops on fixed elements; direct parent-window access
# throws when the gallery is opened over file://). "bottom" aligns the iframe's bottom edge with
# the viewport (open); "top" scrolls back to the Data Schematic's metadata link (close).
RESIZE_LISTENER_JS = (
    "<script>addEventListener(\"message\",function(e){"
    "var d=e.data;if(!d)return;"
    "var f=document.getElementsByClassName(\"dash\"),src=null;"
    "for(var i=0;i<f.length;i++)if(f[i].contentWindow===e.source){src=f[i];break;}"
    "if(!src)return;"
    "if(typeof d.qsvVizHeight===\"number\"){"
    "if(Math.abs(src.clientHeight-d.qsvVizHeight)>1)src.style.height=d.qsvVizHeight+\"px\";}"
    # behavior "instant", not "smooth": this page sets scroll-behavior:smooth, and on an
    # iframe-heavy load a smooth animation can be canceled at its first frame by concurrent
    # layout work and never arrive — the jump stabilizer's snap() documents the same lesson.
    "else if(d.qsvVizReveal===\"bottom\"){var r=src.getBoundingClientRect();"
    "if(r.bottom>innerHeight)scrollTo({top:scrollY+r.bottom-innerHeight,behavior:\"instant\"});}"
    "else if(d.qsvVizReveal===\"top\"){var t=src.getBoundingClientRect();"
    "scrollTo({top:Math.max(0,scrollY+t.top+(d.qsvVizOffset||0)-80),behavior:\"instant\"});}"
    # A Data Schematic sizes its data viewer drawer in vh, but inside an auto-sized iframe vh
    # resolves against the iframe — which this listener has just grown to the Data Schematic's FULL
    # content height — so the drawer opened far taller than the window. The iframe cannot
    # measure the real viewport itself, so answer the child's request with ours; it converts to
    # px-per-vh. Older pregenerated dashboards simply never ask.
    "else if(d.qsvVizWantViewport){"
    "e.source.postMessage({qsvVizViewport:innerHeight},\"*\");}"
    "});"
    # keep them current: a window resize or rotation changes the number every drawer depends on
    "addEventListener(\"resize\",function(){"
    "var f=document.getElementsByClassName(\"dash\");"
    "for(var i=0;i<f.length;i++)if(f[i].contentWindow)"
    "f[i].contentWindow.postMessage({qsvVizViewport:innerHeight},\"*\");"
    "});</script>")

# The standalone (non-smart) figures are reconstructed in this page's own <script> via
# Plotly.newPlot, so — unlike the genuine `qsv viz` HTML output and the smart-Data Schematic iframes —
# they don't carry qsv's injected fullscreen modebar button. Define the same button here and add it
# to each figure's config (`modeBarButtonsToAdd`) so the gallery's standalone charts match the CLI.
# Plain JS (no <script> wrapper): emitted inside the page script, before the newPlot calls. The
# click handler toggles the native Fullscreen API on the graph div plotly passes in; a single
# document-level `fullscreenchange` listener then resizes the plot so it actually fills the
# fullscreen viewport on enter (and restores on exit) — mirroring the CLI handler's resize.
#
# Maps mirror the CLI's fit (see FULLSCREEN_SCRIPT in src/cmd/viz.rs): MapLibre bakes an
# absolute zoom for a fixed assumed px size — standalone `viz map` HTML frames against 1000x600
# (the only map figures reconstructed on this page are standalone; the map-bearing smart dashboards
# are iframes that carry their own CLI prelude). Since MapLibre zoom is logarithmic, the optimal zoom
# for any real container size is bakedZoom + log2(min(curW/1000, curH/600)) keeping the baked center
# (qsvFitTarget); curW/curH are domain-scaled (subplot px). The fit is applied by aiming the GL map
# camera directly (qsvApplyCamera -> _subplot.map.jumpTo), NEVER Plotly.relayout/react — those throw
# on a MapLibre choroplethmap in our pinned plotly fork and blank the layer. Applied on initial
# display and on every fullscreenchange, recomputing from the once-captured baked reference so
# toggles don't drift.
FS_BUTTON_JS = (
    'var qsvFsBtn={name:"qsv-fullscreen",title:"Toggle fullscreen",'
    'icon:{width:512,height:512,path:"M512 512v-208l-80 80-96-96-48 48 96 96-80 80z '
    'M512 0h-208l80 80-96 96 48 48 96-96 80 80z M0 512h208l-80-80 96-96-48-48-96 96-80-80z '
    'M0 0v208l80-80 96 96 48-48-96-96 80-80z"},'
    'click:function(gd){try{var p=document.fullscreenElement?document.exitFullscreen()'
    ':gd.requestFullscreen();if(p&&p.catch)p.catch(function(){});}catch(e){}}};'
    # "Toggle legend" button — mirrors FULLSCREEN_SCRIPT in src/cmd/viz.rs. Reads the EFFECTIVE
    # state from _fullLayout (plotly defaults showlegend:true for multi-trace charts that never set
    # it) so the first click hides a default-visible legend. No-op on maps: Plotly.relayout throws on
    # a MapLibre choroplethmap in our pinned fork, and a choropleth legend is a colorbar (not
    # governed by showlegend). Uses qsvMapKeys (defined below; function decls hoist).
    'var qsvLegendBtn={name:"qsv-legend",title:"Toggle legend",'
    'icon:{width:512,height:512,path:"M0 96h96v64H0z M160 112h352v32H160z M0 224h96v64H0z '
    'M160 240h352v32H160z M0 352h96v64H0z M160 368h352v32H160z"},'
    'click:function(gd){try{if(qsvMapKeys(gd).length)return;var fl=gd._fullLayout||gd.layout||{};'
    'var p=Plotly.relayout(gd,{showlegend:!fl.showlegend});if(p&&p.catch)p.catch(function(){});}'
    'catch(e){}}};'
    'function qsvMapKeys(gd){var lay=(gd&&gd.layout)||{};return Object.keys(lay).filter('
    'function(k){return /^map\\d*$/.test(k)&&lay[k]&&typeof lay[k].zoom==="number";});}'
    'function qsvCaptureBaked(gd){if(gd.__qsvBaked)return;gd.__qsvBaked={};var lay=gd.layout||{};'
    'qsvMapKeys(gd).forEach(function(k){gd.__qsvBaked[k]={z:lay[k].zoom,c:lay[k].center};});}'
    'function qsvPlotPx(gd){var fl=gd._fullLayout||{};'
    'return{w:fl.width||gd.clientWidth||0,h:fl.height||gd.clientHeight||0};}'
    'function qsvFitTarget(gd,k,plotW,plotH){var b=gd.__qsvBaked&&gd.__qsvBaked[k];'
    'if(!b||typeof b.z!=="number"||!(plotW>0)||!(plotH>0))return null;'
    'var aw=1000,ah=600,lay=gd.layout||{};var dom=(lay[k]&&lay[k].domain)||{};'
    'var dx=(dom.x&&dom.x.length===2)?(dom.x[1]-dom.x[0]):1;'
    'var dy=(dom.y&&dom.y.length===2)?(dom.y[1]-dom.y[0]):1;'
    'var ratio=Math.min((dx*plotW)/aw,(dy*plotH)/ah);if(!isFinite(ratio)||ratio<=0)return null;'
    'return{zoom:b.z+Math.log2(ratio),center:b.c};}'
    # Aim the GL map camera directly (gd._fullLayout[k]._subplot.map.jumpTo) — NEVER Plotly.relayout/
    # react, which throw on a MapLibre choroplethmap in our pinned plotly fork ("setData of
    # undefined") and blank the layer. Mirrors applyFitCamera in src/cmd/viz.rs.
    'function qsvApplyCamera(gd,plotW,plotH){if(!gd||!gd.__qsvBaked)return;'
    'var fl=gd._fullLayout||{},lay=gd.layout||{};Object.keys(gd.__qsvBaked).forEach(function(k){'
    'var t=qsvFitTarget(gd,k,plotW,plotH);if(!t)return;'
    'var sp=fl[k]&&fl[k]._subplot,m=sp&&sp.map;if(!m||typeof m.jumpTo!=="function")return;'
    'var dest={zoom:t.zoom};'
    'if(t.center&&typeof t.center.lon==="number"&&typeof t.center.lat==="number")'
    'dest.center=[t.center.lon,t.center.lat];try{m.jumpTo(dest);}catch(e){}'
    'if(lay[k]){lay[k].zoom=t.zoom;if(t.center)lay[k].center=t.center;}});}'
    # capture baked once, wait (bounded) for each map subplot's _subplot.map to attach, then aim.
    'function qsvFitNow(gd,tries){if(tries===undefined)tries=20;qsvCaptureBaked(gd);'
    'var fl=gd._fullLayout||{};var ready=Object.keys(gd.__qsvBaked||{}).every(function(k){'
    'var sp=fl[k]&&fl[k]._subplot;return sp&&sp.map;});'
    'if(!ready&&tries>0){setTimeout(function(){qsvFitNow(gd,tries-1);},100);return;}'
    'var px=qsvPlotPx(gd);qsvApplyCamera(gd,px.w,px.h);}'
    # scrollZoom control (mirrors setScrollZoom/applyScrollZoom in src/cmd/viz.rs, fix #4150): plotly
    # turns wheel-zoom ON by default for geo/map/gl3d subplots, so a wheel/two-finger scroll over a
    # map, choropleth or 3D figure zooms the subplot and swallows the PAGE scroll. Disable it inline
    # (so scrolling scrolls the page); re-enable in fullscreen (no page to scroll). geo/gl3d follow
    # plotly's live read of gd._context.scrollZoom; a MapLibre `map` subplot ignores the render
    # config at the GL-handler level, so toggle its wheel-zoom handler DIRECTLY once the map
    # attaches. Drag-pan (and 3D drag-rotate) is left untouched.
    'function qsvSetScrollZoom(gd,on){try{if(gd._context)gd._context.scrollZoom=on;}catch(e){}'
    'var fl=gd._fullLayout||{};qsvMapKeys(gd).forEach(function(k){'
    'var sp=fl[k]&&fl[k]._subplot,m=sp&&sp.map;'
    'if(m&&m.scrollZoom&&typeof m.scrollZoom.enable==="function"){'
    'try{if(on)m.scrollZoom.enable();else m.scrollZoom.disable();}catch(e){}}});}'
    # wait (bounded) for each MapLibre subplot's _subplot.map to attach (geo/gl3d/non-map figures are
    # ready immediately since qsvMapKeys is empty), then assert the scrollZoom state for the current
    # fullscreen mode. Published as a global so any re-render path can re-assert it.
    'function qsvApplyScrollZoom(gd,tries){if(tries===undefined)tries=20;var fl=gd._fullLayout||{};'
    'var ready=qsvMapKeys(gd).every(function(k){var sp=fl[k]&&fl[k]._subplot;return sp&&sp.map;});'
    'if(!ready&&tries>0){setTimeout(function(){qsvApplyScrollZoom(gd,tries-1);},100);return;}'
    'qsvSetScrollZoom(gd,document.fullscreenElement===gd);}'
    'window.__qsvRefitScrollZoom=qsvApplyScrollZoom;'
    # gl3d (3D scene) panels need a DIFFERENT fix than geo/map: plotly's WebGL canvas swallows the
    # wheel (preventDefault) on EVERY wheel event — even created with scrollZoom:false, which
    # suppresses the zoom but NOT the preventDefault — so a scroll over a 3D chart neither zooms NOR
    # scrolls the page. The robust fix is a capture-phase wheel interceptor: inline we stopPropagation
    # so the canvas handler never runs (never preventDefaults) and the wheel scrolls the PAGE; in
    # fullscreen we let it through. The listener sits on gd so it survives any re-render. Drag-rotate
    # uses pointer events, so it is untouched.
    'function qsvSceneKeys(gd){var fl=gd._fullLayout||{};'
    'return Object.keys(fl).filter(function(k){return /^scene\\d*$/.test(k);});}'
    'function qsvInstallGl3dFix(gd){if(gd.__qsvGl3dFix||!qsvSceneKeys(gd).length)return;'
    'gd.__qsvGl3dFix=true;gd.addEventListener("wheel",function(e){'
    'if(document.fullscreenElement!==gd)e.stopPropagation();},{capture:true,passive:true});}'
    'var qsvTries=0;function qsvInitFit(){if(typeof Plotly==="undefined"){'
    'if(qsvTries++<100)setTimeout(qsvInitFit,50);return;}var pending=false;'
    'document.querySelectorAll(".js-plotly-plot").forEach(function(gd){if(gd.__qsvInitFit)return;'
    'if(gd.data){gd.__qsvInitFit=true;qsvFitNow(gd);qsvApplyScrollZoom(gd);qsvInstallGl3dFix(gd);'
    # Core/Full extent buttons bake an assumed-px zoom; adopt the clicked extent as the new fit
    # reference and re-aim the camera for the current size (mirrors the CLI handler in viz.rs).
    'if(gd.on)gd.on("plotly_buttonclicked",function(ed){try{'
    'var args=ed&&ed.button&&ed.button.args&&ed.button.args[0];if(!args||!gd.__qsvBaked)return;'
    'var hit=false;Object.keys(gd.__qsvBaked).forEach(function(k){var z=args[k+".zoom"];'
    'if(typeof z!=="number")return;var c=args[k+".center"];'
    'gd.__qsvBaked[k]={z:z,c:(c!==undefined?c:gd.__qsvBaked[k].c)};hit=true;});'
    'if(hit)setTimeout(function(){qsvFitNow(gd);},0);}catch(e){}});'
    '}else pending=true;});'
    'if(pending&&qsvTries++<100)setTimeout(qsvInitFit,50);}setTimeout(qsvInitFit,0);'
    # Plotly.Plots.resize is async; fit only AFTER it resolves (mirrors viz.rs) so qsvFitNow reads
    # the post-resize dims rather than stale pre-change ones.
    'function qsvResizeThenFit(gd){var rp;try{rp=Plotly.Plots.resize(gd);}catch(e){}'
    # re-assert scrollZoom for the current fullscreen mode after the async resize resolves (mirrors
    # the fullscreenchange handler in src/cmd/viz.rs #4150): enable wheel-zoom entering fullscreen,
    # disable on exit — read post-resize so a MapLibre subplot's GL handle is attached.
    'var qsvDone=function(){qsvFitNow(gd);qsvApplyScrollZoom(gd);};'
    'if(rp&&rp.then)rp.then(qsvDone).catch(qsvDone);'
    'else qsvDone();}'
    'document.addEventListener("fullscreenchange",function(){try{'
    'var el=document.fullscreenElement;'
    'if(el&&el.classList&&el.classList.contains("js-plotly-plot")){qsvResizeThenFit(el);}'
    'else{document.querySelectorAll(".js-plotly-plot").forEach(function(gd){'
    'try{qsvResizeThenFit(gd);}catch(e){}});}'
    '}catch(e){}});')

BANNER = (
    "<!-- AUTO-GENERATED by examples/viz/gen_gallery.py — do not edit by hand.\n"
    "     Regenerate (from the repo root) after changing viz output or the datasets:\n"
    "       cargo build --bin qsv -F all_features && python3 examples/viz/gen_gallery.py\n"
    "-->"
)

# Attribution for the third-party software this page loads. The gallery references plotly.js from
# the version-pinned CDN (with SRI) rather than embedding it, and renders map figures on MapLibre
# basemaps. Kept in step with `third_party_comment`/`third_party_footer` in src/cmd/viz.rs, which
# emit the equivalent markup for `qsv viz`'s own output.
THIRD_PARTY_NOTICES_URL = "https://github.com/dathere/qsv/blob/master/THIRD_PARTY_NOTICES.md"
THIRD_PARTY_COMMENT = (
    "<!-- Third-party software used by this page:\n"
    "     plotly.js 3.7.0 - (c) 2012-2026 Plotly, Inc. - MIT\n"
    "       (bundles MapLibre GL JS - BSD-3-Clause)\n"
    "     Basemap tiles (c) OpenStreetMap contributors (ODbL 1.0), (c) CARTO\n"
    f"     Full texts: {THIRD_PARTY_NOTICES_URL} -->"
)
THIRD_PARTY_STYLE = (
    "<style>\n"
    "  #qsv-credits { font-size: 11px; line-height: 1.6; text-align: center; padding: 10px 16px 12px; color: var(--qsv-geo-meta, #5b6673); }\n"
    "  #qsv-credits a, #qsv-credits a:visited { color: var(--qsv-link, #0a5fb4); text-decoration: none; }\n"
    "  #qsv-credits a:hover, #qsv-credits a:focus-visible { color: var(--qsv-link-hover, #084b8f); text-decoration: underline; }\n"
    "</style>"
)
# No basemap clause, matching `third_party_footer` in src/cmd/viz.rs: every MapLibre panel draws
# its own always-visible "(c) CARTO, (c) OpenStreetMap contributors" control in-map, so the tile
# attribution is already discharged where the tiles are. THIRD_PARTY_COMMENT above still names
# them unconditionally.
THIRD_PARTY_FOOTER = (
    '<footer id="qsv-credits">'
    'Charts: <a href="https://plotly.com/javascript/" target="_blank" rel="noopener">plotly.js</a> (MIT)'
    f' &middot; <a href="{THIRD_PARTY_NOTICES_URL}" target="_blank" rel="noopener">notices</a>'
    "</footer>"
)

# (title, description, full_width, [viz args]). Order matters: the full-width smart dashboards
# lead and close the contiguous run of individual chart types.
FIGURES = [
    ("smart Data Schematic (--smarter, geospatial)",
     "One command, 13 auto-chosen panels — nearly every "
     "panel type at once on a synthetic catalog of Japanese earthquakes. Things the raw table hides "
     "but the Data Schematic makes obvious: depth_km is <b>bimodal</b> (two populations — shallow "
     "interplate quakes ~20&nbsp;km and the deep Wadati-Benioff slab ~450&nbsp;km — so --smarter "
     "draws a histogram, not a box that would average the peaks away); the points trace Japan's "
     "subduction arcs on the map; and a <b>prefecture choropleth</b> bins each quake into the "
     "GeoJSON region that contains it (point-in-polygon, no geocoding). Most of this catalog is "
     "offshore Pacific seismicity, so under the default 10&nbsp;km snap cap the far-offshore quakes "
     "are dropped (287 of 417 here — the panel title reports it) and the on-land/near-coast "
     "prefectures are colored; raise <code>--snap-max-dist</code> to snap distant quakes to the "
     "nearest prefecture instead, or <code>--no-snap</code> to drop every offshore point. "
     "magnitude vs felt_reports is almost perfectly correlated "
     "(r=0.95); magnitude and felt_reports are right-skewed with flagged outliers; and the "
     "magnitude-over-time trend spikes during a September aftershock sequence. "
     "<code>--bivariate</code> adds an <b>NMI association heatmap</b> spanning every column type, "
     "not just the continuous-numeric ones the Pearson heatmap covers — it surfaces "
     "depth_km and felt_reports as strongly associated with occurrence_date (NMI=0.93 and 0.89), "
     "a temporal-clustering signal (the same September aftershock sequence) a Pearson matrix "
     "restricted to numeric pairs alone cannot express against a date column. Coordinate columns "
     "are shown on the map only, not re-charted as distributions. Rendered with the built-in "
     "<code>plotly_dark</code> theme.",
     True, ["smart", "seismic_events.csv", "--smarter", "--bivariate", "--theme", "plotly_dark",
            "--grid-cols", "3", "--geojson", "japan_prefectures.geojson"]),
    ("smart Data Schematic (geographic outliers)",
     "Delivery stops clustered in metro Denver with four "
     "bad-geocode strays. Points far from the cluster centroid (beyond the Tukey far-out fence of "
     "their distances) are flagged as geographic <b>outliers</b>: drawn as distinct amber markers, "
     "drawn outside the purple (filled) spatial-extent box, and excluded from the auto-zoom — so the "
     "default view stays tight on the core cluster. A second, dashed-magenta no-fill box marks the "
     "full extent (core + outliers); use the <b>Core extent</b> / <b>Full extent</b> buttons at the "
     "top-left of the map to jump between the tight core view and the full spread (where the strays "
     "and the magenta box become visible). In the "
     "full <code>qsv viz smart</code> HTML output the spatial-extent label calls them out — "
     "<i>Colorado, United States &mdash; 4 outliers (Wyoming, Kansas &amp; Nebraska)</i> — while "
     "strays within the core's own jurisdiction are folded back in silently instead. "
     "Each stop also carries delivery attributes (<code>packages</code>, <code>weight_kg</code>, "
     "<code>distance_km</code>, <code>delivery_minutes</code>, a <code>vehicle</code> class and a "
     "<code>delivered_date</code>), so beyond the map the auto-profiler fills the Data Schematic out with "
     "box plots, frequency bars, a correlation heatmap, the strongest-pair scatter "
     "(packages vs weight_kg) and a delivered-over-time trend — all without <code>--smarter</code>.",
     True, ["smart", "delivery_stops.csv"]),
    ("smart Data Schematic",
     "Auto-profiled overview: correlation heatmap + box plots + frequency bars, led by a "
     "drill-down sunburst. `viz smart` now SKIPS an auto hierarchy when the candidate dimensions "
     "are statistically independent (nesting them would just replicate each level's marginal); "
     "sales_sample's region/payment_method/product_category are independent, so "
     "`--hierarchy-style sunburst` is passed to deliberately showcase the interactive sunburst.",
     True, ["smart", "sales_sample.csv", "--hierarchy-style", "sunburst", "--max-charts", "8"]),
    ("smart Data Schematic (KPI gauges & target delta)",
     "The KPI overview row driven by a hand-authored `--dictionary` "
     "(sales_kpi_dict.schema.json). Two optional `x-qsv` hints turn plain measure tiles into "
     "richer KPIs: a `gauge_range` of [0,1] draws Discount % and Profit Margin % as GAUGES on "
     "their canonical ratio scale, and a `target` of 0.25 on Profit Margin adds a \"vs target\" "
     "DELTA (the mean is 0.21, so a red -0.042 below the goal). qsv keeps a gauge only when the "
     "data lies within its range, so a mis-scaled range can't mislead; `gauge_range` is what "
     "`--dictionary infer` emits for canonical-scale ratios, while `target` is a business goal you "
     "hand-author (never LLM-inferred). Overall dataset completeness rides quietly in the header "
     "table, not as a tile.",
     True, ["smart", "sales_sample.csv", "--dictionary", "sales_kpi_dict.schema.json"]),
    ("smart Data Schematic (--smarter)",
     "Same auto-profiler with `--smarter`, which runs `qsv moarstats --advanced` itself to enrich "
     "the stats cache in one step: the bimodal monthly_spend column renders as a histogram (a box "
     "plot would hide its two peaks), and the skewed account_age_days box is annotated with its "
     "skew direction and outlier share.",
     True, ["smart", "customer_spend.csv", "--smarter", "--max-charts", "8"]),
    ("smart Data Schematic (--smarter, Gini/Lorenz inequality + log-skew boxes)",
     "<b>Medicare payments to individual practitioners</b> (CMS &mdash; a 30k sample), with "
     "<code>medical_payment</code> and <code>total_patients</code> per provider. These are additive "
     "amounts across <i>comparable</i> units, so <code>--smarter</code> recognizes them as inequality "
     "measures and adds a <b>Lorenz curve</b> for each (Gini 0.65 / 0.61) &mdash; the further the "
     "curve bows below the diagonal, the more concentrated the payments. The distribution boxes are "
     "<b>skew-aware</b>: a heavily right-skewed money column would squash a linear box into a sliver "
     "against its largest values, so <code>viz smart</code> draws it on a <b>log axis</b> instead, "
     "keeping the median and quartiles legible.",
     True, ["smart", "cms_medicare_providers.csv", "--smarter"]),
    ("smart Data Schematic (dictionary-declared pipeline funnel)",
     "A synthetic <b>product-onboarding funnel</b> &mdash; visits &rarr; signups &rarr; activated "
     "&rarr; subscribed, one row per channel per week. The four stage columns are declared as a "
     "pipeline in <code>onboarding_funnel_dict.schema.json</code> "
     "(<code>x-qsv.relationships</code>, <code>kind: \"pipeline\"</code>); which columns are "
     "stages, and in which direction, is semantics rather than a statistic, so the panel is only "
     "ever drawn from an explicit declaration &mdash; never guessed from column names.<br><br>"
     "Here the stages genuinely <b>nest</b>: every row is a strict subset of the one before it, "
     "and so are the totals. That is what earns a <b>funnel</b>, whose band widths <i>are</i> a "
     "containment claim &mdash; plotly computes the stage-to-stage conversion from the bar values "
     "themselves (<code>100% &rarr; 41% &rarr; 54% &rarr; 46%</code>), so the percentages can "
     "never drift from the bands. Compare the next figure, where the same kind of declaration "
     "over non-nesting totals is drawn as a bridge instead.",
     True, ["smart", "onboarding_funnel.csv",
            "--dictionary", "onboarding_funnel_dict.schema.json"]),
    ("smart Data Schematic (--smarter, zero-inflated capital pipeline)",
     "<b>NYC Capital Projects Database</b> (12,587 projects, sourced from Checkbook NYC), the "
     "dataset this Data Schematic's inequality work was designed against. Its money columns are both "
     "extremely concentrated (Gini 0.93&ndash;0.96) <i>and</i> heavily zero-inflated, which the "
     "Lorenz panels state outright: <code>flat run = 60% zeros, not small values</code>. That long "
     "flat opening run is not a mass of small projects &mdash; it is the projects with nothing "
     "committed or spent <i>yet</i>, a funding-pipeline stage rather than a have-not population. "
     "Every Lorenz panel also carries the unit caveat, because a Gini across units as unlike a "
     "subway extension and a playground resurfacing is close to tautological and must not be read "
     "as inequity.<br><br>It also carries the <b>pipeline bridge</b>, declared by "
     "<code>nyc_capital_projects_dict.schema.json</code> as an <code>x-qsv.relationships</code> "
     "entry of <code>kind: \"pipeline\"</code>. Which columns are stages, and in which direction, "
     "is semantics rather than a statistic, so the panel is only ever drawn from an explicit "
     "declaration &mdash; never guessed from column names.<br><br>The declaration says these "
     "columns are one pipeline; it cannot say that they <i>nest</i>, and that is measured. Here "
     "they do not: spent totals 2.9&times; committed, because the three are independent "
     "aggregates on different bases &mdash; <code>totalplannedcommit</code> is allocated in the "
     "Capital Commitment Plan, while the other two are sums within the City's budget. A funnel's "
     "band widths <i>are</i> a containment claim, so drawing one would render a band wider than "
     "the one above it &mdash; an hourglass asserting the opposite of the numbers, which no "
     "caption can retract. So <code>viz smart</code> keeps the declaration and changes the "
     "<b>form</b>: it bridges the signed gap between consecutive totals, naming each step as the "
     "arithmetic difference it is (<code>Committed &minus; Planned</code>), never as a flow. "
     "Declared order is still never re-sorted by size, and the subtitle says why the form "
     "changed: <code>stages do not nest &mdash; bridged, not funnelled</code>.",
     True, ["smart", "nyc_capital_projects.csv", "--smarter",
            "--dictionary", "nyc_capital_projects_dict.schema.json"]),
    ("bar", "Revenue by region (aggregated sum).",
     False, ["bar", "sales_sample.csv", "--x", "region", "--y", "revenue", "--agg", "sum"]),
    ("bar (animated slider)",
     "Revenue by product category, <b>animated over an ordinal column</b> with "
     "<code>--slider</code>: each distinct satisfaction rating (1&ndash;5) becomes an animation "
     "frame, with a <b>&#9654; Play</b>/<b>&#9208; Pause</b> button and a scrub slider to step "
     "through them (Gapminder-style). Axis ranges are <b>pinned across frames</b> so the bars stay "
     "comparable frame to frame instead of rescaling. <code>--slider</code> also works on line and "
     "scatter, may be split into animated traces with <code>--series</code>, and can accumulate "
     "with <code>--slider-cumulative</code>.",
     False, ["bar", "sales_sample.csv", "--x", "product_category", "--y", "revenue", "--agg", "sum",
             "--slider", "satisfaction"]),
    ("line", "Closing price over time.",
     False, ["line", "stock_prices.csv", "--x", "date", "--y", "close"]),
    ("scatter", "Units sold vs revenue.",
     False, ["scatter", "sales_sample.csv", "--x", "units_sold", "--y", "revenue"]),
    ("scatter (bubble)", "Units vs revenue; marker size = shipping cost, color = profit margin %.",
     False, ["scatter", "sales_sample.csv", "--x", "units_sold", "--y", "revenue",
             "--size", "shipping_cost", "--color", "profit_margin_pct"]),
    ("scatter (Gapminder bubble animation)",
     "The <b>Gapminder bubble animation</b> as a standalone chart: <code>--slider</code> supplies "
     "the time axis, <code>--series</code> the <b>entity</b> (one colored bubble per region) and "
     "<code>--size</code> the <b>third data variable</b> (population). Each region traces a path "
     "through the gdp/wellbeing space as the animation plays (<b>&#9654; Play</b>/"
     "<b>&#9208; Pause</b> + a scrub slider), with axes <b>pinned across frames</b> and bubble "
     "sizes scaled <b>once over every frame</b> so they stay comparable throughout. Each "
     "region&times;month cell collapses to one bubble via <code>--agg</code> (mean by default). "
     "This is the same chart <code>viz smart</code> auto-selects (see the last figure), but "
     "requested explicitly &mdash; so it draws sparse entities too, noting the gaps on stderr "
     "instead of dropping them.",
     False, ["scatter", "regions_growth.csv", "--x", "gdp_index", "--y", "wellbeing_index",
             "--size", "population_m", "--series", "region", "--slider", "month_date"]),
    ("scatter3d", "Units vs revenue vs shipping cost in 3D; marker color = profit margin %.",
     False, ["scatter3d", "sales_sample.csv", "--x", "units_sold", "--y", "revenue",
             "--z", "shipping_cost", "--color", "profit_margin_pct"]),
    ("histogram", "Distribution of unit price.",
     False, ["histogram", "sales_sample.csv", "--x", "unit_price"]),
    ("box", "Spread of revenue (Tukey whiskers; points beyond the fences shown as outliers).",
     False, ["box", "sales_sample.csv", "--y", "revenue"]),
    ("box (grouped)", "Revenue spread per region — real Tukey whiskers + every (jittered) point overlaid (--box-points all).",
     False, ["box", "sales_sample.csv", "--y", "revenue", "--x", "region", "--box-points", "all"]),
    ("violin", "Revenue distribution per region — a KDE density silhouette around an inner "
     "quartile box + mean line, revealing shape (modes, shoulders) a box hides. viz smart "
     "auto-picks this for columns in the bimodality ambiguity band (--violin auto).",
     False, ["violin", "sales_sample.csv", "--y", "revenue", "--x", "region"]),
    ("pie (donut)", "Revenue share by product category.",
     False, ["pie", "sales_sample.csv", "--x", "product_category", "--y", "revenue", "--donut"]),
    ("funnel",
     "Stage-by-stage drop-off for a signup pipeline encoded as <em>rows</em> (one row per stage "
     "per channel, summed per stage). Stages keep the order they first appear in the file, so the "
     "data defines the pipeline and nothing is inferred; each band is labelled with its conversion "
     "from the previous stage. This is the row-shaped counterpart to the column-shaped pipeline "
     "<code>viz smart</code> detects automatically.",
     False, ["funnel", "signup_funnel.csv", "--x", "stage", "--y", "users"]),
    ("heatmap (correlation)", "Pearson correlation matrix over numeric columns.",
     False, ["heatmap", "sales_sample.csv"]),
    ("scatter (correlated pair)",
     "The most strongly correlated numeric pair (discount_pct vs profit_margin_pct, r=-0.99). "
     "viz smart auto-adds this as a drill-down beside the correlation heatmap.",
     False, ["scatter", "sales_sample.csv", "--x", "discount_pct", "--y", "profit_margin_pct"]),
    ("contour", "2D density of units sold vs revenue (binned into a 20x20 grid). viz smart uses "
     "this instead of the pair scatter for large datasets, where a scatter would overplot.",
     False, ["contour", "sales_sample.csv", "--x", "units_sold", "--y", "revenue", "--bins", "20"]),
    ("heatmap (pivot)", "Region x category grid of revenue.",
     False, ["heatmap", "sales_sample.csv", "--x", "region", "--y", "product_category", "--z", "revenue"]),
    ("candlestick", "OHLC price action.",
     False, ["candlestick", "stock_prices.csv", "--x", "date", "--ohlc-open", "open",
             "--high", "high", "--low", "low", "--close", "close"]),
    ("ohlc", "Open-high-low-close bars.",
     False, ["ohlc", "stock_prices.csv", "--x", "date", "--ohlc-open", "open",
             "--high", "high", "--low", "low", "--close", "close"]),
    ("radar", "Multi-axis brand comparison (per-axis mean per series).",
     False, ["radar", "product_ratings.csv", "--cols", "battery,camera,performance,display,value,design",
             "--series", "brand"]),
    ("sankey", "Web session funnel (duplicate edges aggregated).",
     True, ["sankey", "web_flows.csv", "--source", "source", "--target", "target", "--value", "sessions"]),
    ("treemap", "Part-to-whole spend by plan then region, sized by summed monthly_spend. Rounded "
     "tiles + white separators come from the treemap-specific marker; non-numeric/negative measure "
     "cells are rejected so proportions can't silently misstate.",
     True, ["treemap", "customer_spend.csv", "--cols", "plan,region", "--value", "monthly_spend",
             "--agg", "sum"]),
    ("sunburst", "Three-level hierarchy (region -> product_category -> payment_method) as concentric "
     "rings, sized by row count; inner rings are parents, outer rings their children. Opens at two "
     "rings (maxdepth) so labels stay legible instead of crowding a ~100-sector outer ring; click a "
     "sector to drill in and the deeper ring's labels grow back. Hover always shows value + percent.",
     True, ["sunburst", "sales_sample.csv", "--cols", "region,product_category,payment_method"]),
    ("icicle", "Same three-level hierarchy (region -> product_category -> payment_method) as a rectangular "
     "icicle: parents on the left, children fanning right, each rectangle sized by row count. The flat "
     "left-to-right layout keeps deep labels readable where a sunburst's outer ring would crowd; click a "
     "rectangle to zoom into that branch. Hover shows label + value + percent of parent.",
     True, ["icicle", "sales_sample.csv", "--cols", "region,product_category,payment_method"]),
    ("splom", "Scatter-plot matrix of four numeric columns (units_sold, revenue, discount_pct, "
     "profit_margin_pct): every pairwise scatter in an N x N grid with shared axes, so correlation "
     "structure is legible at a glance. This is a <b>standalone</b> chart command &mdash; the "
     "<code>viz smart</code> Data Schematic has no SPLOM panel; it covers the same ground with its "
     "correlation heatmap plus the strongest-pair scatter and 3D scatter drill-downs.",
     True, ["splom", "sales_sample.csv", "--cols", "units_sold,revenue,discount_pct,profit_margin_pct"]),
    ("parcats", "Parallel-categories flow over three categorical columns (region -> product_category "
     "-> payment_method): each ribbon is a category combination, sized by how many rows share it, so "
     "co-occurrence between the dimensions is visible without implying a part-to-whole nesting. "
     "Ribbons are <b>colored by their first-axis category</b> and bundled (like a Sankey), and each "
     "axis opens ordered by frequency with a <b>&#8645; category order</b> toggle that flips every "
     "axis between frequency and alphabetical order. viz "
     "smart auto-adds this panel for 3-4 associated many-to-many categoricals (and suppresses the "
     "hierarchy on the same columns); genuine rollup trees still auto-select a treemap/sunburst.",
     True, ["parcats", "sales_sample.csv", "--cols", "region,product_category,payment_method"]),
    ("map", "Earthquake points on token-free OpenStreetMap tiles; marker color = magnitude, size = depth.",
     False, ["map", "quakes.csv", "--lat", "lat", "--lon", "lon", "--color", "magnitude", "--size", "depth_km"]),
    ("map (density)", "DensityMap heatmap of the same points on a light Carto basemap, "
     "<b>weighted by magnitude</b> (via <code>--color</code>) so stronger quakes glow hotter "
     "&mdash; hovering a point shows its magnitude, not just the coordinates.",
     False, ["map", "quakes.csv", "--lat", "lat", "--lon", "lon", "--density", "--color", "magnitude",
             "--style", "carto-positron"]),
    ("geo", "Same earthquakes on an offline natural-earth projection (no tiles, no token); marker "
     "color = magnitude. viz smart auto-uses this projection for global-extent coordinates.",
     False, ["geo", "quakes.csv", "--lat", "lat", "--lon", "lon", "--color", "magnitude",
             "--projection", "natural-earth"]),
    ("geo (animated slider)",
     "An <b>animated point map</b>: <code>--slider</code> steps through the region column, "
     "revealing the world's seismicity <b>continent by continent</b>, each region in its own "
     "color via <code>--series</code>. With <code>--slider-cumulative</code> the points "
     "<b>accumulate</b> as the animation plays (Play/Pause + a scrub slider). Unlike the MapLibre "
     "tile map, <code>scattergeo</code> animates natively, so <code>--slider</code> is supported "
     "on <code>viz geo</code> &mdash; use it instead of <code>viz map</code> for animated point "
     "maps.",
     False, ["geo", "quakes.csv", "--lat", "lat", "--lon", "lon", "--slider", "region",
             "--series", "region", "--slider-cumulative", "--projection", "natural-earth"]),
    ("choropleth", "Filled-region map coloring countries by GDP, matched by ISO-3 code on a "
     "token-free projection basemap. Use --location-mode usa-states / country-names / geojson-id "
     "for other region keys, --map for a MapLibre tile basemap, or --geocode to derive codes from "
     "lat/lon or place names.",
     False, ["choropleth", "country_stats.csv", "--locations", "iso3", "--value", "gdp_usd_tn",
             "--color-scale", "viridis"]),
    ("choropleth (US states)", "Same chart, <code>--location-mode usa-states</code>: state codes "
     "matched to Plotly's built-in US-state geometry on the token-free albers-usa projection "
     "(CONUS + Alaska/Hawaii insets) — no GeoJSON needed. States are colored by renewable-electricity "
     "share.",
     False, ["choropleth", "us_state_stats.csv", "--locations", "state", "--value",
             "renewable_electricity_pct", "--location-mode", "usa-states"]),
    ("choropleth (MapLibre + GeoJSON)", "<code>--map</code> draws the filled regions on an "
     "interactive MapLibre <b>tile</b> basemap (token-free carto-positron) instead of a projection. "
     "The regions come from a custom GeoJSON (<code>--geojson</code> local file or URL) matched to "
     "the data by <code>--feature-id-key</code> — here the near-rectangular western states, colored "
     "by installed wind capacity. The view auto-centers and zooms to the GeoJSON extent (shown "
     "full-width so the computed zoom frames the regions as the CLI does — a tile map's zoom is "
     "fixed, so a narrow grid cell would crop it).",
     True, ["choropleth", "western_states.csv", "--locations", "state", "--value",
             "wind_capacity_gw", "--geojson", "western_states.geojson", "--feature-id-key", "id",
             "--map", "--style", "carto-positron"]),
    ("smart Data Schematic (time-series)",
     "Auto Data Schematic for stock_prices: a time-series trend panel (the first numeric column over the "
     "date) leads; the strongest-correlated pair drill-down (open vs close) is shown as a <b>static "
     "scatter</b> — that relationship is a near-perfect line whose 2-D shape doesn't evolve, so "
     "the judicious animation gate withholds the (uninformative) animated version — alongside "
     "box-plot summaries of the OHLC columns.",
     True, ["smart", "stock_prices.csv", "--max-charts", "8"]),
    ("smart Data Schematic (quarterly cadence)",
     "Auto Data Schematic for quarterly_filings: 3 years of <b>quarterly</b> data spans ~1,000 days, "
     "which the span-based rule alone would bucket by <i>week</i> — ~150 buckets of which only 12 "
     "are non-empty, a comb of spikes. The cadence-aware floor detects the data's native quarterly "
     "spacing (most weekly/monthly periods are empty) and buckets the trend by <b>quarter</b> "
     "instead, rendered as evenly-spaced <code>YYYY-Qn</code> labels on a category axis with no "
     "inter-quarter gaps. A curated dictionary can also pin the floor explicitly via "
     "<code>x-qsv.cadence</code> (a describegpt-inferred token).",
     True, ["smart", "quarterly_filings.csv"]),
    ("smart Data Schematic (per-US-state choropleth)",
     "`viz smart` reverse-geocodes each point; because every city "
     "resolves to a US state, it adds a per-US-<b>state</b> choropleth (cities-per-state, albers-usa) "
     "beside the point map, alongside the usual box plots, frequency bars and the strongest-pair "
     "scatter. (The point map's <i>spatial extent</i> caption counts the data's bounding-box corners, "
     "which spill into neighboring countries and ocean — the choropleth instead resolves each city to "
     "its own state.) No flags, no LLM — the state fill is derived purely from the lat/lon columns.",
     True, ["smart", "us_cities.csv"]),
    ("smart Data Schematic (--dictionary infer, treemap)",
     "Auto Data Schematic for customer_spend with a describegpt-inferred Data Dictionary "
     "(--dictionary infer) guiding panel selection & field labels. Two categorical dimensions "
     "(plan, region) form a shallow part-to-whole hierarchy, auto-rendered as a TREEMAP "
     "(area = size). Requires a local LLM; the committed HTML is reused on regen.",
     True, ["smart", "customer_spend.csv", "--dictionary", "infer"]),
    ("smart Data Schematic (--dictionary infer, sunburst)",
     "Auto Data Schematic for sales_sample with a describegpt-inferred Data Dictionary. Its three "
     "categorical dimensions are statistically independent, so the auto-profiler skips the "
     "hierarchy by default; `--hierarchy-style sunburst` forces a SUNBURST here (concentric rings "
     "emphasize parent-child structure) to showcase the chart. Requires a local LLM; the committed "
     "HTML is reused on regen.",
     True, ["smart", "sales_sample.csv", "--dictionary", "infer", "--hierarchy-style", "sunburst"]),
    ("smart Data Schematic (--dictionary infer, world choropleth)",
     "<b>1,179 cities</b> with population "
     "over 500,000 across <b>six inhabited continents</b> (GeoNames-derived): `viz smart` "
     "reverse-geocodes every point and adds a per-<b>country</b> choropleth (cities-per-country, "
     "ISO-3) <b>framed to the filled-country geometries</b> via Plotly <code>fitbounds</code> — so "
     "the regions are never clipped at the viewport edge — beside the dense natural-earth point map "
     "(crimson markers so coastal/island points read against the ocean), plus a six-continent "
     "breakdown. A describegpt-inferred Data Dictionary supplies the friendly field labels (e.g. "
     "<i>Metro Population</i>, <i>Avg Annual Temp</i>). The <code>continent</code> column follows "
     "the <a href=\"https://plotly.com/javascript/reference/layout/geo/#layout-geo-scope\">plotly.js "
     "geo <code>scope</code></a> continent vocabulary (<i>Oceania</i>, <i>North America</i>, …). "
     "<b>Note:</b> <code>elevation_m</code> is real (GeoNames), while <code>avg_annual_temp_c</code> "
     "is a rough synthetic proxy (latitude + elevation-lapse model), so treat it as illustrative. "
     "Requires a local LLM; the committed HTML is reused on regen.",
     True, ["smart", "world_cities.csv", "--dictionary", "infer"]),
    ("smart Data Schematic (--smarter, curated --dictionary, region-code zip choropleth)",
     "All <b>50,013</b> Allegheny County lifetime dog licenses, profiled into auto-chosen panels. "
     "The headline panel is a <b>summary choropleth keyed off a region-code COLUMN</b>, not "
     "coordinates: this dataset has <i>no</i> lat/lon, only an <code>OwnerZip</code> column, so "
     "<code>viz smart</code> aggregates <b>licenses per zip</b> and fills the matching "
     "<a href=\"https://data.wprdc.org/dataset/allegheny-county-zip-code-boundaries2/"
     "resource/14e5de97-0a5f-4521-84f6-ba74413db598\">Allegheny County zip-boundary</a> "
     "polygons from "
     "<code>--geojson</code> (<code>--feature-id-key properties.ZIP</code>). The key column is "
     "auto-chosen by matching each geo-dimension column's values against the boundary ids — here "
     "<b>118 zips</b> match, from 1 license (rural fringe) to <b>2,866</b> in zip 15237 (suburban "
     "North Hills). Because the matched regions span a metro-scale extent, the fill lands on an "
     "interactive <b>MapLibre tile basemap</b> (token-free carto tiles) rather than the coarse "
     "projection basemap used for country/continental choropleths. A curated "
     "<code>--dictionary</code> (<code>allegheny_dogs_dict.schema.json</code>) tags "
     "<code>OwnerZip</code> as <code>geo.zip_code</code> — the signal that turns a numeric zip "
     "code into a choropleth key instead of a frequency bar — and marks <code>_id</code>/"
     "<code>DogName</code> as identifiers so they're skipped. This dataset carries no numeric "
     "<b>measure</b>, so only the per-zip <b>count</b> map is drawn; a dataset that also tags a "
     "measure column (e.g. a sale price) additionally gets a per-zip <b>median-of-measure</b> "
     "choropleth beside it. Alongside the map the profiler fills the Data Schematic with "
     "<code>LicenseType</code>/<code>Breed</code>/<code>Color</code> frequency bars and, via "
     "<code>--bivariate</code>, an <b>NMI association heatmap</b> (Breed ↔ Color) plus a ranked "
     "Top&nbsp;Relationships panel. <code>--dict-info</code> adds a per-panel info icon and a "
     "slide-over <b>Data Dictionary</b> drawer sourced from the same curated schema, topped by a "
     "<b>download row</b> bundling the dictionary, the charted frequency counts and the stats &amp; "
     "bivariate sidecars into the HTML itself. Fully "
     "deterministic — no LLM needed (the curated dictionary is reviewed and committed, exactly "
     "like an <code>--dictionary infer</code> pass would produce).",
     True, ["smart", "allegheny_dog_licenses.csv", "--smarter", "--bivariate", "--dict-info",
            "--dictionary", "allegheny_dogs_dict.schema.json",
            "--geojson", "allegheny_zip_boundaries.geojson",
            "--feature-id-key", "properties.ZIP"]),
    ("smart Data Schematic (animated geo, world events)",
     "Auto Data Schematic for world_events_dated: global-extent dated points across six continents "
     "(lon span ~300&deg;, lat span ~99&deg;). Because the extent is continental/global, "
     "<code>viz smart</code> draws a <b>ScatterGeo projection basemap</b> (which animates natively, "
     "unlike a MapLibre tile map) and adds an <b>animated geographic reveal</b>: the points "
     "accumulate <b>cumulatively over monthly time buckets</b> (Play/Pause + scrub slider). A "
     "city-scale point cloud would stay a static map — the animation only fires for large extents.",
     True, ["smart", "world_events_dated.csv"]),
    ("smart Data Schematic (Gapminder bubble, regions growth)",
     "Auto Data Schematic for regions_growth: a low-cardinality categorical entity "
     "(<code>region</code>) + a measure pair (<code>gdp_index</code> vs "
     "<code>wellbeing_index</code>) + a monthly date drive a <b>Gapminder-style animated bubble "
     "chart</b> &mdash; one bubble per region, each tracing a <b>distinct curved path</b> through "
     "the measure space over time, sized by per-cell record count and colored by region (Play/Pause "
     "+ scrub slider, legend of regions). Strict quality gates (min rows per cell, near-complete "
     "per-entity panel) keep it a real drift story rather than noisy jitter.",
     True, ["smart", "regions_growth.csv"]),
]

# The gallery closes with clickable SCREENSHOTS (not plotly figures): scaled preview images that
# open a full, standalone `qsv viz smart` "visual data dictionary" Data Schematic in a new popup window.
# These pages are far too heavy to embed as iframes like the smart_*.html figures (6.9MB for
# Boston, 6.8MB for Pittsburgh; NYC's 3.6MB Data Schematic is shown this way too so the gallery page
# stays light), and Pittsburgh's and Boston's source data is too large to commit at all. Kept OUT
# of FIGURES so they never run through `qsv viz` as a chart command.
#
# Each entry carries the verbatim command that produced its Data Schematic (rendered as a
# copy-pasteable block), a `win` popup-window name (distinct per entry, so opening one doesn't
# replace another), and optionally `args` — the `qsv viz` args to REGENERATE the linked page on
# every run. Only NYC 311 has `args`: its dataset (nyc_311.csv) is committed, so its Data Schematic
# stays deterministic and plaintext-validated like the iframe figures. Entries without `args` are
# committed artifacts reused as-is, and supply their own `cmd`.
SCREENSHOTS = [
    {
        "title": "Pittsburgh 311 smart visual data dictionary",
        "win":   "pitt311data",
        "desc": (
            "A full <code>qsv viz smart</code> <b>visual data dictionary</b> over real "
            "<b>Pittsburgh 311</b> service requests from the "
            '<a href="https://data.wprdc.org/dataset/pittsburgh-311-data">Western Pennsylvania '
            "Regional Data Center (WPRDC)</a>. The Data Schematic bins each request's lat/lon by "
            "point-in-polygon into Pittsburgh's neighborhood polygons "
            "(<code>--geojson pittsburgh-neighborhoods</code>, no geocoding); a curated "
            "<code>--dictionary</code> (<code>pitt311data.schema.json</code>) tags identifier/code "
            "columns and supplies friendly field labels, and <code>--dict-info</code> renders that "
            "dictionary as its own in-page <b>Data Dictionary</b> tab (with a hover/click info icon "
            "on every panel title). <code>--smarter</code> enriches the stats cache "
            "(<code>moarstats --advanced</code>) and <code>--bivariate</code> adds the NMI "
            "association heatmap plus the ranked top-relationships panel, while "
            "<code>--dataset-pid</code> adds a clickable citation link back to the source dataset. "
            "The standalone page is a ~10.4&nbsp;MB self-contained Data Schematic &mdash; too large to embed "
            "inline &mdash; so this is a screenshot: <b>click it to open the fully interactive "
            "Data Schematic in a new window</b>."),
        "image": "pitt311data-visual-datadic.webp",
        "href":  "pitt311data.html",
        # `--geojson pittsburgh-neighborhoods` is a QSV_GEOJSON_SHORTCUTS alias, not a file: the
        # env var maps a name to {path, id}, and the alias's id supplies --feature-id-key (here
        # properties.hood, which is what the committed page was built with). Do NOT put that JSON
        # in a repo `.env` to make the alias resolve -- qsv loads .env with dotenv_override(),
        # which clobbers the value the shortcut tests inject and breaks 6 of them. To rebuild this
        # page, use refresh_external_dashboards.sh, which passes the geojson file and the
        # feature-id-key directly (equivalent, and needs no env setup).
        "cmd":   ("qsv viz smart pittsburgh_311.tsv --smarter --dictionary pitt311data.schema.json "
                  "--dict-info --bivariate -o pitt311data.html "
                  "--geojson pittsburgh-neighborhoods "
                  "--dataset-pid https://data.wprdc.org/dataset/pittsburgh-311-data"),
    },
    {
        "title": "NYC 311 smart visual data dictionary (metro choropleth)",
        "win":   "smart_nyc311",
        "desc": (
            "A <b>10,000-row</b> sample of NYC 311 service requests (2010–2020) profiled into auto-chosen "
            "panels on a real, wide municipal dataset. The "
            "headline panel is the <b>metro choropleth</b>: each request's lat/lon is binned into NYC's "
            "<b>188 neighborhood</b> polygons (no geocoding — 110 of them receive requests, and 10 of the "
            "7,464 located points fall too far from any polygon to snap), and because the matched regions "
            "span a city-scale extent, <code>viz smart</code> draws the filled "
            "regions on an interactive <b>MapLibre tile basemap</b> (token-free carto tiles, fine "
            "street/coastline detail) instead of the coarse projection basemap it uses for "
            "country/continental choropleths. The leading <b>point map</b> flags bad-geocode "
            "<b>outliers</b> (here, <i>9 in Pennsylvania</i>): a nice illustration of the hover's value — "
            "for a stray PA point, the tooltip shows the record's own <i>Incident City: NEW YORK</i> "
            "right above its reverse-geocoded <i>Pennsylvania, United States</i>, so a real Manhattan "
            "complaint saddled with corrupt coordinates is self-evident at a glance. "
            "<code>--smarter</code> runs <code>moarstats --advanced</code> "
            "to enrich the stats cache, and a committed <code>--dictionary</code> "
            "(<code>nyc311_dict.schema.json</code> — a Data Dictionary generated for this dataset, then "
            "reviewed and committed alongside it) tags the record's identifier columns (<i>Unique Key</i>, "
            "<i>BBL</i>) so they're skipped rather than charted as quantities, labels the State-Plane "
            "coordinates as coordinate dimensions, and supplies friendly names (e.g. "
            "<i>Complaint Creation Date</i>, <i>Resolution Deadline</i>), so the "
            "profiler treats a service-request log as volume-and-category data. Alongside the maps the "
            "auto-profiler fills the Data Schematic with frequency bars, an <b>hour-of-day</b> seasonality "
            "profile, a time trend, a <b>parallel-categories (parcats) flow</b> over 3-4 associated "
            "categorical columns (co-occurrence ribbons, auto-chosen over a nested treemap/sunburst for "
            "this many-to-many set) and a mean-by-borough panel. New here is an "
            "auto-selected, colored <b>Sankey flow</b> (<i>Agency Code → Submission Channel</i>): it "
            "traces how each city agency's complaints arrive by channel, the thickest ribbons being "
            "<i>HPD → PHONE</i> (1,426) and <i>NYPD → PHONE</i> (1,348). "
            "<code>--bivariate</code> adds an <b>NMI association heatmap</b> across the <b>36 charted "
            "columns</b> plus a ranked "
            "<b>Top Relationships</b> panel — a horizontal <b>multivariate lollipop</b> where each dot's "
            "position is the pair's NMI on a value axis <b>zoomed</b> to the shown band (so near-ceiling "
            "associations separate instead of crushing together at 1.0), its <b>size</b> encodes "
            "co-occurrence support, and an <b>amber</b> dot flags a nonlinear pair. The top pair is a "
            "purely categorical one a Pearson-only heatmap could never surface, since neither column is "
            "numeric: <i>Borough</i> × <i>Park Borough</i> (NMI=1.0, n=10,000 of 10,000). It also flags a "
            "genuine <b>nonlinear</b> pair in amber — <i>Closed Date</i> × <i>Due Date</i> (NMI=0.9994): a "
            "complaint's actual close date is almost perfectly rank-associated with its deadline, yet how "
            "far the two land apart varies by complaint type, curving the relationship in a way a linear "
            "correlation alone would understate. The ranking is <b>support-weighted</b>: a pair only "
            "qualifies "
            "when its co-occurring row count is at least 10% of the best-supported pair's, so a "
            "technically-perfect NMI from two sparsely-populated columns can't crowd out a more broadly "
            "meaningful one — the top of the ranking instead surfaces genuinely dataset-wide pairs like "
            "<i>Borough</i> × <i>Park Borough</i> (n=10,000) and "
            "<i>Due Date</i> × <i>Resolution Action Updated Date</i> (NMI=0.9996, n=3,467). "
            "<code>--dict-info</code> adds a per-panel info icon and a slide-over <b>Data Dictionary</b> "
            "drawer sourced from the same committed schema, topped by a <b>download row</b> that bundles "
            "the dictionary as JSON Schema, the frequency counts the panels actually charted, and the "
            "stats &amp; bivariate sidecars this run was built from &mdash; every file carried inside the "
            "HTML, so anyone you send the Data Schematic to can save them. The run stays fully "
            "<b>deterministic</b> and offline: because the dictionary is committed, <code>--bivariate</code> "
            "never triggers a live <code>--dictionary infer</code> pass. "
            "The standalone page is a ~3.6&nbsp;MB Data Schematic, shown here as a screenshot so the "
            "gallery page stays light: <b>click it to open the fully interactive Data Schematic in a "
            "new window</b>."),
        "image": "nyc311data-visual-datadic.webp",
        "href":  "smart_nyc311.html",
        # regenerated on every run from the committed nyc_311.csv (its `cmd` is derived from
        # these args, so the displayed command can't drift from what was actually run)
        "args": ["smart", "nyc_311.csv", "--smarter", "--bivariate", "--dict-info",
                 "--dictionary", "nyc311_dict.schema.json",
                 "--geojson", "nyc_neighborhoods.geojson"],
    },
    {
        "title": "Boston 311 (2025) smart visual data dictionary",
        "win":   "smart_boston_311_2025",
        "desc": (
            "A full <code>qsv viz smart</code> <b>visual data dictionary</b> over a full year of "
            "<b>Boston 311</b> service requests (2025): <b>267,187 rows x 31 columns</b>, 92.6% "
            "complete, from the "
            '<a href="https://data.boston.gov/dataset/311-service-requests">City of Boston open '
            "data portal</a>, profiled into <b>28 auto-chosen panels</b>. A dense point map leads, "
            "followed by a <b>25-neighborhood choropleth</b>: each request's coordinates are binned "
            "point-in-polygon into Boston's neighborhood boundaries (<code>--geojson</code> with "
            "<code>--feature-id-key properties.name</code>, no geocoding) &mdash; Dorchester tops it "
            "at <b>38,855</b> requests (14.7%, rank 1 of 25), and each region's hover reports how "
            "many nearby strays were snapped into it. Because the matched regions span a city-scale "
            "extent, the fill lands on an interactive <b>MapLibre tile basemap</b> rather than the "
            "coarse projection basemap used for country/continental choropleths. Beyond the maps the "
            "auto-profiler adds an <b>hour-of-day</b> seasonality profile, a case-volume time trend, "
            "a <b>parallel-categories flow</b> (<i>Data Source &rarr; Subject Area &rarr; Fire "
            "District &rarr; City Council District</i>) and 23 frequency bars &mdash; among them the "
            "SLA split (<b>ONTIME 182,145</b> vs <b>OVERDUE 85,042</b>). <code>--smarter</code> "
            "enriches the stats cache (<code>moarstats --advanced</code>), and a committed "
            "<code>--dictionary</code> (<code>boston311.schema.json</code>, generated with "
            "<code>qsv describegpt --dictionary --two-pass --format jsonschema</code>, then reviewed "
            "and committed) supplies the friendly field labels that <code>--dict-info</code> renders "
            "as the in-page <b>Data Dictionary</b> tab &mdash; a hover/click info icon on every panel "
            "title, and a download row bundling the dictionary as JSON Schema alongside the stats and "
            "charted-frequency sidecars this run was built from. <code>--dataset-pid</code> adds a "
            "clickable citation link back to the source dataset. <code>--photos</code> turns the "
            "<code>closed_photo</code> column into map imagery: a point that has a photo says so in "
            "its hover, and resting on it for two seconds opens the case photo in a small preview "
            "beside the marker (nothing is fetched until you dwell, since the images load from a "
            "third-party host). Embedding every one of the 264,951 mapped points (all 267,187 "
            "records, via <code>QSV_VIZ_MAX_POINTS=300000</code>), plus the first 50,000 rows for "
            "the <b>data viewer</b> drawer that the row count's <b>(Preview)</b> link opens, makes "
            "this a ~21.5&nbsp;MB "
            "self-contained Data Schematic "
            "&mdash; too large to embed inline &mdash; so this is a screenshot: <b>click it to open "
            "the fully interactive Data Schematic in a new window</b>."),
        "image": "boston311data-visual-datadic.webp",
        "href":  "smart_boston_311_2025.html",
        "cmd":   ("QSV_VIZ_MAX_POINTS=300000 qsv viz smart boston311-2025.tsv --smarter "
                  "--dictionary boston311.schema.json --dict-info --bivariate --photos "
                  "-o smart_boston_311_2025.html "
                  "--geojson boston_neighborhood_boundaries.json "
                  "--dataset-pid https://data.boston.gov/dataset/311-service-requests/"
                  "resource/9d7c2214-4709-478a-a2e8-fb2020a5bb94 "
                  "--feature-id-key properties.name"),
    },
    {
        "title": "Brazil LPG prices smart visual data dictionary (Portuguese)",
        "win":   "smart_brazil_lpg",
        "desc": (
            "A <code>qsv viz smart</code> <b>visual data dictionary</b> rendered entirely in "
            "<b>Portuguese</b> over four weeks of <b>LPG (GLP) cooking-gas prices</b> — "
            "<b>12,732 rows x 16 columns</b>, 89.1% complete — from Brazil's national open data "
            'portal, <a href="https://dados.gov.br/dados/conjuntos-dados/serie-historica-de-'
            'precos-de-combustiveis-e-de-glp">dados.gov.br</a>. This is the <b>localization</b> '
            "showcase: <code>--language pt</code> resolves through the curated <code>pt-BR</code> "
            "locale (the bare tag is a registered alias), so every piece of Data Schematic chrome — "
            "<i>Descrição</i>, <i>Linhas</i>, <i>Colunas</i>, <i>Completude</i>, <i>Dicionário de "
            "dados</i>, <i>Ver o gráfico</i> — plus the vendored plotly and DataTables locale "
            "bundles all render in Portuguese. The input is a <b>semicolon-separated "
            "(<code>.ssv</code>)</b> export whose dates are day-first, so "
            "<code>QSV_PREFER_DMY=1</code> makes <code>Data da Coleta</code> parse as DD/MM/YYYY "
            "rather than month-first. The auto-profiler leads with a <b>27-state choropleth</b> "
            "(<i>contagem por Estado (Sigla)</i>) drawn on the coarse projection basemap "
            "<code>viz smart</code> uses for country-scale extents, followed by a "
            "<b>day-of-week radar</b> over the collection date and a price time trend. "
            "<code>--bivariate</code> adds the NMI association panels (and implicitly forces "
            "<code>--smarter</code>, so the stats cache is enriched by "
            "<code>moarstats --advanced</code>), while a committed <code>--dictionary</code> "
            "supplies the friendly Portuguese field labels that <code>--dict-info</code> renders "
            "as the in-page <b>Dicionário de dados</b> tab. Neither the <code>.ssv</code> export "
            "nor <code>brazil_geo.json</code> is committed here, so this page is reused as-is. "
            "The standalone Data Schematic is a ~17&nbsp;MB self-contained page &mdash; too large to "
            "embed inline &mdash; so this is a screenshot: <b>click it to open the fully "
            "interactive Data Schematic in a new window</b>."),
        "image": "brazil-lpg-visual-datadic.webp",
        "href":  "smart-brazil-lpg-4-semanas.html",
        "cmd":   ("QSV_PREFER_DMY=1 qsv viz smart ultimas-4-semanas-glp.ssv --smarter "
                  "--dictionary ultimas-4-semanas-glp.schema.json --dict-info --bivariate "
                  "-o smart-brazil-lpg-4-semanas.html --geojson brazil_geo.json --language pt "
                  "--dataset-pid https://dados.gov.br/dados/conjuntos-dados/"
                  "serie-historica-de-precos-de-combustiveis-e-de-glp"),
    },
    {
        "title": "Colombia air quality (PM) smart visual data dictionary (Spanish)",
        "win":   "smart_colombia_aire",
        "desc": (
            "A <code>qsv viz smart</code> <b>visual data dictionary</b> rendered entirely in "
            "<b>Spanish</b> (<code>--language es</code>) over <b>PM10 / PM2.5 particulate "
            "measurements</b> recorded at Colombian monitoring stations between 2011 and 2024 — "
            "<b>8,842 rows x 25 columns</b>, 100% complete. A <b>KPI row</b> headlines the totals "
            "(19.2M observations, 758k exceedance counts, 182k days with an exceedance), followed "
            "by a station <b>point map</b> and a <b>department choropleth</b> "
            "(<i>Regiones</i>) that bins each station point-in-polygon into Colombia's department "
            "boundaries via <code>--geojson colombia_departments.geojson</code> with "
            "<code>--feature-id-key properties.shapeName</code> — 14 stray points are snapped to "
            "their nearest department within the auto-derived 35&nbsp;km cap. Because the matched "
            "regions span a country-scale extent the fill lands on an interactive <b>MapLibre "
            "tile basemap</b>. The committed <code>--dictionary</code> is <b>hand-tuned</b>, and "
            "both edits are load-bearing: <code>Latitud</code>/<code>Longitud</code> carry "
            "explicit <code>geo.latitude</code>/<code>geo.longitude</code> concepts because "
            "<code>viz</code>'s header-name fallback matches only the English "
            "<i>lat</i>/<i>latitude</i>/<i>lon</i>/<i>longitude</i> (without them the Data Schematic "
            "has no map at all), and only the genuinely additive columns keep "
            "<code>role=measure</code> — <code>viz</code> recognises non-additive measures from an "
            "English token list, so Spanish <i>Promedio</i>/<i>Mediana</i>/<i>Porcentaje</i> would "
            "otherwise be SUMMED into meaningless KPI totals. <code>--bivariate</code> adds the "
            "NMI association panels (and implicitly forces <code>--smarter</code>), and "
            "<code>--dict-info</code> renders the dictionary as the in-page <b>Diccionario de "
            "datos</b> tab. Regenerate with the committed dictionary, <b>not</b> "
            "<code>--dictionary infer</code>. The standalone Data Schematic is a ~5.1&nbsp;MB "
            "self-contained page, shown here as a screenshot so the gallery page stays light: "
            "<b>click it to open the fully interactive Data Schematic in a new window</b>."),
        "image": "colombia-calidad-aire-visual-datadic.webp",
        "href":  "smart-colombia-calidad-aire.html",
        "cmd":   ("qsv viz smart calidad-aire-pm-colombia.csv --language es "
                  "--geojson colombia_departments.geojson "
                  "--feature-id-key properties.shapeName "
                  "--dictionary calidad-aire-pm-colombia.schema.json --dict-info --bivariate "
                  "-o smart-colombia-calidad-aire.html"),
    },
]


def find_qsv():
    if os.environ.get("QSV_BIN"):
        return os.environ["QSV_BIN"]
    for rel in ("target/debug/qsv", "target/release/qsv"):
        cand = os.path.join(REPO, rel)
        if os.path.exists(cand):
            return cand
    found = shutil.which("qsv")
    if found:
        return found
    sys.exit("qsv binary not found: build it (cargo build --bin qsv -F all_features) or set QSV_BIN")


def viz_command_tokens(title, args):
    """The `qsv viz` command for a figure as a token list, runnable from this directory.

    Dataset paths in `args` are already relative to examples/viz; a slugged `-o <slug>.html`
    (derived from the unique title) is appended so the command writes a viewable artifact
    instead of flooding stdout. This is display text only — `gen_gallery.py` runs each figure
    into a tempfile, so no example output file is written to the tree. Args are shlex-quoted so
    each token is atomic (safe to wrap on token boundaries even if a value contained spaces)."""
    slug = re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_")
    return ["qsv", "viz", *(shlex.quote(a) for a in args), "-o", f"{slug}.html"]


def viz_command(title, args):
    """The single-line command string — used verbatim as the copy-to-clipboard source."""
    return " ".join(viz_command_tokens(title, args))


def anchor_id(title):
    """Stable, hyphenated DOM id / URL fragment for a figure (e.g. `fig-choropleth-us-states`),
    so the Table of Contents can deep-link to it and the fragment is shareable. Titles are unique,
    so the derived ids are too."""
    return "fig-" + re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")


def toc_label(title):
    """Display text for a figure's Table-of-Contents link. The many `smart Data Schematic (...)` figures
    share a long `smart Data Schematic ` prefix that buries the distinguishing parenthetical and gets
    ellipsis-truncated in the TOC grid, so move the common `Data Schematic` word to the end (e.g.
    `smart Data Schematic (--smarter, geospatial)` -> `smart (--smarter, geospatial) Data Schematic`). The
    figure title itself, its anchor id and the iframe lookup keys are unchanged — this only affects
    the TOC link text. Bare `smart Data Schematic` (no parenthetical) round-trips unchanged."""
    prefix = "smart Data Schematic"
    if title.startswith(prefix):
        return "smart" + title[len(prefix):] + " Data Schematic"
    return title


def wrap_command_lines(tokens, width=60):
    """Wrap a token list into lines, breaking BEFORE a flag (`-`/`--` token) once the current
    line reaches `width`. Keeps each flag with its value on the same line and never splits a
    token. Returns the list of line strings (joined later with a ` \\` shell continuation)."""
    lines, cur = [], ""
    for tok in tokens:
        if not cur:
            cur = tok
        elif tok.startswith("-") and len(cur) >= width:
            lines.append(cur)
            cur = tok
        else:
            cur += " " + tok
    if cur:
        lines.append(cur)
    return lines


# Continuation joiner: trailing space + backslash + newline + 2-space indent. The displayed,
# wrapped command stays a valid shell command if pasted as-is; the Copy button copies the
# single-line form (data-cmd) for the cleanest paste.
WRAP_SEP = " \\\n  "


def command_block_html(tokens):
    """The copy-pasteable command block (wrapped display form + icon-only Copy button) for a token
    list. Shared by the per-figure figcaptions and the closing screenshot entries — the latter show
    a verbatim command (or one built from their own args) rather than one synthesized from a
    figure's viz args."""
    display = html_escape(WRAP_SEP.join(wrap_command_lines(tokens)))
    oneline = html_escape(" ".join(tokens), quote=True)
    return (f'<div class="cmdbox"><button class="copy" type="button" title="Copy" '
            f'aria-label="Copy command to clipboard" data-cmd="{oneline}">'
            f'{COPY_ICON_SVG}{CHECK_ICON_SVG}</button>'
            f'<pre class="cmd"><code>{display}</code></pre></div>')


def figcaption_html(title, desc, args):
    """A figure's `<figcaption>`: title, description, then the runnable command block with a
    Copy button. The displayed command is wrapped for readability; the button copies the
    single-line form."""
    return (f'<figcaption><span class="t">{title}</span>'
            f'<span class="d">{desc}</span>'
            f'{command_block_html(viz_command_tokens(title, args))}</figcaption>')


def scan_object(html, brace_start):
    """Brace-scan the balanced {...} object starting at brace_start; return (obj, end_index)."""
    assert html[brace_start] == "{", f"expected object at {brace_start}, got {html[brace_start]!r}"
    depth, in_str, esc = 0, False, False
    for i in range(brace_start, len(html)):
        c = html[i]
        if in_str:
            if esc:
                esc = False
            elif c == "\\":
                esc = True
            elif c == '"':
                in_str = False
            continue
        if c == '"':
            in_str = True
        elif c == "{":
            depth += 1
        elif c == "}":
            depth -= 1
            if depth == 0:
                return json.loads(html[brace_start:i + 1]), i + 1
    raise ValueError("unbalanced braces after newPlot marker")


def extract_fig_json(html):
    """The single grid-form figure object: `Plotly.newPlot(graph_div, {...})`."""
    obj, _ = scan_object(html, html.index(MARKER) + len(MARKER))
    return obj


# Start of the parcats "category order" animation script that `viz` injects into every page it
# emits (src/cmd/viz.rs `PARCATS_ORDER_MARKER`). The gallery reassembles bare figure JSON under its
# own scaffold, which carries none of viz's injected chrome — without lifting this block across,
# the gallery's parcats figure would fall back to plotly's un-animated snap.
PARCATS_MARKER = "<!--qsv-parcats-order-->"

# Filled in from the first `qsv viz` run of this regen (every emitted page carries the same block),
# then spliced into the scaffold by main().
PARCATS_SCRIPT = ""


def extract_parcats_script(html):
    """The marker + `<script>` block driving the animated parcats category-order flip, or "" when
    the binary predates it (the gallery then simply keeps the native snapping button)."""
    start = html.find(PARCATS_MARKER)
    if start < 0:
        return ""
    end = html.index("</script>", start) + len("</script>")
    return html[start:end]


def extract_inline_panels(html):
    """The inline-div smart Data Schematic form (used when a map panel forces it, or for >8 panels):
    a series of `Plotly.newPlot("qsv-viz-panel-N", {...})` calls, each a self-contained figure.
    Returns the list of panel objects, or None when the output isn't the inline form."""
    needle = 'Plotly.newPlot("qsv-viz-panel-'
    if needle not in html:
        return None
    panels, idx = [], 0
    while True:
        i = html.find(needle, idx)
        if i < 0:
            break
        obj, idx = scan_object(html, html.index("{", i))
        panels.append(obj)
    return panels


# Markers of a non-plaintext payload in viz output. All three are literals viz emits — a
# script-tag content type, a JS call, and the exact typed-array serialization — none of which
# scraped user data can produce.
#
# The two encodings QSV_VIZ_NO_COMPRESS disables have DIFFERENT thresholds, so checking only the
# gzip ones is not enough: a figure gzips at >=64KB of JSON (MAP_FIG_GZ_MIN_BYTES) but a coordinate
# array becomes base64 float32 at just >=64 elements (BDATA_MIN_LEN). A small map panel (e.g.
# delivery_stops.csv, 201 rows) therefore emits `bdata` and no gzip marker at all.
COMPRESSED_MARKERS = (
    'type="application/gzip-b64"',       # gzipped bundle / figure payload
    "qsvNewPlotGz(",                     # deferred render of a gzipped figure
    '"dtype":"float32","bdata":"',       # base64 float32 typed array (fields serialized in order)
)


def assert_plaintext(html, source):
    """Fail loudly if viz output is not fully plain text.

    `extract_inline_panels` scrapes plaintext `Plotly.newPlot(...)` calls; a gzipped panel is
    emitted as `qsvNewPlotGz(...)` + a base64 blob instead, so it would be skipped *silently* —
    a map panel would simply vanish from the gallery with no error. Typed arrays don't break the
    scrape (they parse as nested JSON) but do turn committed iframes into undiffable base64.
    QSV_VIZ_NO_COMPRESS in `run_html` prevents both; this asserts it actually took effect (e.g.
    it wasn't dropped, or overridden to a falsy value)."""
    found = [m for m in COMPRESSED_MARKERS if m in html]
    if found:
        raise ValueError(
            f"{source} is not plain text ({', '.join(found)}). The gallery needs plain-text "
            "output to scrape figure JSON and to commit diffable iframes — run_html sets "
            "QSV_VIZ_NO_COMPRESS=1 for this; check it is not being overridden (a falsy value in "
            "the environment or a .env file disables it). Pre-generated iframes must have been "
            "produced the same way."
        )


def run_html(qsv, args):
    """Run `qsv viz <args>` and return its HTML output as a string.

    QSV_VIZ_CDN makes viz emit a plotly CDN `<script src>` instead of the ~4.6MB inline bundle,
    so the smart-Data Schematic iframes are small enough to commit. QSV_VIZ_NO_COMPRESS keeps figure
    payloads in the fully readable plain form (no gzip+DecompressionStream, no base64 float32
    typed arrays), which this script needs to scrape figure JSON out of the HTML.

    Note QSV_VIZ_CDN alone is not enough: it only swaps the plotly *bundle* for a CDN tag, while
    map-panel *figures* keep their gzip+bdata encoding."""
    fd, out = tempfile.mkstemp(suffix=".html")
    os.close(fd)
    # Gallery-wide data viewer cap: smart dashboards embed their rows for the Explore/Preview
    # drawer (issue #4283), and under QSV_VIZ_NO_COMPRESS the payload is PLAIN json — the default
    # 50k-row threshold would add ~6MB to smart_nyc311.html alone. 500 rows still demonstrates
    # the drawer (small demo CSVs stay "(Explore)", larger ones show "(Preview)") while keeping
    # the committed iframes small.
    args = list(args)
    if args and args[0] == "smart" and "--preview-threshold" not in args:
        args += ["--preview-threshold", "500"]
    try:
        subprocess.run([qsv, "viz", *args, "-o", out], cwd=VIZ_DIR,
                       check=True, capture_output=True, text=True,
                       env={**os.environ, "QSV_VIZ_CDN": "1", "QSV_VIZ_NO_COMPRESS": "1"})
        with open(out, encoding="utf-8") as fh:
            html = fh.read()
    finally:
        os.unlink(out)
    assert_plaintext(html, f"`qsv viz {' '.join(args)}` output")
    global PARCATS_SCRIPT
    if not PARCATS_SCRIPT:
        PARCATS_SCRIPT = extract_parcats_script(html)
    return html


def run_fig(qsv, args):
    html = run_html(qsv, args)
    panels = extract_inline_panels(html)
    if panels is not None:
        return {"panels": panels}          # inline multi-panel Data Schematic
    return {"fig": extract_fig_json(html)}  # single grid-form figure


def inject_resize_reporter(html):
    """Add the postMessage height/user-scroll reporter just before the page's real </body> so the
    iframe can be auto-sized to the Data Schematic with no inner scrollbar and no trailing whitespace.
    Anchor on the LAST </body>, not the first: `--dict-info` dashboards embed a complete standalone
    HTML document (with its own </body></html>) as a string inside the qsvOpenDictTab script, so a
    first-match replace would inject the reporter inside that script string, where it never runs —
    leaving the iframe stuck at its initial height (the Allegheny dog-licenses Data Schematic clipping).

    Idempotent: strip any previously-injected reporter (a <script> containing qsvVizHeight; it has no
    literal `<` in its body) before re-adding the current one, so re-running against an already-built
    Data Schematic (e.g. the reused pre-generated ones) refreshes the reporter instead of duplicating it."""
    html = re.sub(r"<script>[^<]*qsvVizHeight[^<]*</script>\n?", "", html)
    idx = html.rfind("</body>")
    if idx == -1:
        return html + RESIZE_REPORTER_JS
    return html[:idx] + RESIZE_REPORTER_JS + "\n" + html[idx:]


def grid_cols(args):
    """The --grid-cols value from a smart Data Schematic's args (default 2)."""
    if "--grid-cols" in args:
        return int(args[args.index("--grid-cols") + 1])
    return 2


def cleanup_sidecars():
    # `viz smart` writes a stats cache next to the CSV; `--smarter` (via its internal
    # `moarstats --advanced`) also auto-creates an `.idx` index; `--bivariate` additionally
    # writes a `*.stats.bivariate.csv` sidecar (a separate moarstats output, so it doesn't
    # match the `.stats.csv` substring check below). Don't leave any of these in the tree
    # (the committed datasets ship without them).
    #
    # Called at BOTH ends of main(). Sweeping afterwards keeps the tree clean; sweeping first is
    # what keeps the gallery reproducible, because `viz smart` REUSES an existing stats cache
    # rather than recomputing it. A cache left behind by unrelated manual work -- notably a
    # `--smarter` run, whose moarstats enrichment adds outlier annotations to panel titles -- is
    # otherwise silently inherited by a figure whose own command never asked for it. That
    # produced a committed Data Schematic whose caption did not match the command printed beside it.
    removed = 0
    for f in os.listdir(VIZ_DIR):
        if (".stats.csv" in f or ".stats.jsonl" in f or ".stats.bivariate.csv" in f
                or f.endswith(".idx")):
            os.unlink(os.path.join(VIZ_DIR, f))
            removed += 1
    return removed


def regen_llm_level():
    """0 = off, 1 = regenerate the LLM dashboards, 2 = also force a genuinely FRESH inference.

    Only explicit values enable it, so QSV_VIZ_REGEN_LLM=0/false/off (or empty) stays off rather
    than enabling LLM work just because the var is present. Note "2" is deliberately matched
    before the truthy set -- it is not a member of it, so a plain truthiness test would read
    QSV_VIZ_REGEN_LLM=2 as OFF and silently disable LLM regen entirely.
    """
    raw = os.environ.get("QSV_VIZ_REGEN_LLM", "").strip().lower()
    if raw == "2":
        return 2
    return 1 if raw in {"1", "true", "t", "yes", "y", "on"} else 0


# Every identifier the data-viewer chrome namespaces under `qsv-data-` — element ids, CSS class
# names and custom properties. Emitted as PLAIN TEXT in the drawer's own <style>/<script>, so this
# is readable even on pages that inline the DataTables bundle gzip+base64 (where the bundle's own
# builder header is unreadable, and a bundle-version comparison sees nothing at all).
QSV_DATA_MARK_RE = r"qsv-data-[a-z0-9-]+"


def data_viewer_markers(html):
    """The set of `qsv-data-*` identifiers a generated page's data viewer uses, or None if it has
    no viewer. Two pages built by the same qsv agree exactly; a chrome change that adds or removes
    a drawer element, class or custom property shows up as a set difference."""
    marks = set(re.findall(QSV_DATA_MARK_RE, html))
    return marks or None


def warn_stale_data_viewers(linked_pages):
    """Warn when a committed Data Schematic's data viewer lags the one the current qsv binary emits.

    Every figure this script regenerates picks up the current viewer for free. Two linked pages
    cannot be: `pitt311data.html` and `smart_boston_311_2025.html` are built from datasets that are
    NOT in the repo (`boston311-2025.tsv` alone is 153 MB, and `*.tsv*` is gitignored), so they are
    committed artifacts carrying whatever chrome they were last built with. That is why their
    SCREENSHOTS entries have `cmd` (a display string) and no `args`.

    A change to the drawer therefore refreshes every other page and silently leaves those two
    showing a viewer the binary no longer produces. Deliberately a WARNING, not a SystemExit:
    refusing to build would break the gallery for everyone without the 153 MB source data, which
    is the normal case. Refreshing them needs the datasets and the entry's documented `cmd`.

    Compares the `qsv-data-*` identifier set rather than any single hand-picked marker, so it stays
    honest across future chrome changes without being re-taught what "current" looks like — and
    unlike the DataTables bundle version, that set is plain text even when the bundle is
    compressed, which is exactly the case for the two pages this exists to watch.
    """
    ref_name = ref = None
    for name in sorted(SMART_IFRAME.values()):
        path = os.path.join(VIZ_DIR, name)
        if not os.path.exists(path):
            continue
        with open(path, encoding="utf-8") as fh:
            marks = data_viewer_markers(fh.read())
        if marks:
            ref_name, ref = name, marks
            break
    if not ref:
        return

    stale = []
    for name in linked_pages:
        path = os.path.join(VIZ_DIR, name)
        if not os.path.exists(path) or name == ref_name:
            continue
        with open(path, encoding="utf-8") as fh:
            marks = data_viewer_markers(fh.read())
        if marks is not None and marks != ref:
            missing = ", ".join(sorted(ref - marks)) or "-"
            extra = ", ".join(sorted(marks - ref)) or "-"
            stale.append(f"{name}: missing [{missing}] stale [{extra}]")
    if stale:
        sys.stderr.write(
            "WARNING: these committed pages ship a data viewer that differs from the one "
            f"{ref_name} was just built with:\n  " + "\n  ".join(stale) +
            "\n  They are built from datasets that are not committed (see the SCREENSHOTS entry "
            "for the command), so this script cannot refresh them.\n")


def warn_stale_parcats_pages(linked_pages):
    """Warn when a committed Data Schematic carries a parcats panel but not the animation script.

    The same two pages `warn_stale_data_viewers` exists for -- `pitt311data.html` and
    `smart_boston_311_2025.html`, built from datasets that are not in the repo -- also carry parcats
    panels, so a regen leaves their category-order button on plotly's un-animated snap. Every page
    this script rebuilds gets the current script for free; those two cannot be rebuilt here.

    Deliberately a warning and NOT an in-place splice of PARCATS_SCRIPT into them: the script is
    only half of the current output (the button's `pad`, which lifts the pill off the first axis
    label, lives in the figure JSON), so patching the script alone would produce a page no `qsv viz`
    run emits -- animated, but still overlapping. Refreshing them properly needs the datasets and
    the SCREENSHOTS entry's documented `cmd`, exactly as for the data viewer.
    """
    if not PARCATS_SCRIPT:
        return  # this binary emits no such script; nothing to be stale against
    stale = []
    for name in linked_pages:
        path = os.path.join(VIZ_DIR, name)
        if not os.path.exists(path):
            continue
        with open(path, encoding="utf-8") as fh:
            html = fh.read()
        if '"type":"parcats"' in html and PARCATS_MARKER not in html:
            stale.append(name)
    if stale:
        sys.stderr.write(
            "WARNING: these committed pages have a parcats panel but not the category-order "
            "animation script:\n  " + "\n  ".join(stale) +
            "\n  Their toggle keeps plotly's un-animated snap. They are built from datasets that "
            "are not committed (see the SCREENSHOTS entry for the command), so this script cannot "
            "refresh them.\n")


def llm_dictionary_sidecars():
    """The `<stem>.schema.json` sidecars belonging to the LLM (`--dictionary infer`) dashboards.

    Derived from PREGENERATED -> SMART_IFRAME (reversed to iframe->title) -> FIGURES -> input CSV.
    NOT from scanning argv for `--dictionary infer`: seismic_events.csv reaches inference via
    `--bivariate` implying it, with no `--dictionary` in its args, so an argv scan finds only 3 of
    the 4 and would silently leave the geospatial Data Schematic's sidecar in place.

    A blanket `*.schema.json` sweep is FORBIDDEN: the curated dictionaries beside these
    (allegheny_dogs_dict, nyc311_dict, sales_kpi_dict, onboarding_funnel_dict,
    nyc_capital_projects_dict, boston311, pitt311data) are hand-authored, reviewed and tracked in
    git, and drive several other figures via an explicit `--dictionary <path>`.
    """
    iframe_to_title = {v: k for k, v in SMART_IFRAME.items()}
    args_by_title = {t: a for (t, _d, _f, a) in FIGURES}
    paths = []
    for iframe in sorted(PREGENERATED):
        args = args_by_title.get(iframe_to_title.get(iframe, ""), None)
        if args and len(args) >= 2 and args[0] == "smart" and args[1].endswith(".csv"):
            paths.append(os.path.join(VIZ_DIR, os.path.splitext(args[1])[0] + ".schema.json"))
    # Fail closed: a retitled figure would otherwise quietly shrink this list and reintroduce the
    # exact silent no-op (a "fresh" run reusing a cached dictionary) this mode exists to fix.
    if len(paths) != len(PREGENERATED):
        raise SystemExit(
            f"gen_gallery.py: mapped only {len(paths)} of {len(PREGENERATED)} PREGENERATED "
            "dashboards back to an input CSV -- SMART_IFRAME/FIGURES titles are out of step")
    return paths


# Counting words as README.md writes them. Only the range the gallery can plausibly reach.
_NUM_WORDS = {
    1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six", 7: "seven",
    8: "eight", 9: "nine", 10: "ten", 11: "eleven", 12: "twelve", 13: "thirteen",
    14: "fourteen", 15: "fifteen", 16: "sixteen", 17: "seventeen", 18: "eighteen",
    19: "nineteen", 20: "twenty", 21: "twenty-one", 22: "twenty-two",
    23: "twenty-three", 24: "twenty-four",
}


def check_readme_claims():
    """Fail closed on the README drift this script can actually prove.

    Adding a figure silently invalidates prose counts and leaves its input undocumented; that
    has happened repeatedly, most recently a Data Schematic count that was corrected to the number of
    `smart_*.html` files on disk when the sentence is about EMBEDDED iframes -- two of those files
    are screenshot link-outs, so the "fix" was wrong in the other direction.

    Only claims with a mechanical source of truth are checked. Command text is deliberately NOT
    compared: README.md is a tutorial and its invocations differ from the gallery's argv on
    purpose. Runs AFTER the gallery is written, so a new figure still regenerates -- the exit
    code is what tells you the docs need a follow-up.
    """
    readme_path = os.path.join(VIZ_DIR, "README.md")
    if not os.path.exists(readme_path):
        return
    with open(readme_path, encoding="utf-8") as fh:
        readme = fh.read()

    problems = []

    def expect_phrase(count, phrase_fmt, what):
        word = _NUM_WORDS.get(count, str(count))
        if phrase_fmt.format(n=word) not in readme:
            problems.append(
                f"{what}: expected README.md to say "
                f"{phrase_fmt.format(n=word)!r} (there are {count})"
            )

    expect_phrase(
        len(SMART_IFRAME),
        "The {n} **smart dashboards** are embedded",
        "embedded-Data Schematic count",
    )
    expect_phrase(
        len(SCREENSHOTS),
        "closes with {n} clickable **screenshot link-outs**",
        "screenshot link-out count",
    )

    # every input actually fed to qsv should be findable in the README's tables. That includes
    # the screenshot link-outs built with `args`: those run qsv exactly like a figure does. The
    # `cmd`-only entries are display strings for datasets too large to commit, so their inputs
    # are deliberately not required here.
    fed_to_qsv = [fig[3] for fig in FIGURES]
    fed_to_qsv += [shot["args"] for shot in SCREENSHOTS if "args" in shot]
    inputs = {
        arg
        for argv in fed_to_qsv
        for arg in argv
        if arg.endswith((".csv", ".tsv", ".schema.json", ".geojson"))
    }
    for asset in sorted(inputs):
        if f"`{asset}`" not in readme:
            problems.append(f"undocumented gallery input: `{asset}` is not mentioned in README.md")

    if problems:
        raise SystemExit(
            "gallery written, but README.md is now out of step with gen_gallery.py:\n  "
            + "\n  ".join(problems)
        )


def main():
    qsv = find_qsv()
    # before anything runs qsv: a stale cache from unrelated work would otherwise be reused and
    # silently change a figure (see `cleanup_sidecars`).
    stale = cleanup_sidecars()
    if stale:
        sys.stderr.write(
            f"swept {stale} pre-existing qsv sidecar(s) so figures regenerate from source\n"
        )
    # QSV_VIZ_REGEN_LLM opts into regenerating the `--dictionary infer` dashboards live (needs a
    # local LLM up); otherwise their committed HTML is reused so a normal run stays LLM-free.
    # Level 2 additionally forces the DICTIONARIES to be re-inferred -- see `regen_llm_level` and
    # the PREGENERATED block comment for why level 1 alone never reaches the LLM.
    regen_level = regen_llm_level()
    if regen_level >= 2:
        # Drives off module-level PREGENERATED, NOT the `pregenerated` local below: that local is
        # deliberately EMPTY in regen mode, so sweeping from it would delete nothing in the only
        # mode where the sweep matters.
        os.environ["QSV_VIZ_DICT_FRESH"] = "1"
        swept = 0
        for sidecar in llm_dictionary_sidecars():
            if os.path.exists(sidecar):
                os.unlink(sidecar)
                swept += 1
        sys.stderr.write(
            f"QSV_VIZ_REGEN_LLM=2: deleted {swept} inferred dictionary sidecar(s) and set "
            "QSV_VIZ_DICT_FRESH=1 -- dictionaries will be re-inferred from scratch\n"
        )
    pregenerated = set() if regen_level else PREGENERATED
    if not pregenerated:
        sys.stderr.write(
            f"QSV_VIZ_REGEN_LLM={regen_level}: regenerating LLM dashboards (local LLM required)\n")
    # reuse the existing scaffold verbatim: everything up to and including `<div class="grid">`,
    # minus any previous banner (re-added below so it stays a single, current copy)
    with open(GALLERY, encoding="utf-8") as fh:
        existing = fh.read()
    head = existing[: existing.index('<div class="grid">') + len('<div class="grid">')]
    head = re.sub(r"<!-- AUTO-GENERATED by examples/viz/gen_gallery\.py.*?-->\n?", "", head, flags=re.S)
    # Drop any prior third-party notice before re-adding the current one. The head is reused
    # verbatim from the previous gallery.html, which already carries this comment, so without
    # this the block would stack up one extra copy per regen.
    head = re.sub(r"<!-- Third-party software used by this page:.*?-->\n?", "", head, flags=re.S)
    head = head.replace("<!doctype html>\n", "<!doctype html>\n" + BANNER + "\n" + THIRD_PARTY_COMMENT + "\n", 1)
    # iframe styling for the embedded smart-Data Schematic pages. Drop any prior rule first (the head is
    # reused verbatim across runs) so the current DASH_CSS always wins.
    head = re.sub(r"\s*figure\.full iframe\.dash\{[^}]*\}", "", head)
    head = head.replace("</style>", " " + DASH_CSS + "\n</style>", 1)
    # per-figure command block styling (idempotent: drop all prior cmd rules before re-adding)
    head = re.sub(r"\s*figure (?:pre\.cmd|\.cmdbox|button\.copy)[^{]*\{[^}]*\}", "", head)
    head = head.replace("</style>", " " + CMD_CSS + "\n</style>", 1)
    # clickable-screenshot styling for the final "visual data dictionary" entry
    # (idempotent: drop any prior `figure a.shot` rules before re-adding)
    head = re.sub(r"\s*figure a\.shot[^{]*\{[^}]*\}", "", head)
    head = head.replace("</style>", " " + SHOT_CSS + "\n</style>", 1)
    # opaque backdrop when a reconstructed figure is sent fullscreen via its modebar button
    # (idempotent: drop any prior rule before re-adding)
    head = re.sub(r"\s*\.js-plotly-plot:fullscreen\{[^}]*\}", "", head)
    head = head.replace("</style>", " .js-plotly-plot:fullscreen{background:#fff}\n</style>", 1)
    # navigation + responsive CSS (idempotent: drop any prior marked block before re-adding)
    head = re.sub(r"\s*/\*qsv-nav\*/.*?/\*end-qsv-nav\*/", "", head, flags=re.S)
    head = head.replace("</style>", " /*qsv-nav*/" + NAV_CSS + "/*end-qsv-nav*/\n</style>", 1)
    # sticky "Jump to a chart" Table of Contents, generated from FIGURES so it always lists every
    # figure in order. Inserted right before the grid (idempotent: strip any prior copy first).
    head = re.sub(r'<details class="toc">.*?</details>\n', "", head, flags=re.S)
    toc_links = "".join(
        f'<a href="#{anchor_id(t)}">{html_escape(toc_label(t))}</a>' for (t, _d, _f, _a) in FIGURES)
    # final entries: the clickable screenshots / visual data dictionaries (link-outs, not charts)
    toc_links += "".join(
        f'<a href="#{anchor_id(s["title"])}">{html_escape(s["title"])}</a>' for s in SCREENSHOTS)
    dict_count = (f'{len(SCREENSHOTS)} data '
                  f'{"dictionary" if len(SCREENSHOTS) == 1 else "dictionaries"}')
    toc_html = (f'<details class="toc"><summary>Jump to a chart'
                f'<span class="toc-count">{len(FIGURES)} charts + {dict_count}</span></summary>'
                f'<div class="toc-links">{toc_links}</div></details>\n')
    head = head.replace('<div class="grid">', toc_html + '<div class="grid">', 1)

    figs, fig_divs, plots = [], [], []
    for idx, fig in enumerate(FIGURES):
        title, desc, full, args = fig
        gid = f"g{idx}"
        anchor = anchor_id(title)  # TOC deep-link target; distinct from gid (the inner plot div id)
        iframe_name = SMART_IFRAME.get(title)
        if iframe_name:
            # embed the genuine `qsv viz smart` output (CDN-slimmed) as a full-width iframe so the
            # real full-width overview panels, theme and map buttons render as the CLI produces them
            if iframe_name in pregenerated:
                # LLM-dependent (`--dictionary infer`): reuse the committed, already-cdnified HTML so
                # regen stays offline & deterministic (refresh it manually — see README commands).
                # Still refresh the injected reporter in place (idempotent) so the height/user-scroll
                # postMessage stays current across all dashboards, not just the regenerated ones.
                sys.stderr.write(f"[{idx}] {title}: reusing pre-generated {iframe_name}\n")
                pre_path = os.path.join(VIZ_DIR, iframe_name)
                with open(pre_path, encoding="utf-8") as fh:
                    existing = fh.read()
                # reused verbatim, so it never passed through run_html's check — validate it here,
                # else a Data Schematic refreshed without QSV_VIZ_NO_COMPRESS gets silently re-committed
                assert_plaintext(existing, f"pre-generated {iframe_name}")
                refreshed = inject_resize_reporter(existing)
                if refreshed != existing:
                    with open(pre_path, "w", encoding="utf-8") as fh:
                        fh.write(refreshed)
            else:
                sys.stderr.write(f"[{idx}] {title}: qsv viz {' '.join(args)} -> {iframe_name}\n")
                html = inject_resize_reporter(run_html(qsv, args))
                with open(os.path.join(VIZ_DIR, iframe_name), "w", encoding="utf-8") as fh:
                    fh.write(html)
            figs.append(None)  # keep FIGS index aligned with idx for the non-iframe figures
            fig_divs.append(
                f'<figure class="cell full" id="{anchor}">{figcaption_html(title, desc, args)}'
                # allow fullscreen so each Data Schematic's in-iframe Plotly "Fullscreen" modebar
                # button (gd.requestFullscreen()) isn't blocked by the iframe permissions policy.
                f'<iframe src="{iframe_name}" class="dash" scrolling="no" loading="lazy" '
                f'allowfullscreen allow="fullscreen" '
                f'title="{title}"></iframe></figure>'
            )
            continue
        sys.stderr.write(f"[{idx}] {title}: qsv viz {' '.join(args)}\n")
        result = run_fig(qsv, args)
        if "panels" in result:
            # inline multi-panel Data Schematic: store the panel list and render a nested sub-grid of
            # independent plots (one <div> + newPlot per panel) inside a single full-width cell
            panels = result["panels"]
            figs.append(panels)
            cols = grid_cols(args)
            cells = "".join(
                f'<div id="{gid}-p{k}" style="height:340px"></div>' for k in range(len(panels)))
            fig_divs.append(
                f'<figure class="cell full" id="{anchor}">{figcaption_html(title, desc, args)}'
                f'<div style="display:grid;grid-template-columns:repeat({cols},minmax(0,1fr));'
                f'gap:14px">{cells}</div></figure>'
            )
            for k in range(len(panels)):
                plots.append(
                    f'Plotly.newPlot("{gid}-p{k}", FIGS[{idx}][{k}].data, '
                    f'FIGS[{idx}][{k}].layout || {{}}, '
                    f'Object.assign({{responsive:true,scrollZoom:false,modeBarButtonsToAdd:[qsvFsBtn,qsvLegendBtn]}}, '
                    f'FIGS[{idx}][{k}].config || {{}}));'
                )
        else:
            figs.append(result["fig"])
            cls = "cell full" if full else "cell"
            fig_divs.append(
                f'<figure class="{cls}" id="{anchor}">{figcaption_html(title, desc, args)}'
                f'<div id="{gid}" class="plot"></div></figure>'
            )
            # animation frames live on the figure object but the positional newPlot(div,data,layout,
            # config) form drops them, so re-add via Plotly.addFrames once newPlot resolves (mirrors
            # the CLI's FULLSCREEN_SCRIPT enhance() fix). No-op for the non-animated figures.
            plots.append(
                f'Plotly.newPlot("{gid}", FIGS[{idx}].data, FIGS[{idx}].layout || {{}}, '
                f'Object.assign({{responsive:true,scrollZoom:false,modeBarButtonsToAdd:[qsvFsBtn,qsvLegendBtn]}}, '
                f'FIGS[{idx}].config || {{}})).then(function(){{'
                f'var fr=FIGS[{idx}].frames; if(fr&&fr.length)Plotly.addFrames("{gid}",fr);}});'
            )

    # Final, clickable-screenshot entries (full-width cells, no plotly figure): each opens its
    # standalone Data Schematic in its own popup window on click; target=_blank is the no-JS fallback and
    # the onclick's window.open gives the popup its own sized window, named per entry so opening one
    # Data Schematic never replaces another. NOT added to `figs`/`plots` — they carry no Plotly data or
    # newPlot call, so the FIGS array stays aligned with the charts.
    for shot in SCREENSHOTS:
        # entries with `args` are regenerated from a committed dataset (same helpers as the iframe
        # figures, so the linked page stays deterministic and plaintext-validated); the rest are
        # committed artifacts reused as-is.
        #
        # `cmd`-only is not a style choice — those entries CANNOT be regenerated here, because
        # their inputs are not in the repo (`boston311-2025.tsv` is 153 MB and `*.tsv*` is
        # gitignored; likewise `pittsburgh_311.tsv`). The consequence worth knowing: a change to
        # the data-viewer chrome refreshes every `args` page and silently leaves these behind on
        # the old one. `warn_stale_data_viewers` reports that at the end of a run rather than
        # letting it pass unnoticed; refreshing them needs the datasets and this entry's `cmd`.
        if shot.get("args"):
            sys.stderr.write(f"[shot] {shot['title']}: qsv viz {' '.join(shot['args'])} "
                             f"-> {shot['href']}\n")
            html = inject_resize_reporter(run_html(qsv, shot["args"]))
            with open(os.path.join(VIZ_DIR, shot["href"]), "w", encoding="utf-8") as fh:
                fh.write(html)
            cmd_tokens = ["qsv", "viz", *(shlex.quote(a) for a in shot["args"]), "-o", shot["href"]]
        else:
            cmd_tokens = shlex.split(shot["cmd"])
        shot_anchor = anchor_id(shot["title"])
        fig_divs.append(
            f'<figure class="cell full" id="{shot_anchor}">'
            f'<figcaption><span class="t">{shot["title"]}</span>'
            f'<span class="d">{shot["desc"]}</span>'
            f'{command_block_html(cmd_tokens)}</figcaption>'
            f'<a class="shot" href="{shot["href"]}" target="_blank" rel="noopener" '
            f'''onclick="window.open(this.href,'{shot["win"]}','popup,width=1280,height=900');'''
            f'''return false;" '''
            f'title="Open the full interactive Data Schematic in a new window">'
            f'<img src="{shot["image"]}" loading="lazy" '
            f'alt="{html_escape(shot["title"])} screenshot"/></a></figure>'
        )

    # The parcats "category order" animation, lifted from this run's own viz output. The figures
    # here are bare JSON reassembled under this scaffold, so without it the gallery's parcats
    # button would fall back to plotly's un-animated snap. Idempotent (the head is reused verbatim
    # across regens): drop any prior copy before re-adding, so it neither stacks nor goes stale.
    head = re.sub(re.escape(PARCATS_MARKER) + r".*?</script>\n?", "", head, flags=re.S)
    if PARCATS_SCRIPT:
        head = head.replace("</head>", PARCATS_SCRIPT + "\n</head>", 1)
    else:
        sys.stderr.write(
            "NOTE: this qsv emits no parcats category-order animation script; the gallery's "
            "parcats button keeps plotly's un-animated snap\n")

    figs_json = "const FIGS = [" + ",".join(
        json.dumps(f, ensure_ascii=False, separators=(",", ":")) for f in figs) + "];"
    body = (
        head + "\n"
        + "\n".join(fig_divs) + "\n"
        + "</div>\n<script>\n"
        + figs_json + "\n"
        + FS_BUTTON_JS + "\n"
        + "\n".join(plots) + "\n"
        + "</script>\n"
        + RESIZE_LISTENER_JS + "\n"
        + COPY_JS + "\n"
        + TOC_JS + "\n"
        + JUMP_JS + "\n"
        + THIRD_PARTY_STYLE + "\n"
        + THIRD_PARTY_FOOTER + "\n"
        + "</body></html>\n"
    )
    # Keep every plotly CDN <script> tag on this page — the gallery frame AND all embedded
    # dashboards — identical to what `qsv viz` emits today, which is version-pinned and carries
    # Subresource Integrity.
    #
    # Two things drift here. The gallery's <head> is reused verbatim across runs, so its own tag
    # never updated. And the LLM-backed dashboards are reused verbatim by design, so they keep
    # whatever tag they were generated with — leaving embedded pages loading plotly from a CDN with
    # no integrity check, which is exactly the exposure the SRI work closed for the regenerated
    # ones. Normalize both from viz's own output rather than hardcoding a second copy of the
    # version/digest in this script.
    CDN_TAG_RE = r'<script src="https://cdn\.plot\.ly/[^>]*></script>'
    # every page this script is responsible for besides the gallery frame: the embedded iframes plus
    # the screenshot link-out targets. The link-outs are not embedded, but they ARE committed here
    # (smart_nyc311.html is regenerated on every run and CDN-backed), so they get the same treatment.
    # A page carrying an inline plotly bundle instead (pitt311data.html, smart_boston_311_2025.html)
    # simply has no CDN tag to sync or check, so this is a no-op for it.
    linked_pages = sorted(set(SMART_IFRAME.values()) | {s["href"] for s in SCREENSHOTS})

    def _emitted_cdn_tag():
        # from a REGENERATED Data Schematic: those came from the qsv binary in this very run, so they
        # are the authority. The pre-generated ones are precisely what may be stale.
        for name in SMART_IFRAME.values():
            if name in pregenerated:
                continue
            path = os.path.join(VIZ_DIR, name)
            if not os.path.exists(path):
                continue
            with open(path, encoding="utf-8") as fh:
                m = re.search(CDN_TAG_RE, fh.read())
            if m:
                return m.group(0)
        return None

    emitted_tag = _emitted_cdn_tag()
    if emitted_tag:
        # the gallery frame itself
        body, n = re.subn(CDN_TAG_RE, emitted_tag, body, count=1)
        if n:
            sys.stderr.write(f"scaffold plotly tag synced to viz output: {emitted_tag}\n")
        # every embedded Data Schematic (including the reused LLM-backed ones) and every screenshot
        # link-out target. Only the <script> tag is touched — figure content, themes and layout are
        # left exactly as generated.
        for name in linked_pages:
            path = os.path.join(VIZ_DIR, name)
            if not os.path.exists(path):
                continue
            with open(path, encoding="utf-8") as fh:
                html = fh.read()
            patched, n = re.subn(CDN_TAG_RE, emitted_tag, html, count=1)
            if n and patched != html:
                with open(path, "w", encoding="utf-8") as fh:
                    fh.write(patched)
                sys.stderr.write(f"synced plotly CDN tag in {name}\n")

    # Fail closed: never commit a page that pulls plotly from a CDN without integrity. This catches
    # a Data Schematic added to SMART_IFRAME (or linked from SCREENSHOTS) but never regenerated, and any
    # future path that bypasses the sync above.
    unprotected = []
    for label, html in [("gallery.html", body)] + [
        (name, open(os.path.join(VIZ_DIR, name), encoding="utf-8").read())
        for name in linked_pages
        if os.path.exists(os.path.join(VIZ_DIR, name))
    ]:
        for tag in re.findall(CDN_TAG_RE, html):
            if "integrity=" not in tag or "crossorigin=" not in tag:
                unprotected.append(f"{label}: {tag}")
    if unprotected:
        raise SystemExit(
            "refusing to write the gallery: these pages load plotly from the CDN without "
            "Subresource Integrity:\n  " + "\n  ".join(unprotected))

    warn_stale_data_viewers(linked_pages)
    warn_stale_parcats_pages(linked_pages)

    with open(GALLERY, "w", encoding="utf-8") as fh:
        fh.write(body)
    cleanup_sidecars()
    sys.stderr.write(f"wrote {GALLERY} ({len(body)} bytes, {len(figs)} figures)\n")

    check_readme_claims()


if __name__ == "__main__":
    main()
