Visualization - dathere/qsv GitHub Wiki

Visualization

Tier: Intermediate Commands covered: viz

Note

Per-command flag reference lives in /docs/help/viz.md β€” the same text qsv viz --help prints in your terminal. This page is the workflow layer: when to reach for each chart, how viz smart builds a dashboard for you, and the reasoning behind the defaults.

viz turns a CSV/TSV straight into interactive Plotly charts β€” no notebook, no spreadsheet round-trip. Output is a self-contained HTML file (the Plotly runtime is embedded, so it works offline) written to --output or stdout. With a viz_static build you can export PNG / SVG / PDF / JPEG / WebP instead (needs a local Chrome/Chromium plus a matching chromedriver β€” see Static image export). Pass --open to pop the result straight into your browser. Interactive HTML charts carry two custom Plotly-modebar buttons: a β›Ά fullscreen toggle and a legend toggle that shows/hides the legend (handy when it crowds a dense plot). Both appear on every chart in both the single-chart HTML and the viz smart dashboard.

viz is behind the viz feature flag (✨) β€” it ships in the prebuilt qsv binaries but not in qsvlite or qsvdp. See Binary Variants.

Important

22.0.1 is the first release to ship viz at all. The command landed just after 21.1.0 was tagged, so no earlier release contains it β€” and if you are on a "p for portable" or qsvpy binary from an older release, it silently lacked viz even though the release notes of the day didn't say so. 22.0.1 enables viz across the little-endian publish targets.

viz does not work on big-endian targets (s390x, ppc64 BE) β€” that combination is now a hard compile_error! rather than a binary that builds clean and misbehaves. (powerpc64LE is little-endian, so the guard doesn't fire there; viz is simply not enabled for that prebuilt.)

Contents

Section Jump to
Gallery Β· Quick decision table every chart type, rendered
viz smart β€” the Data Schematic panels Β· skew Β· moarstats Β· --bivariate Β· animation Β· photos Β· distribution panels Β· downsampling Β· data viewer Β· guided Tour Β· languages
The Data Dictionary panel selection Β· hand-tuning Β· KPI row Β· units & money Β· denominators Β· pipelines Β· --dict-info
Chart subcommands animation, range & annotation Β· hierarchy charts
Maps & geography point maps Β· choropleths Β· Census boundaries Β· --check-geojson-key Β· point-in-polygon Β· rate maps
Theming Β· Static image export Β· Output & file size

Gallery

A single page rendering every chart type below, generated from the sample datasets in examples/viz/:

The sunburst figures (the standalone sunburst and the --dictionary infer, sunburst dashboard) render their in-sector labels radially β€” running along each ring's spoke β€” a plotly.js 3.6 refinement that keeps deep-path labels legible instead of clipping them tangentially.

Note

The gallery is served from GitHub Pages, published from examples/viz/ by a workflow on every push to master. It renders with the correct text/html type, so the charts and the embedded smart-dashboard iframes load directly β€” no third-party proxy. The raw.githubusercontent.com link above serves .html as text/plain (shows source), so use it only to view or download the file.

Quick decision table

If you want to… Use Notes
A one-shot overview of an unfamiliar dataset viz smart Builds a neuro-symbolic Data Schematic
Compare a measure across categories viz bar --agg sum|mean|count|min|max when x repeats
Show a trend over time / an ordered x viz line x is sorted numerically/temporally
Relate two numeric columns viz scatter optional --series to color by group
Relate three numeric columns viz scatter3d 3D scatter; --color/--size encodings
See the 2D density of two numerics viz contour binned density grid; --bins
See the distribution of one numeric column viz histogram --bins (default auto)
Summarize spread (quartiles, outliers) viz box optional grouping x column
See distribution shape (modes, shoulders) + spread viz violin box + KDE density silhouette; same inputs as box
Show parts of a whole viz pie --donut for a ring; --y value or counts
Show a nested part-to-whole as sized tiles viz treemap nested rectangles β€” area encodes size (best for size comparison); --cols dim1,dim2… (levels, outer first), optional --value/--agg
Show a nested hierarchy as rings viz sunburst concentric rings β€” emphasize parent-child structure (best for deeper paths); same inputs as treemap
Show a nested hierarchy as level-aligned bars viz icicle stacked bars per level β€” level-aligned, good for deep, wide hierarchies; same inputs as treemap
Spot correlations across many numerics viz heatmap Pearson matrix (RdBu, βˆ’1…1) β€” no --x/--y/--z
See every pairwise scatter at once viz splom scatter-plot matrix over --cols (default: all numeric) in an NΓ—N grid with shared axes
Trace how categories co-occur viz parcats parallel-categories flow β€” ribbons sized by row count; --cols dim1,dim2… (best with 3-4)
Cross-tabulate two categories by a measure viz heatmap give --x, --y and --z (pivot mode)
Plot OHLC price action viz candlestick / viz ohlc --ohlc-open --high --low --close
Show flows between nodes viz sankey --source --target [--value]
Compare entities across many axes viz radar --cols a,b,c… + optional --series
Plot lat/lon points on a map viz map tile basemap; --color/--size encodings or --density heatmap
Plot lat/lon on an offline projection map viz geo no tiles/token; --projection; same encodings as map
Fill whole regions by a value viz choropleth countries (ISO-3) / US states built-in, or custom --geojson; --geocode derives codes from lat/lon
Bin lat/lon points into custom regions viz choropleth point-in-polygon into a --geojson, colored by count or a --value measure
Map US counties/ZCTAs/tracts/places without a GeoJSON viz choropleth --geojson auto fetches Census TIGERweb boundaries β€” details (23.0.1)
Map US regions named by county or city name, not code viz choropleth --geojson auto (+ --region-state for counties, --geocode for cities) β€” details (23.0.1)
Chart a rate instead of a raw count viz choropleth --denominator-key / --denominator / --denominator census β€” details (23.0.1)
Find out why a choropleth shades nothing viz choropleth --check-geojson-key ranks every candidate --feature-id-key β€” details (23.0.1)
Narrate a dashboard for a specific audience viz smart --tour-audience "the City Council", --tour-steps <n> β€” details (23.0.1)

viz smart β€” the Data Schematic

viz smart is the command; what it produces is a Data Schematic β€” a single, self-contained rendering of a dataset's schema and statistics in which every claim shown is checkable against the data it describes.

Where a data dictionary lists fields one at a time, alphabetically and unordered, a schematic shows components and how they connect: measures that correlate, stages that follow one another in a process, dimensions that nest into a hierarchy, a date column that paces a numeric one, a latitude that is meaningless without its longitude. That's the part a dictionary has nowhere to put β€” and the part a schematic makes falsifiable, since anyone with the file in front of them can refute a wrong drawing. The format is tool-neutral and specified in docs/DATA_SCHEMATIC.md; viz smart is qsv's implementation of it.

It is neuro-symbolic by construction. Deterministic, reproducible statistics and heuristics decide what gets drawn and what the numbers are; an LLM handles only what computation cannot β€” classifying each field against a shared concept vocabulary, world knowledge, translation. Because that drafted schema is saved as an editable sidecar (see Hand-tuning the Data Dictionary), a data steward or curator can ratify or correct anything the LLM proposed and re-render from the corrected schema.

smart profiles the dataset from qsv's stats cache (πŸͺ„) and lays out one panel per column in a grid:

  • KPI overview row β€” when the dataset has headline numeric measures, the dashboard leads with a strip of "big number" tiles, one per measure (summed for extensive quantities, averaged for intensive ones). With a --dictionary, two optional per-field hints in the field's x-qsv object upgrade a tile: a validated x-qsv.gauge_range [min, max] that contains the value renders it as a GAUGE on that canonical scale (e.g. [0,1] for a ratio, [0,100] for a percent), and an x-qsv.target number adds a "vs target" DELTA (value βˆ’ target). describegpt --dictionary now emits gauge_range for canonical-scale measures; target is a goal you hand-author (never fabricated). Overall dataset completeness stays a quiet "Completeness:" line in the header metadata, not a tile. Omitted for static image exports and for datasets with no headline measure.
  • Correlation heatmap β€” a Pearson matrix when there are 2+ continuous numeric columns, plus a correlated-pair drill-down of the strongest pair beside it: a scatter, or β€” for large datasets where a scatter would overplot β€” a 2D density contour (which embeds only a fixed bin grid). With 3+ numeric columns, a 3D scatter of the strongest-correlation triple is added as well.
  • Time-series trend β€” a line of the first continuous numeric column over a detected date/datetime column. (New in 24.0.0: with a --dictionary, a column of 4-digit years tagged as time β€” e.g. Program_Year, concept time.date β€” also serves as a yearly axis; stats types bare years as Integer, not Date.)
  • Seasonality rings β€” a polar panel that scores hour of day, day of week and month of year from the date/datetime column, and draws a ring for every cycle that is real, strongest first; each slot is plotted relative to its local average. A dataset with no real cycle gets no panel. HTML only. (New in 24.0.0 β€” on 23.0.1 and earlier the panel folded onto one cycle chosen by column type and plotted raw counts without testing the cycle; see Recent viz changes (24.0.0).)
  • Part-to-whole hierarchy β€” when 2+ low-cardinality dimensions are present, a full-width treemap (shallow 2-level hierarchy β€” area encodes size), sunburst (deeper 3-level hierarchy β€” concentric rings), or icicle (level-aligned stacked bars) nesting them. auto follows best practice for the depth; override with --hierarchy-style auto|treemap|sunburst|icicle. A panel is only auto-built when the dimensions are actually associated (bias-corrected CramΓ©r's V β‰₯ 0.10 across every pair) β€” nesting statistically independent categoricals just replicates each level's marginal at every branch and tells you nothing the separate frequency bars don't, so that case is skipped with a note on stderr. An explicit --hierarchy-style treemap|sunburst|icicle bypasses the screen and forces the panel.
  • Pipeline funnel β€” a declared process (planned β†’ committed β†’ spent, or Impression β†’ Click β†’ Lead β†’ Conversion) drawn as a single panel instead of leaving the drop-off scattered across three unrelated box titles as "% zeros" annotations. This panel's only source is a --dictionary: a dataset-level x-qsv.relationships entry with "kind": "pipeline", in either of two hand-editable encodings β€” stages as columns (members lists the stage columns in process order, widest/upstream first) or stages as row values (stage_column names the category column, stages lists its values in order, optional value_column names the measure to sum). Declared order is authoritative and is never re-sorted by size, so a stage that outruns its predecessor stays visible: the panel switches to a bridge rather than drawing a funnel that widens. A declaration naming a missing column, or a stage that is an average/rate rather than a summable amount, is skipped with a note instead of erroring.
  • Geographic map β€” when a latitude/longitude column pair is detected (by header name + numeric type + plausible coordinate ranges), a map leads the dashboard on a full-width row: a MapLibre tile map for a local extent, or an offline ScatterGeo projection world-overview (no tiles or token) when the coordinates span a continental/global area. Points far from the cluster centroid (beyond the Tukey far-out fence of all point-to-centroid distances) are flagged as geographic outliers β€” drawn with a distinct marker and excluded from the core extent, so a few strays don't inflate the zoom. With the geocode feature, the core extent's bounding box is reverse-geocoded against the local Geonames index and drawn as a labeled box plus a location summary (e.g. "New York & New Jersey, United States"); when outliers exist, a second dotted box marks the full extent and the HTML map gains "Core extent" / "Full extent" buttons to jump between views. In static image export the map renders as an offline ScatterGeo projection fit to the data extent (US-spanning data uses albers-usa) β€” only the MapLibre tile map and 3D scatter stay HTML-only. A dense point map opens as individual points with an in-map "Clusters/Points" toggle that collapses crowded areas into native MapLibre count bubbles (expanding back to hoverable points on zoom-in); --cluster auto|on|off controls when the toggle is offered (auto: markers-only cores past ~1,000 points), and --heatmap-density <n> opts back into a static DensityMap heat surface at or above <n> points instead.
  • Geographic choropleth β€” a companion panel to the point map. When the lat/lon points reverse-geocode to two or more regions, smart adds a filled-region panel: a per-US-state fill when every point lands in the United States, otherwise a per-country (ISO-3) fill, framed to the filled geometries via Plotly fitbounds so regions are never clipped. This panel needs the geocode feature (bundled in the prebuilt qsv/qsvpy binaries and any all_features build); a minimal viz-only build shows just the point map. Supply a custom --geojson (with --feature-id-key) to instead bin each point into the polygon that contains it β€” point-in-polygon, exact, no geocoding β€” colored by row count or a --value/--agg measure. Points falling outside every region snap to the nearest one within --snap-max-dist or are dropped (--no-snap); the dropped-point count is reported in the panel title. See point-in-polygon binning for how the snap cap is derived. Metro-scale region sets render on a token-free MapLibre tile basemap; country/continental sets use the offline projection. With no --lat/--lon at all, supplying a --geojson plus a --dictionary that tags a column as a geographic region code (recognized concepts include zip_code/zip/postal_code, census_tract, county/county_fips, state/state_fips, country, and fips) leads the dashboard with summary choropleths built by aggregating rows on that code β€” a count-per-region fill, plus a median-of-measure fill when the dataset has a measure column β€” matching the codes to feature ids (zero-padding short zips to 5 digits).
  • Distribution panels (violins by default) β€” for continuous numeric columns. By default (--violin auto) each panel is a violin: a KDE density silhouette wrapped around an inner quartile box + mean line, revealing shape (modes, shoulders) that a plain box hides. Two kinds of column keep an honest box instead: those on a logarithmic value axis (Plotly estimates the KDE in linear space, which a log axis would distort) and those with fewer than 20 distinct values (too few for a meaningful KDE β€” the curve turns into a comb). Confirmed-bimodal columns still route to a histogram first (see moarstats below). Force the choice with --violin auto|on|off: on violins even the log/low-cardinality columns; off keeps every distribution panel a box.
    • Point overlay follows a size heuristic measured against each column's non-null value count (not the dataset row count): every point for small columns (≀ 1,000 values), Tukey outliers for medium (≀ 10,000), and none above that (a box then stays a fast cache-only quartile summary). In a dense grid (> 8 panels) an all-points overlay demotes to outliers-only, where clouds turn to noise. Smart violins cap their overlay at outliers regardless (the KDE already shows what an all-points cloud would). Pass --box-points outliers|all|suspected|none to force a mode (accepted by smart, not just box/violin).
    • --box-points none only hides the points, not the panel: a violin still renders (its KDE needs the raw values anyway). The cache-only escape hatch is --violin off --box-points none.
  • Frequency bars β€” for low-cardinality categoricals, booleans, and ratings. Each bar chart shows the top-N categories (--limit, default 10) plus two aggregate buckets that mirror qsv frequency: a (NULL) bar for empty cells and an Other (N) bar collecting the categories beyond the limit (N = how many distinct values were rolled up). Both are drawn in muted grey to read as summaries, set apart from the palette-colored real categories, and are shown by default β€” suppress them with --no-nulls / --no-other.

When one bucket dwarfs the rest (e.g. a dominant (NULL) or Other), the panel switches its y-axis to a logarithmic scale so the small categories stay readable. This is controlled by --log-scale auto|on|off (default auto, which kicks in only on high-dynamic-range panels; on forces it on every frequency panel, off keeps everything linear). A log panel carries two redundant cues so it's never mistaken for linear: a count (log) y-axis title, and diagonal hatching on the muted-grey (NULL)/Other bars.

ID-like (near-unique) and high-cardinality text columns are reported and skipped. (24.0.0+: a near-unique Float is treated as a measure, not an ID.) By default the dashboard fits the data (up to 64 panels) for both HTML and static image export: up to 8 cartesian panels render as one typed subplot grid; beyond 8, HTML switches to an inline-div grid of independent plots and static export uses domain-positioned axes to fit them all in a single image. Cap the count with --max-charts.

Skew-aware encoding πŸͺ„

With --smarter (see moarstats-informed dashboards below, or a pre-extended stats cache), smart reacts to a measure's skew rather than plotting it flat:

  • Inequality panels β€” for a heavily concentrated additive measure (spend, payments, population), smart adds a Lorenz curve with its Gini coefficient, which says "how unequally is this distributed?" in a way a box plot can't. (New in 24.0.0: the panel also shows the Hoover index β€” the share of the total that would have to move to reach equality.)
  • Log-scale boxes β€” a right-skewed money-like column draws its distribution panel on a log axis so the bulk of the data isn't crushed against zero by a long tail, with an explicit on-chart cue that the axis is logarithmic.

examples/viz/cms_medicare_providers.csv (30,007 Medicare practitioners) is the worked example β€” see the gallery.

moarstats-informed dashboards πŸͺ„

If you run moarstats before viz smart, the dashboard reads the extended statistics it adds to the stats cache and makes better chart choices. Without a moarstats run the behavior is unchanged β€” the extra stats are simply absent, so smart falls back to its defaults.

Tip

Pass --smarter to do this in one step β€” viz smart runs moarstats --advanced for you (one extra pass over the data, writing the .stats.csv sidecars + an .idx index) before building the dashboard.

  • Bimodal β†’ histogram. A continuous column flagged bimodal/multimodal (high bimodality coefficient) renders as a histogram instead of a box plot β€” a box plot summarizes by quartiles and would hide the separate peaks. Plain viz smart computes the bimodality coefficient itself from exact moments, the same way moarstats --advanced does, so --smarter isn't needed for this (24.0.0+; on 23.0.1 and earlier it needed moarstats --advanced).
  • Box shape hints. Box panels are annotated with the column's skew direction and outlier share β€” e.g. account_age_days (right-skewed, 4.7% outliers) β€” from the moment skewness (G1, cut at 0.5) and outlier-percentage stats, and titles show as many hints as fit, listing the rest on hover. These work from a plain qsv moarstats run. (24.0.0+; 23.0.1 and earlier used Pearson's second skewness coefficient and showed at most two hints β€” see Recent viz changes (24.0.0).)
  • Concentrated categoricals. A high-cardinality categorical that would normally be skipped as ID-like noise is kept as a top-N bar when its normalized entropy is low (a few dominant categories rather than near-uniform noise).
  • Benford first-digit panels (25.0.0). An additive measure that passes moarstats' Benford gate (at least 100 non-zero values spanning two or more orders of magnitude) gets a panel comparing how often each leading digit 1–9 occurs with what Benford's law predicts. The title gives the cached benford_mad and Nigrini's verdict: close (< 0.006), acceptable (< 0.012), marginal (< 0.015) or nonconforming. A deviation is a reason to look closer, not proof of error. At most two per page, least conforming first. Needs moarstats --advanced.
  • File-order drift hint (25.0.0). A box/violin title adds "drifts in file order" when a measure's lag-1 autocorrelation (moarstats --advanced) is 0.5 or more: the rows are sorted by it, trend, or were appended in batches. It is left out when the file runs in the order of the charted date column, because the time-series panel already shows that trend.
  • Fit line on the strongest-pair scatter (25.0.0). Not a moarstats feature: the least-squares line is computed from the plotted points, so it appears with or without --smarter. Its hover gives the equation and rΒ². It is left off log axes, where a straight line would render as a curve.
# one step: --smarter runs moarstats --advanced for you first
qsv viz smart customer_spend.csv --smarter -o spend_dashboard.html

# or do it manually β€” extend the stats cache, then let viz smart reuse it
qsv moarstats --advanced customer_spend.csv
qsv viz smart customer_spend.csv -o spend_dashboard.html
# monthly_spend (bimodal) -> histogram; account_age_days (skewed) -> annotated box

The sample above uses customer_spend.csv (300 customers with a bimodal monthly_spend and a right-skewed account_age_days). Under the hood, moarstats regenerates the stats cache with the extra columns and viz smart reuses it β€” see Stats Cache & Caching.

Pairwise associations β€” --bivariate

The correlation heatmap is Pearson-only, so it sees numeric columns and nothing else. --bivariate adds two overview panels driven by qsv moarstats --bivariate that work across numeric and categorical columns:

  • a normalized mutual information (NMI) heatmap over every column pair, and
  • a ranked "top relationships" bar of the strongest pairs, added when there are more than 8 chartable columns.
qsv viz smart permits.csv --bivariate -o permits.html

Three things worth knowing:

  • The ranked bar has a support floor; the heatmap does not. A pair must have a co-occurring row count of at least 10% of the best-supported pair's to appear in the bar β€” otherwise two sparsely-populated columns that only ever co-occur in a narrow row slice could post a technically-perfect NMI and crowd out a broader, more meaningful association. Each bar's hover shows its co-occurring row count. The heatmap still shows every pair.
  • Nonlinearity is flagged. A numeric pair whose Spearman rank correlation diverges sharply from its Pearson correlation is marked nonlinear in the hover β€” the case the Pearson heatmap alone would under-report.
  • It turns other things on. --bivariate implies --smarter, and when --dictionary isn't already set it also implies --dictionary infer, so the identifier / PII / free-text exclusion has semantic signal to work with. Map --lat/--lon columns are excluded too.

Note

Capped at 50 columns (1,225 pairs). Wider datasets skip both panels with a warning β€” run qsv moarstats --bivariate directly for the full pairwise output.

Judicious animated panels

smart may add at most one animated panel per dashboard, and only when the motion is genuinely informative. When several qualify, the priority is: a Gapminder-style entity-bubble chart, then an animated geo reveal of dated global-extent points, then a pair-drift scatter (the numeric pair whose per-time-bucket centroid path bends the most, colored by time bucket).

Control it with --slider auto|on|off β€” auto (the default) requires a strong signal (a date column and enough time buckets), on lowers the bar to as few as two buckets but still requires a genuinely drifting relationship, and off never animates. Axis ranges are pinned across frames so nothing jumps.

Photo hovers on the map panel

When a column holds image URLs (detected from the stats cache β€” an http(s) value ending in a common image extension), --photos turns the smart map panel into a visual browse of the underlying records: dwell on a point for 2 seconds and that row's photo opens in a small preview card beside the pointer. Arrow keys page through a row with several photos, Escape dismisses the card, and points that have photos say so in their hover label. Where the image host allows cross-origin reads, fetched images are cached in the browser's IndexedDB so re-dwelling or reloading serves them locally; otherwise the preview falls back to an ordinary image load.

Warning

--photos is off by default, deliberately. Images load from whatever third-party host the data names, so enabling it makes everyone who opens the dashboard request those URLs directly β€” revealing their IP address to that host. Nothing is fetched until a viewer dwells on a point. Embedding every mapped point's URLs also grows the HTML. Applies to HTML output only (static exports are unaffected), and only on the tile-basemap map panel.

Distribution panels: how many points get drawn

Two flags govern the distribution panels, and they interact. --violin auto|on|off picks the panel shape; --box-points outliers|all|suspected|none picks the point overlay.

Reading the raw values is what lets Plotly draw true Tukey whiskers (1.5Γ—IQR) with the out-of-fence points as outliers β€” so the overlay decision is also a data-access decision, and viz smart defaults to a size heuristic measured against each column's non-null value count (not the dataset row count):

Non-null values in the column Default overlay Data access
≀ 1,000 every point, jittered one batched pass
≀ 10,000 outliers only one batched pass
> 10,000, column has outliers outliers on a precomputed quartile box one pass collecting only out-of-fence values, capped
> 10,000, column has no outliers none β€” cache-only quartile summary no data re-scan

Two modifiers to that table:

  • In a dense grid (more than 8 panels) an all-points overlay demotes to outliers-only β€” at that size an all-points cloud is noise.
  • A smart violin panel skips the all upgrade entirely and overlays only the outliers unless you ask otherwise: its KDE silhouette already shows what the cloud would.

An explicit --box-points mode overrides the heuristic and is applied to every panel (one batched pass to read the values). The exception is none, which keeps the cache-only box for box panels.

Violin sizing

--violin auto (the default) draws a violin for every continuous column except two kinds that keep an honest box: those on a logarithmic value axis (Plotly estimates the KDE in linear space, which a log axis would geometrically distort) and those with fewer than 20 distinct values or fewer than 50 non-null observations (too few for a meaningful KDE β€” the curve would be smoothing artifact). on violins those too; off never draws one. KDEs are clamped to each column's observed range (Plotly spanmode: "hard"), so a bounded or discrete measure can't show density past a natural limit.

Non-null values Violin
≀ 10,000 exact violin, with the point overlay
≀ 30,000 exact violin, no point overlay
> 30,000 silhouette + inner box from a deterministic evenly-strided sample of at most 30,000 values, no overlay, titled "(sampled)"

The 30,000 figure is one-fifth of the smart point budget, so it scales with QSV_VIZ_MAX_POINTS. A strided sample misses the true extremes, which is why a sampled violin carries no point overlay and says so in its title.

Tip

--box-points none hides points, it does not cache-only the panel. A violin still renders under none β€” its KDE needs the raw values anyway. The genuine cache-only escape hatch is --violin off --box-points none.

Data size & downsampling

To keep the self-contained HTML small and fast even on millions of rows, the point-heavy viz smart panels embed at most 150,000 points per series (tunable with QSV_VIZ_MAX_POINTS). Panels that summarize rather than plot raw points are unaffected: box plots read quartiles straight from the stats cache (the point overlay follows its own size heuristic β€” see above), frequency bars embed category counts, the correlation heatmap embeds a fixed coefficient matrix, and the density contour embeds a fixed bin grid. Two algorithms do the thinning, picked by panel:

Panel When it downsamples Algorithm
Time-series trend (line over a date/datetime) > 150,000 points LTTB (Largest-Triangle-Three-Buckets)
Scatter drill-down pair, 3D scatter triple, map/geo lat-lon points, histogram source values > 150,000 points Uniform stride (every n-th row)
Violin distribution panel > 10,000 non-null values Uniform stride to ≀ 10,000 values (silhouette + inner box only, no point overlay, panel titled "(sampled)")

Unlike a cache-only box, a violin needs the raw values to estimate its KDE, so it embeds them: a column with ≀ 10,000 non-null values draws an exact violin; a larger one draws its silhouette and inner box from a deterministic stride sample of at most 10,000 values (β‰ˆ 70 KB/panel, bounded memory). Because a strided sample misses the true extremes, a sampled violin carries no point overlay and is titled (sampled).

  • LTTB is shape-preserving: it selects points by largest triangle area in (x, y) space, so brief spikes and peaks survive instead of being stepped over, and the earliest and latest observation are always kept. It applies only to the time-series panel, whose x-axis is chronologically sorted (LTTB requires monotonic x). The algorithm is based on the paper Downsampling Time Series for Visual Representation.
  • Uniform stride keeps every n-th row (endpoint-inclusive, so the first and last row are retained). It's used where x isn't ordered β€” a scatter cloud has no monotonic axis β€” and where two columns must share the same row indices (e.g. a bubble chart's color and size encodings).

Note

Downsampling is a viz smart behavior only. The explicit chart subcommands (viz scatter, viz line, viz histogram, …) plot every row you give them β€” cap the input yourself with sample, slice, or a sqlp LIMIT if a single chart gets too heavy.

# auto-fit dashboard for an unfamiliar file
qsv viz smart sales_sample.csv -o dashboard.html

# cap panels, lay out 3 columns, top-5 categories per frequency bar
qsv viz smart sales_sample.csv --max-charts 6 --grid-cols 3 --limit 5 -o dashboard.html

The sample above uses sales_sample.csv (500 e-commerce orders, designed so the correlation heatmap spans the full red→blue scale).

The data viewer drawer

New in 22.0.1. A Data Schematic is explorable, not just viewable β€” the data behind the charts ships in the same file.

Next to the row count in the schematic's metadata header sits a link: "(Explore)" when every row is embedded, or "(Preview)" when only the first N are. Either opens the underlying table in a bottom drawer, mirroring the Data Dictionary drawer. It's built on vendored DataTables running vanilla β€” no jQuery β€” with CSV export, correct date sorting, global search, and per-column search widgets via DataTables ColumnControl.

--preview-threshold <n> decides which link you get (default 50,000):

# default: ≀ 50,000 rows embeds every row β†’ "(Explore)"
qsv viz smart data.csv -o schematic.html

# embed at most 5,000 rows β†’ "(Preview)" on anything larger
qsv viz smart data.csv --preview-threshold 5000 -o schematic.html

# disable the viewer entirely β€” no link, no embedded data
qsv viz smart data.csv --preview-threshold 0 -o schematic.html

Note

Embedded rows grow the HTML file (and browser memory) in proportion to rows Γ— columns. --preview-threshold is the knob when a schematic gets too heavy to share. HTML output only β€” static image exports are unaffected.

Rows and map points are cross-linked

Where the schematic has a map panel, the drawer and the map talk to each other in both directions: clicking a table row highlights its point on the map, and clicking a map point reveals its row β€” each scrolling the counterpart into view. Rows with no plottable coordinates are pinned rather than left silently unreachable. Clicking a region on a summary choropleth filters the drawer to just that region's rows.

The guided Tour

A Data Schematic is a storytelling dictionary, but a first-time reader used to land on a wall of panels with no narrator β€” nothing explained that the dictionary drawer exists, that the data viewer has three complementary search surfaces, or why each panel was chosen.

Every viz smart HTML page now ships an interactive guided tour: auto-started the first time a reader opens the page, and relaunchable anytime from the Tour button. It walks the Data Dictionary drawer, the data viewer and the summary panels, explaining why each chart suits this data. Alongside it, the page's three pills β€” the Data Schematic dictionary drawer, the Tour relaunch pill, and the Explore/Preview data viewer in the metadata table's Rows row β€” pulse once on a reader's first visit.

Flag Effect
--tour-steps <n> How many chart panels the tour spotlights (default 8). Budgets the per-panel steps only β€” the introduction, dictionary chapter, data-viewer chapter and conclusion are always included. --tour-steps 0 suppresses the tour entirely: no tour, no Tour button, no first-visit hint beacons
--tour-audience <text> Who the tour talks to

--tour-audience is forwarded to describegpt when --dictionary infer generates the dictionary, so the inferred dictionary carries an x-qsv.tour narration written for that audience. It defaults to "Explain like I'm 10", but the same dataset will happily brief "the mayor", "the City Council", "my 11th grade Civics class", "my grandma" or "an urban planner" β€” one flag, and the numbers stay identical while the story around them is re-pitched. Pair it with --language for a non-English narration.

Important

The audience shapes the tour prose ONLY. Field labels and descriptions keep their normal register, so re-pitching the narration never quietly rewrites the data dictionary.

Warning

--tour-audience is ignored unless a FRESH --dictionary infer actually runs. When an existing <stem>.schema.json sidecar is reused, the narration already baked into it is reused too. Delete the sidecar (or set QSV_VIZ_DICT_FRESH=1) to re-infer with a new audience. smart only.

Refining the narration: x-qsv.tour

A dictionary's dataset-level x-qsv object may carry a tour object that refines what the tour says, without touching what it draws:

Key Effect
overrides Replaces step prose by step id
panels Replaces per-panel explanations, keyed by raw field name, or by an @kind token (@kpi, @correlation, @map, …) for overview panels
panel_order Picks which panels the tour spotlights, in order

panel_order is still capped by --tour-steps, and is never amended by the frequency-distribution guarantee described above.

"x-qsv": {
  "tour": {
    "overrides": { "intro": "This file tracks every building permit issued in 2024." },
    "panels": {
      "@kpi": "The headline totals β€” start here.",
      "permit_type": "Most permits are alterations, not new construction."
    },
    "panel_order": ["@kpi", "permit_type", "@map"]
  }
}

Localized schematics

New in 22.0.1. A Data Schematic renders in the dataset's own language β€” not just the UI chrome, but the chart strings and coverage notes too.

The language is resolved once, with explicit intent winning over inference:

  1. --language <lang> β€” a BCP-47/ISO 639-1 tag (es), an ISO 639-3 code (spa, which is what describegpt's detection pass emits), or an English language name (Spanish).
  2. The dictionary's x-qsv.detected_language_code β€” describegpt detects the dataset's content language locally with whatlang, deterministically and at zero token cost.
  3. English, otherwise.
# force Spanish, regardless of what the data looks like
qsv viz smart datos.csv --language es -o esquema.html

# let the dictionary's detected language drive it
qsv viz smart datos.csv --dictionary datos.schema.json -o esquema.html

Curated coverage is eight languages: en, es, fr, de, it, pt-BR, ja, zh-CN. --language is also forwarded to describegpt when inferring a dictionary, so a Spanish dataset gets a Spanish dictionary and a Spanish schematic without being asked twice.

The Data Dictionary

viz smart works from statistics alone by default. A describegpt data dictionary layers semantics on top β€” what each field means β€” and it is an ordinary JSON Schema file you can edit by hand. Everything in this section is hand-editable: edit, re-run, and the Data Schematic changes with no LLM and no code.

Dictionary-guided panel selection (EXPERIMENTAL)

By default viz smart routes each column to a panel from its statistics alone. Passing --dictionary layers in a describegpt data dictionary so panels are chosen from each field's semantic role instead of (just) its stats: dimensions and numeric codes (ward, census_tract, zone) become bars, measures get box / correlation / trend panels, date & datetime columns feed the time-series (not noisy frequency bars), identifiers / PII / free-text are skipped, and lat/lon feed the map. The dictionary's field labels become panel titles. Columns the dictionary can't classify still fall back to the statistical heuristic.

--dictionary <src> takes either:

  • infer β€” run describegpt on the input now and use its output. Needs a reachable LLM (set QSV_LLM_MODEL, and QSV_LLM_BASE_URL / QSV_LLM_APIKEY as needed). Add --dictionary-context <file> (a glossary, README, data dictionary, or PDF) to feed describegpt extra context β€” better context yields better role/label tags, hence a better dashboard.
  • a path to an existing describegpt dictionary file (jsonschema or json) β€” reused as-is, no LLM call.

Dictionary generation or read failures soft-fall back to the stats-only dashboard, so it's safe to leave on.

# infer a data dictionary with a local LLM, then build a semantics-guided dashboard
qsv viz smart customer_spend.csv --dictionary infer -o spend_dashboard.html

# reuse an existing dictionary file (no LLM call)
qsv viz smart customer_spend.csv --dictionary customer_spend.schema.json -o spend_dashboard.html

Hand-tuning the Data Dictionary

The dictionary --dictionary feeds viz smart is not a black box β€” it's an ordinary JSON Schema file you can edit by hand. Every column is a property carrying an x-qsv object, and a few of its keys decide which chart the column gets and which measures headline the KPI row. Edit them, re-run, and the dashboard changes β€” no LLM, no code.

1. Get an editable schema. Three ways to land a <stem>.schema.json you can edit:

# A) let viz smart infer one β€” it's saved beside the input as customer_spend.schema.json
qsv viz smart customer_spend.csv --dictionary infer --dict-info -o dash.html
#    re-running REUSES that file as-is (edit it to tune; delete it to re-infer)

# B) or generate it directly with describegpt
qsv describegpt customer_spend.csv --dictionary --format jsonschema -o customer_spend.schema.json

# C) from a SAS/Stata/SPSS file's own metadata β€” no LLM (24.0.0+)
qsv readstat survey.sav --dictionary survey.schema.json -o survey.csv

With C), variable labels become titles, value labels and declared missing values go under x-qsv, and SPSS measure becomes the x-qsv.role viz charts by. Named <stem>.schema.json after the CSV, viz smart finds it on its own β€” see readstat.

Open the result with --dict-info to see how each column was classified (a "Data Dictionary" link plus a per-panel info icon), then edit the schema and re-run with --dictionary customer_spend.schema.json.

2. Know the precedence. viz smart picks each column's panel from the first signal that resolves, in this order:

concept β†’ role β†’ content_type β†’ column statistics

So to override a column, set its most specific signal β€” usually concept, else role. All three live in that column's x-qsv:

"properties": {
  "monthly_spend": {
    "type": "number",
    "x-qsv": {
      "role": "measure",
      "concept": "measure.amount",
      "target": 175
    }
  }
}

role β€” the coarse classifier

A closed set of four values:

role Panel
dimension frequency bar
measure box / violin / histogram β€” and KPI-eligible (see below)
identifier skipped
timestamp time-series line

concept β€” the specific classifier (wins over role)

A namespaced catalog token (geo.zip_code, measure.amount, …). viz smart routes on the namespace before the first .:

concept Routes to Notes
geo.latitude / geo.longitude / geo.coordinate_pair map lat/lon point map
other geo.* (zip_code, census_tract, county, state, …) bar forces a numeric admin code onto a bar, not a measure
time.* time-series
id.* / pii.* skipped
org.* / category.* / nyc.* bar
measure.* measure measure.ratio averages; the others sum
unknown / unrecognized falls through to role

The exact leaf tokens per namespace are the CONCEPT_VOCAB list; see describegpt. A value outside it isn't an error on reuse β€” it just falls through to role.

content_type β€” the fallback classifier

The 49-token semantic label (full list), used only when concept and role are absent/unrecognized. It routes by bucket:

content_type Routes to
category, state, country, currency_code, industry, job_title, time_zone, … bar
latitude / longitude map
date / datetime / time / duration time-series
unknown / empty falls through to statistics
everything else (IDs, PII, addresses, technical, free-text) skipped

Note

unique_id, date, and datetime are reserved β€” qsv stamps them from the data itself (cardinality and stats type), so setting them by hand has no effect (24.0.0+: Float columns are exempt from the unique_id stamp); describegpt only adds the optional strftime suffix (e.g. datetime:%m/%d/%Y).

Tuning the KPI overview row

The KPI overview row is derived from the panels, so the three classifiers above steer it indirectly:

  • A column becomes a KPI tile only if it was routed to a measure panel (box/violin/histogram) and its type is Integer/Float. So to promote a number to a headline KPI, give it role: measure (or a measure.* concept); to drop one, set role: dimension or identifier. At most 6 tiles are shown.

Two x-qsv keys then shape the tile itself:

  • gauge_range: [min, max] β€” renders the tile as a gauge on that canonical scale (e.g. [0, 5] for a rating, [0, 100] for a percent). describegpt auto-emits this for measures it recognizes as canonical-scale, but keeps it only when the observed value falls inside the range (so a mis-scaled range can't draw a misleading dial). A gauged tile shows the mean, not the sum.
  • target: <number> β€” adds a "vs target" delta (value βˆ’ target) to the tile. This is a goal you set, so describegpt never emits it β€” hand-author it. See the KPI overview row for how both render.

Worked example (customer_spend.csv β€” columns plan, region, monthly_spend, account_age_days):

# 1. save an editable dictionary
qsv describegpt customer_spend.csv --dictionary --format jsonschema -o customer_spend.schema.json

# 2. edit customer_spend.schema.json (each column's x-qsv object):
#    monthly_spend     -> add  "target": 175         # KPI tile gains a "vs target" delta
#    account_age_days  -> set  "role": "dimension"   # drop from the KPI row; chart it as a bar
#    a 0-100 percent   -> add  "gauge_range": [0,100] # that KPI tile becomes a gauge

# 3. rebuild β€” reuses the edited file as-is (no LLM); --dict-info to eyeball the result
qsv viz smart customer_spend.csv --dictionary customer_spend.schema.json --dict-info -o dash.html

Note

Hand-edits are consumed as-is and not re-validated β€” a mistyped concept/role silently falls through to the next signal instead of erroring. Two guardrails still apply: a measure is downgraded to a bar when the column is a zero-padded code or a low-cardinality integer code, and both --dict-info and this tuning are HTML-only and smart-only.

Units and money

A Data Schematic now says "18.4 Β°C" and "$192B" rather than a bare float and an SI "G".

  • x-qsv.unit β€” describegpt proposes a per-field UCUM code, and viz resolves it to a display symbol at the consumption side. A non-monetary numeric measure headlines its KPI tile with the unit (18.4 Β°C, 1.2B kWh), names the symbol once in the panel subtitle, and carries it into hover text and axis titles.
  • measure.money β€” money is now a first-class concept rather than the generic measure.amount, so a dollar column formats as currency instead of taking an SI magnitude suffix.

Two consequences worth knowing:

  • Per-unit money measures are averaged, not summed. A "Total Unit Price" KPI tile is meaningless; unit_price and shipping_cost now headline a mean. x-qsv.aggregation overrides the choice.
  • An off-table unit code simply vanishes rather than rendering. An unknown ISO currency code still shows (XOF 1.2B β€” three letters are self-describing), but qty (furlong) would assert a unit qsv cannot vouch for, so it is dropped. The same guardrail clears both x-qsv.currency and x-qsv.unit when it rejects a column's measure reading.

Note

viz smart's distribution panels deliberately carry no axis titles β€” column identity lives in the panel title and subtitle, to keep the cells compact. That is where currency and unit are rendered for those panels.

Denominators declared in the dictionary

--denominator is a viz choropleth-only flag. In viz smart you declare the denominator in the dictionary instead, as an x-qsv.denominator object on a region-code column (a zip, county, state or fips column). viz smart then adds a rate panel beside the region map's raw-count panel.

"county_fips": {
  "type": "string",
  "x-qsv": {
    "concept": "geo.county_fips",
    "denominator": { "column": "county_pop", "unit": null, "level": "geo.county" }
  }
}
Key Meaning
column The column holding each region's population β€” or households, area, fleet size
unit The unit those values are in, when they are an area: m2 or km2. Without it, land area in square metres is divided raw and the map reads "per 100,000 AREALAND" at approximately zero everywhere
level The areal unit the values describe, as a geo. concept token (e.g. geo.state)

Three rules qsv enforces on read, so a hand-edit can correct the model but cannot smuggle in a claim the data does not support:

  • The value must be constant within a region. It describes the region, not the row. When it isn't, qsv notes why and charts raw counts only.
  • level is checked against the region column's own concept. A denominator measured over a coarser area than the map is keyed by β€” county counts over state populations β€” is constant within each region and positive, so every data-derived check passes while the map is confidently wrong. qsv refuses the rate panel on a mismatch. The two codings of one geography compare equal, so a per-county denominator is fine against either a geo.county or a geo.county_fips region. describegpt emits the level itself; a hint declaring none still charts, and says so in the rate panel's subtitle.
  • A constant denominator is suppressed β€” see when a denominator is refused.

Tip

Prefer --denominator-key when the boundary file already carries the figure.

What the region map colors

New in 24.0.0 (#4683). A viz smart map keyed by a region-code column draws up to four panels:

  1. Count of rows per region. It is skipped when every region has exactly the same number of rows, which is typical of pre-aggregated data (one row per state Γ— year Γ— type). There the count map is a single flat shade, and its rate map would only redraw the denominator upside down. A note on stderr says so; --agg count keeps the map.
  2. Count rate beside it, when a denominator is available (see above).
  3. Total of the additive measure per region, e.g. "Total Payment Amount (USD) by State Code (2019–2025)". The title names the date span when the data covers several years, because the total is cumulative.
  4. That total's rate, e.g. "… per resident", when a denominator is available.

Which measure is totalled:

  • Only a measure whose aggregation is sum. That is its x-qsv.aggregation, or the default for its concept after qsv's intensive-measure check. A money column declared aggregation: mean (a pre-averaged payment) is never totalled.
  • Money and amounts come before counts (#4684), then decimals before whole numbers, then the leftmost column.
  • Populations, areas and declared denominators are never totalled. Nor is any column that repeats one value on every row of its region: such a column describes the region, not its rows, so summing it multiplies it by the row count. The map shows the region's own value instead, with a note. The flip side is that a genuinely per-row amount that happens to be identical within every region (a flat fee per region) is shown the same way rather than totalled.
  • With no additive measure, the third panel is the per-region median of a tagged measure, as before.

--value / --agg override the choice. --value <col> maps that column's per-region total; add --agg mean|min|max for an average, minimum or maximum instead (no rate panel for those, since dividing an average by a population means nothing). Both flags need --geojson. --agg other than count needs --value, and --value must be numeric. If no region-code map gets built (a point-in-polygon map, or image output), qsv says the flags were not applied.

Declaring a pipeline β€” funnel or bridge

Which columns are process stages, and in which direction, is semantics, not a statistic. No column-name vocabulary settles it and no statistic does either, so viz smart draws the pipeline panel only from an explicit declaration β€” a dataset-level x-qsv.relationships entry with "kind": "pipeline". It is never guessed. (The standalone qsv viz funnel chart type takes its stage order from the file and needs no dictionary.)

Two hand-editable encodings:

Stages as COLUMNS. members lists the stage columns in process order, widest/upstream first β€” note this is the opposite direction from a "kind": "ordered" group, which ascends.

{ "kind": "pipeline",
  "members": ["planned_amt", "committed_amt", "spent_amt"] }

Stages as ROW VALUES. stage_column names the category column, stages lists its values in process order, and an optional value_column names the measure to sum per stage (omit it to count rows).

{ "kind": "pipeline",
  "members": ["stage", "revenue"],
  "stage_column": "stage",
  "stages": ["Impression", "Click", "Lead", "Conversion"],
  "value_column": "revenue" }

The declaration fixes the order; the numbers decide the form

A funnel's band widths are a containment claim, so a funnel is drawn only while the stage totals never grow. If any stage outruns the one before it, the same declaration is drawn as a BRIDGE instead: the signed difference between consecutive totals, each step labelled as the arithmetic difference it is rather than as a flow. A funnel there would render a band wider than the one above it β€” asserting the opposite of the data. The subtitle says which form was used and why.

Stage order is the declared order and is never re-sorted by size, so a stage that outruns its predecessor stays visible instead of being quietly reordered away.

Two disclosures that are easy to conflate:

  • Form is decided by the stage totals.
  • Row-wise containment β€” does each stage nest inside the one before it? β€” is measured and disclosed in the subtitle separately, because rows can overrun while the totals still shrink, or nest while the totals grow. (Column encoding only.)

Totals sum over the rows complete across every declared stage, so they do not match stats.sum; the subtitle always discloses that denominator.

A declaration naming a missing column, or a stage that is an average/rate rather than a summable amount, is skipped with a note rather than erroring. Costs one extra data pass over the declared stages only.

The --dict-info drawer

When a dictionary is in play, --dict-info surfaces it in the dashboard: it adds a "Data Dictionary" link beneath the title that opens a human-friendly rendering of the dictionary in its own tab (embedded in the HTML β€” no extra file), plus an info icon beside each panel title (hover shows that column's description, click jumps to its entry). Descriptions are rendered as Markdown. HTML output only; ignored with a note when no dictionary is available or when exporting an image.

The dictionary page also carries a download row bundling every sidecar the run consumed β€” not just the dictionary. The JSON Schema data dictionary, the frequency table and the stats cache that drove panel selection all download straight from the page, so a dashboard you hand someone travels with the inputs it was built from and its choices stay auditable offline.

--dataset-pid <value> records a persistent identifier for the dataset β€” typically a DOI (https://doi.org/10.1234/abc) or other citable URL. When set, a clickable "PID" row is added to the dataset metadata table at the top of the viz smart dashboard, using the value verbatim as the link target. HTML output only.

# semantics-guided dashboard with an embedded, browsable data dictionary + a citable PID
qsv viz smart customer_spend.csv --dictionary infer --dict-info \
  --dataset-pid https://doi.org/10.1234/customer-spend -o spend_dashboard.html

What the drawer bundles

Every file is bundled into the HTML, so anyone you send the Data Schematic to can download them with no access to your machine. The download row carries the dictionary itself as JSON Schema, the frequency counts the Data Schematic actually charted, and every generated sidecar this run read β€” the stats cache and its metadata, the frequency cache when it was reused, and the bivariate stats CSV when freshly written.

A sidecar qsv wrote but viz never read β€” the human-readable <stem>.stats.csv β€” is deliberately not offered: nothing can show it describes the same computation the Data Schematic used. Sidecars over 4 MB are skipped with a note. Absolute local paths are stripped from the embedded metadata, so sharing a Data Schematic doesn't disclose your directory layout.

What the drawer says was left out

Omissions used to go to stderr only, so the artifact people keep and share carried no record of them. The drawer now travels with them:

  • A column viz smart did not chart carries a "not charted" note giving the reason it reported β€” an identifier, all-empty, high-cardinality text, a duplicate of a sibling column that is charted, or simply capped by --max-charts β€” and is dimmed in the table of contents. A skipped column the dictionary never described still gets an entry, so nothing disappears silently.
  • Panels qsv declined to draw β€” a hierarchy panel whose dimensions are statistically independent, a refused funnel, an association panel β€” are listed together under "Panels not drawn".

The drawer is open by default on load, dismissed with its close button or Esc, and its popout button opens the same document in its own browser tab (needs a browser that allows user-initiated pop-ups).

Chart subcommands

Grab the sample datasets first:

curl -LO https://raw.githubusercontent.com/dathere/qsv/master/examples/viz/sales_sample.csv
curl -LO https://raw.githubusercontent.com/dathere/qsv/master/examples/viz/stock_prices.csv
curl -LO https://raw.githubusercontent.com/dathere/qsv/master/examples/viz/web_flows.csv
curl -LO https://raw.githubusercontent.com/dathere/qsv/master/examples/viz/product_ratings.csv
curl -LO https://raw.githubusercontent.com/dathere/qsv/master/examples/viz/quakes.csv
curl -LO https://raw.githubusercontent.com/dathere/qsv/master/examples/viz/country_stats.csv
curl -LO https://raw.githubusercontent.com/dathere/qsv/master/examples/viz/us_state_stats.csv
curl -LO https://raw.githubusercontent.com/dathere/qsv/master/examples/viz/western_states.csv
curl -LO https://raw.githubusercontent.com/dathere/qsv/master/examples/viz/western_states.geojson
curl -LO https://raw.githubusercontent.com/dathere/qsv/master/examples/viz/world_events_dated.csv
# bar β€” revenue by region (aggregated)
qsv viz bar sales_sample.csv --x region --y revenue --agg sum -o bar.html

# line β€” closing price over time
qsv viz line stock_prices.csv --x date --y close -o line.html

# scatter β€” units sold vs revenue
qsv viz scatter sales_sample.csv --x units_sold --y revenue -o scatter.html

# scatter3d β€” three numeric columns in 3D, colored by a fourth
qsv viz scatter3d sales_sample.csv --x units_sold --y revenue --z shipping_cost --color profit_margin_pct -o scatter3d.html

# contour β€” 2D density of two numeric columns (binned)
qsv viz contour sales_sample.csv --x units_sold --y revenue --bins 20 -o contour.html

# histogram β€” distribution of unit price
qsv viz histogram sales_sample.csv --x unit_price -o histogram.html

# box β€” spread of revenue
qsv viz box sales_sample.csv --y revenue -o box.html

# violin β€” distribution shape of revenue per region (box + KDE silhouette)
qsv viz violin sales_sample.csv --y revenue --x region -o violin.html

# pie (donut) β€” revenue share by product category
qsv viz pie sales_sample.csv --x product_category --y revenue --donut -o pie.html

# treemap β€” nested part-to-whole as sized tiles (--cols are the levels, outer first), sized by a --value sum
qsv viz treemap sales_sample.csv --cols region,product_category --value revenue --agg sum -o treemap.html

# sunburst β€” deeper hierarchy as concentric rings; sized by row count when --value is omitted
qsv viz sunburst sales_sample.csv --cols region,product_category,payment_method -o sunburst.html

# icicle β€” same hierarchy as level-aligned stacked bars (good for deep, wide trees)
qsv viz icicle sales_sample.csv --cols region,product_category,payment_method -o icicle.html

# heatmap (correlation) β€” Pearson matrix over all numeric columns
qsv viz heatmap sales_sample.csv -o heatmap_corr.html

# splom β€” scatter-plot matrix: every pairwise scatter of --cols in an N x N grid with shared axes
qsv viz splom sales_sample.csv --cols units_sold,revenue,discount_pct,profit_margin_pct -o splom.html

# parcats β€” parallel-categories flow; each ribbon is a category combination, sized by row count
qsv viz parcats sales_sample.csv --cols region,product_category,payment_method -o parcats.html

# heatmap (pivot) β€” region x category grid of revenue (give --x, --y AND --z)
qsv viz heatmap sales_sample.csv --x region --y product_category --z revenue -o heatmap_pivot.html

# candlestick / ohlc β€” OHLC price action
qsv viz candlestick stock_prices.csv --x date --ohlc-open open --high high --low low --close close -o candlestick.html
qsv viz ohlc        stock_prices.csv --x date --ohlc-open open --high high --low low --close close -o ohlc.html

# sankey — session funnel (duplicate source→target pairs are aggregated)
qsv viz sankey web_flows.csv --source source --target target --value sessions -o sankey.html

# funnel β€” stage-by-stage drop-off (--x = stage column, optional --y = value to sum)
qsv viz funnel pipeline.csv --x stage --y amount -o funnel.html

# radar β€” multi-axis brand comparison (one polygon per --series value, per-axis mean)
qsv viz radar product_ratings.csv --cols battery,camera,performance,display,value,design --series brand -o radar.html

# map β€” point map on token-free OpenStreetMap tiles; color by magnitude, size by depth
qsv viz map quakes.csv --lat lat --lon lon --color magnitude --size depth_km -o map.html

# map (density) β€” DensityMap heatmap of the same points, on a light Carto basemap
qsv viz map quakes.csv --lat lat --lon lon --density --style carto-positron -o map_density.html

# geo β€” offline projection map (no tiles/token); color by magnitude
qsv viz geo quakes.csv --lat lat --lon lon --color magnitude --projection natural-earth -o geo.html

# choropleth β€” fill countries by GDP, matched by ISO-3 code (token-free projection basemap)
qsv viz choropleth country_stats.csv --locations iso3 --value gdp_usd_tn --color-scale viridis -o choropleth.html

# choropleth (US states) β€” built-in state geometry on the albers-usa projection, no GeoJSON needed
qsv viz choropleth us_state_stats.csv --locations state --value renewable_electricity_pct \
    --location-mode usa-states -o choropleth_states.html

# choropleth (--map) β€” filled custom regions on a MapLibre tile basemap, matched by --feature-id-key
qsv viz choropleth western_states.csv --locations state --value wind_capacity_gw \
    --geojson western_states.geojson --feature-id-key id --map --style carto-positron \
    -o choropleth_map.html

Note

--ohlc-open is spelled out (not --open) because --open already means "open the result in a browser".

Animation, range & annotation

Option What it does
--slider <col> Animates the chart over a column β€” each distinct value becomes a frame, with Play/Pause and a scrub slider (Gapminder-style). Works on bar/line/scatter and geo, and can be split into animated traces with --series. Axis ranges are pinned across frames so nothing jumps.
--slider-speed <ms> Milliseconds each frame is shown while playing (default 800).
--slider-cumulative Frame N includes every row at or below the Nth --slider value, so the animation builds up instead of replacing each step.
--rangeslider Adds a draggable navigator strip under the x-axis for panning/zooming. Best on an ordered x-axis; applies to the cartesian charts. Off by default.
--y-range <min:max> Pins the y-axis to an explicit range instead of auto-fitting.
--annotation <s> A caption note drawn at the bottom of the plot β€” handy for noting a clipped outlier. Cartesian charts only.
# animate revenue by category over the satisfaction rating
qsv viz bar sales_sample.csv --x product_category --y revenue --agg sum \
    --slider satisfaction -o bar_animated.html

# an animated point map β€” use `geo`, not `map` (a MapLibre basemap can't animate)
qsv viz geo world_events_dated.csv --lat lat --lon lon --slider event_date \
    --slider-cumulative --series place -o geo_animated.html

Note

--slider is not supported for viz map β€” the MapLibre basemap can't animate. Use viz geo for an animated point map.

On viz smart, --slider instead means "should the dashboard get one animated panel?" β€” auto (the default) adds one only on a strong signal, on lowers the bar, off never does. See animated panels below.

Sankey node order β€” --sankey-value-order

By default a sankey's nodes use plotly's crossing-minimizing "snap" order, which packs the diagram most compactly. --sankey-value-order opts into a flow-ranked initial ordering instead (largest total flow at the top of each column).

Either way the rendered chart carries an on-screen "node order" button to flip between the two layouts, and link ribbons are always coloured by their source node.

This flag applies to both the standalone viz sankey chart and the viz smart Data Schematic's flow panel.

Hierarchy charts in depth

viz treemap, viz sunburst, and viz icicle all render a part-to-whole hierarchy from the --cols you list β€” each column is one nesting level, outermost first (e.g. --cols region,product_category nests categories inside regions). They differ only in shape, so pick by what you want to read:

  • viz treemap draws nested rectangles where area encodes size β€” best when you want to compare magnitudes across the hierarchy.
  • viz sunburst draws concentric rings that emphasize parent-child structure β€” best for deeper hierarchies (3+ levels) where ring lineage reads more clearly than nested boxes. In-sector labels orient radially (along each ring's spoke), so even a crowded deep-path ring stays legible rather than clipping tangential text.
  • viz icicle draws stacked bars per level, all levels aligned on a common axis β€” best for deep, wide hierarchies where treemap tiles get too small and sunburst rings get too thin; the level alignment makes it easy to compare siblings across branches.

Sizing:

  • By default each leaf is sized by its row count (how many rows fall under that path).
  • Add --value <numeric> to size by a measure instead, and --agg sum|count to choose how repeated paths roll up (sum is the default when --value is given). Unlike viz bar, these charts accept only the additive aggregations sum and count β€” mean/min/max would break the part-to-whole invariant (a parent must equal the sum of its children) and are rejected.

In viz smart, the same hierarchy appears as an auto-built panel governed by the association screen described above; the standalone subcommands have no such screen β€” they always build the chart you ask for.

# treemap β€” revenue by region then product category, summed
qsv viz treemap sales_sample.csv --cols region,product_category --value revenue --agg sum -o treemap.html

# sunburst β€” a deeper 3-level hierarchy, sized by row count (no --value)
qsv viz sunburst sales_sample.csv --cols region,product_category,payment_method -o sunburst.html

# icicle β€” level-aligned stacked bars for the same hierarchy
qsv viz icicle sales_sample.csv --cols region,product_category,payment_method -o icicle.html

Maps & geography

Maps in depth

viz map plots --lat/--lon coordinates on a tile basemap and auto-centers/zooms to the data's bounding box (with antimeridian wrap, so clusters straddling the 180Β° line frame correctly).

  • Encodings (point maps): --color <numeric> for a continuous Viridis colorscale + colorbar, --size <numeric> for bubble sizes, or --series <category> for one trace per group. --text <col> adds per-point hover labels.
  • --density switches to a DensityMap (MapLibre) heatmap, weighted by --color/--size (or uniform).
  • --style picks the basemap. All styles are MapLibre and render token-free β€” no account or access token is ever needed. Choices: the default open-street-map; carto-positron, carto-darkmatter, carto-voyager (each with a -nolabels variant); white-bg; basic; streets; outdoors; light; dark; satellite; and satellite-streets. (The old Stamen basemaps have no MapLibre equivalent and were dropped.)

viz geo is the tile-free alternative: it draws the same --lat/--lon points (and the same --color/--size/--series/--text encodings) on a ScatterGeo projection basemap β€” coastlines, land, and country borders rendered locally, so it works fully offline with no tiles and no token. Choose the projection with --projection (natural-earth default, plus mercator, orthographic, equirectangular, albers-usa, robinson, winkel-tripel, mollweide, hammer, azimuthal-equal-area). It's the better fit for continental/global data, which is exactly when viz smart auto-picks it over the tile map. There's no --density mode for geo.

Tip

Pair these with geocode β€” turn city names or addresses into the lat/lon columns viz map and viz geo plot.

Choropleths in depth

Where viz map/viz geo plot points, viz choropleth fills whole regions β€” countries, US states, or your own polygons β€” coloring each by a value (--value/--agg, or row counts when omitted). How a row finds its region is set by --location-mode:

  • iso3 (the default) β€” --locations holds an ISO-3166-1 alpha-3 country code.
  • usa-states β€” 2-letter US state codes, drawn on built-in state geometry (albers-usa); no GeoJSON needed.
  • country-names β€” full country names.
  • geojson-id β€” match a feature id in a custom --geojson (point the matcher at it with --feature-id-key, e.g. id or properties.fips).

By default the fill is drawn on a token-free projection basemap (no tiles, no token); add --map to render a custom --geojson on a token-free MapLibre tile basemap instead (reuses --style). Recolor with --color-scale (viridis default, plus cividis, blues, reds, rdbu, …).

Two ways to get region codes without a code column of your own:

  • --geocode (needs the geocode feature) β€” reverse-geocode the --lat/--lon points into ISO-3 or US-state codes, or forward-geocode a place-name column, then count/aggregate per region.
  • Point-in-polygon binning β€” give a custom --geojson (with --feature-id-key) and --lat/--lon but no --geocode: each point is binned into the polygon that contains it (exact, no geocoding). Points landing outside every region snap to the nearest within --snap-max-dist or are dropped with --no-snap; a stderr note and the map's caption report coverage either way. The snap cap is auto-derived, not a fixed 10 km β€” see point-in-polygon binning.
# fill countries by GDP, matched by ISO-3 code
qsv viz choropleth country_stats.csv --locations iso3 --value gdp_usd_tn -o choropleth.html

# US-state fill from built-in geometry β€” no GeoJSON
qsv viz choropleth us_state_stats.csv --locations state --location-mode usa-states \
    --value renewable_electricity_pct -o choropleth_states.html

# custom GeoJSON regions on a MapLibre tile basemap, matched by --feature-id-key
qsv viz choropleth western_states.csv --locations state --value wind_capacity_gw \
    --geojson western_states.geojson --feature-id-key id --map -o choropleth_map.html

# point-in-polygon: bin lat/lon points into custom regions by count (no geocoding)
qsv viz choropleth quakes.csv --lat lat --lon lon \
    --geojson prefectures.geojson --feature-id-key properties.id -o by_pref.html

The same choropleth shows up automatically in viz smart whenever lat/lon columns are present β€” see the smart auto-dashboard above.

US boundaries with no GeoJSON: --geojson auto

New to Census geography (GEOIDs, ZCTAs, vintages)? Start with US Census Integration β€” a primer, worked examples and best practices.

viz choropleth --geojson auto (or census) resolves US boundaries from the Census TIGERweb REST service, scoped to the states your data names, and sets --feature-id-key to properties.GEOID for you. You supply nothing but the CSV.

25.0.0: an explicit --feature-id-key overrides the key auto picks, consistently on every path (#4735). The coverage check scores the column under the key the map will render with, and far-away regions are left out of the frame under that key too. Two cases differ:

  • County names and city/place names resolve to Census GEOIDs, so they refuse a key that doesn't carry them. properties.GEOID and the top-level id (which mirrors it) work; properties.NAME is refused rather than drawing a map that silently drops every row.
  • viz smart tries the next region column when one column's boundaries lack the key (e.g. properties.STUSAB on counties), instead of failing.

Usually the simplest choice is to omit --feature-id-key with --geojson auto.

County FIPS and ZIP codes are both 5 digits, so qsv probes the candidate layers and picks whichever your codes actually resolve against, reporting them when it can't tell. Force one when you know:

Selector Layer
census:county Counties
census:zcta ZIP Code Tabulation Areas
census:tract Census tracts
census:place Places β€” incorporated places and Census Designated Places
census:state States, DC, Puerto Rico and the island areas (AS, GU, MP, VI), from USPS codes (PA), state FIPS (42) or full state names (Pennsylvania, U.S. Virgin Islands; exact, any case). The map keys on properties.STUSAB or properties.GEOID to match the column; names are joined to that key, so the hover keeps the Census name. A column of bare county names that are also state names (Washington) needs --region-state to be read as counties. New in 24.0.0.

Each accepts an @<year> suffix to pin a vintage (e.g. census:county@2021). Vintages matter more than they look: Litchfield County exists in ACS2021 and Northwest Hills Planning Region in ACS2023, with no shared GEOID, so names and polygons are always fetched from the same vintage.

Note

census:place covers the full place universe as of 23.0.1. A Census "place" is an incorporated place or a CDP β€” the unincorporated communities Census delineates for tabulation β€” but the layer previously matched incorporated places only, so every CDP row silently went missing. If a CDP-heavy --locations column now fails to resolve, that is deliberate: when too few values match a layer, qsv refuses rather than drawing a partial map.

Region names, not just codes

A --locations column of county names ("Allegheny County") drives --geojson auto with nothing else supplied: the distinct names are resolved against the Census's own name table for the vintage in play, and the boundaries those GEOIDs name are fetched.

Warning

422 county names are shared across states β€” Washington County exists in 30 of them, and shared names cover roughly half of all counties. A bare national county-name column is therefore refused, not guessed. Disambiguate with --region-state, which accepts a USPS code (PA), a 2-digit state FIPS (42), or the full state name (Pennsylvania). In viz smart the state column is taken from the data dictionary instead. A county FIPS column needs none of this β€” it is unambiguous already.

City / place names take the other path: --geojson auto --geocode forward-geocodes them to their containing US county FIPS (Geonames states the county directly, so this is a lookup, not an approximation) and fetches those counties. --geocode-country and --geocode-admin1 disambiguate same-named places; the country defaults to us.

Important

Do not reach for --geocode on a column of COUNTY names. It searches city names, so it returns silent wrong answers that all look valid β€” Litchfield County resolves to Maricopa County AZ, Nome Census Area to Niksar, Turkey. Every result is a real county, so a coverage check reads 100%. qsv now pre-empts this before any lookup, but the distinction is worth knowing: county names β†’ the Census name table (no --geocode); city names β†’ --geocode.

--check-geojson-key β€” find the key that actually binds

--feature-id-key defaults to id, which is usually wrong for a real boundary file β€” and picking the wrong one renders a choropleth that shades nothing while exiting 0.

qsv viz choropleth data.csv --locations fips --geojson counties.geojson --check-geojson-key

Instead of rendering, this scores every candidate path β€” the feature id, every scalar leaf under properties, and every top-level foreign member β€” against the distinct values of your locations column, and prints them ranked by overlap. It uses the same matcher the render path uses, so a path reported here as a full match will bind at render time.

Requires --geojson and --locations, with a concrete source for the former (path, URL or shortcut). Every automatic Census spec (auto, census, census:<layer>, and their @<year> forms) is refused β€” automatic resolution picks the key itself, so there is nothing to diagnose.

Point-in-polygon binning: snapping and coverage

Give a custom --geojson (with --feature-id-key) and --lat/--lon but no --geocode, and each row's point is binned into the polygon that contains it β€” exact, no geocoding β€” coloured by --value/--agg or by row count. This works in both the viz choropleth command and the viz smart GeoJSON choropleth panel.

Points that fall outside every region are the interesting case:

  • By default they snap to the nearest region, if one is within the snap-distance cap.
  • --no-snap disables snapping entirely β€” every outside point is dropped.
  • --snap-max-dist <km> sets the cap explicitly. It cannot be combined with --no-snap. Pass a large value for effectively unbounded snapping.

Important

The default cap is auto-derived, not a fixed 10 km. When --snap-max-dist is omitted, the cap comes from the GeoJSON's median region size (10% of the median bbox diagonal) and the lat/lon coordinate precision β€” coarse, few-decimal coordinates raise the cap to cover their quantization error β€” then clamped to 0.1–100 km. A fixed 10 km is used only when neither signal is available. Distance is an equirectangular km approximation.

Coverage is reported either way: a note on stderr, a note beneath the map (or in the smart panel's title) counting snapped and dropped points, and a per-region hover tally of the points each snapping region absorbed from outside. The notes also state which cap was applied and how it was derived.

In viz smart, a --geojson also overlays the region boundaries on the map panel, labelling each with its --feature-name-key value (falling back to its id) β€” as on-map text on the projection/static map, or as a centroid hover marker on the interactive tile map, which culls on-map text. Labels are omitted above 60 regions.

Rate maps: dividing by a denominator

For --denominator census β€” what it fetches, the API key, and pinning ACS vintages β€” see also US Census Integration.

A choropleth colored by row counts is largely a population map β€” the region with the most people tallies the most rows, so the map ranks regions by size rather than by intensity. Boston 311 picks out Dorchester; Allegheny dog licenses pick out ZIP 15237. A choropleth colored by a rate is a finding.

Three ways to say what to divide by:

Flag Denominator source
--denominator-key <path> A property on the GeoJSON itself, e.g. properties.POP2020 β€” same addressing as --feature-id-key
--denominator <column> A column in your CSV. Must be constant within a region (it describes the region, not the row)
--denominator census US population fetched from the Census Data API β€” a per-capita rate from nothing but the CSV

In viz choropleth the map itself becomes the rate; in viz smart a rate panel is added beside the count panel. Regions whose denominator is missing or non-positive are excluded from the rate and reported. The per-region scale (per 1,000 / 10,000 / 100,000 / 1,000,000 / 1,000,000,000) is chosen automatically.

# rate straight off the boundary file's own population property
qsv viz choropleth complaints.csv --locations fips --geojson auto \
    --denominator-key properties.POP2020 -o rate.html

# per-capita with nothing supplied but the CSV
qsv viz choropleth complaints.csv --locations fips --geojson auto \
    --denominator census -o per_capita.html

The gallery's wpa_211_requests figure is the real-data counterpart to its synthetic rate maps: 279,464 Western Pennsylvania 2-1-1 helpline requests across 25 counties, with nothing in the CSV but the requests — --geojson auto fetches the boundaries and canonicalizes the county names, and --denominator census@2024 fetches the population. Here per-capita does not invert the map: Allegheny leads both panels, but the middle reshuffles (Venango climbs 11th→3rd). It also shows the caveat of any rate: a helpline's rate map measures service reach as much as need. See examples/viz/README.md for the exact command.

Note

census is a RESERVED value for --denominator β€” a column of that name cannot be used. It is scoped to state and county, the two geographies whose ACS coverage is complete and uncaveated; tracts and ZCTAs are deferred rather than shipped with caveats.

A column --denominator is viz choropleth only (--denominator census, being a source rather than a column, works on viz smart too β€” pin the ACS vintage with census@<year>, e.g. census@2019), and only with a --locations region column (not with --geocode or lat/lon binning). It is valid with the default count aggregation and with --agg sum; the other aggregations are already intensive, so a denominator is rejected. In viz smart, declare the column in the data dictionary instead, via x-qsv.denominator β€” see Dictionary-guided panel selection.

Units: --denominator-unit

Real published boundary files reliably carry exactly one denominator β€” land area, in square metres. Divided raw, the rate lands around 1e-9 and reads as "per 100,000 AREALAND" at approximately zero everywhere.

--denominator-unit names the unit the values are in, so qsv can convert and label them: one of m2, km2 (also spelled mΒ², kmΒ², sqm, sqkm). Values in m2 are converted so the map reads "per kmΒ²".

Important

A unit is never inferred from a field name β€” declare it or qsv divides raw. (Boundaries fetched by --geojson auto declare their own units and need no flag.) This is deliberate: a wrong unit silently multiplies every value, so qsv refuses to guess. Contrast the annotations that only label a correct number, which qsv is happy to accept on proposal and verify.

When a denominator is refused

qsv would rather draw no rate panel than a misleading one. Two refusals worth knowing:

  • A constant denominator. If the hint resolves to ONE distinct value across the matched regions, dividing rescales every region by the same constant β€” identical ranks, identical relative shading, a rate panel that is only the count panel with different axis labels. Suppressed, and the suppression is threshold-free: it fires on D == 1 distinct values, not on a ratio.
  • A geography mismatch. A county extract that joined in state populations would otherwise chart county counts over state totals. describegpt declares the geography a denominator describes (x-qsv.denominator.level) and viz checks it against the region key, rejecting a mismatch rather than charting a rescaled count.

In both cases, on a genuine conflict the KPI tile is omitted β€” it does not silently fall back to the raw total.

Theming

--theme applies a built-in Plotly theme β€” background, fonts, and axis styling β€” to any chart type, including viz smart. Choose from default, plotly_white, plotly_dark, seaborn, seaborn_whitegrid, seaborn_dark, matplotlib, and plotnine (case-insensitive; hyphens accepted). When omitted, qsv's built-in look is used.

qsv viz smart sales_sample.csv --theme plotly_dark -o dashboard_dark.html
qsv viz bar sales_sample.csv --x region --y revenue --agg sum --theme seaborn -o bar.html

Don't confuse --theme (the chart's visual style, all chart types) with --style (the map basemap, viz map only).

Static image export

Any chart can be written as a static image instead of HTML β€” just change the extension. This needs a viz_static build (the prebuilt full binaries qualify) plus a local Chrome/Chromium and a matching chromedriver β€” see Driver setup below. The only tile-based renders can't be exported (they need live network tiles): the viz map tile basemap and a viz choropleth --map MapLibre choropleth β€” the projection-basemap viz geo and default viz choropleth export fine.

viz smart dashboards export in full β€” all eligible panels (beyond 8, laid out with domain-positioned axes), including the offline ScatterGeo map fit to the data extent. Only the MapLibre tile map (it needs network tiles) and 3D scatter panels stay HTML-only.

qsv viz scatter sales_sample.csv --x units_sold --y revenue -o scatter.png
qsv viz smart   sales_sample.csv -o overview.svg --width 1400 --height 900

Driver setup

Static export renders in a headless Chrome/Chromium that Plotly drives through chromedriver. Firefox is not supported β€” viz_static compiles against plotly_static/chromedriver, and the chromedriver/geckodriver features are mutually exclusive upstream, so pointing at a geckodriver cannot work no matter how it is configured.

As of 24.0.0, qsv finds the driver for you, in this order (on 23.0.1 and earlier you had to set WEBDRIVER_PATH yourself β€” see the note at the end of this section):

  1. WEBDRIVER_PATH, if set and it points at an executable file
  2. $WEBDRIVER_INSTALL_PATH/chromedriver
  3. ~/.local/bin/chromedriver (where a source build's auto-download lands)
  4. anything named chromedriver on your PATH
  5. /opt/homebrew/bin, /usr/local/bin, /usr/bin (Unix only β€” on Windows the search stops at PATH)

Two environment variables override that, and are read by plotly_static directly rather than by qsv (so they are not QSV_* and will not show up in qsv --envlist):

Variable Description
WEBDRIVER_PATH full path to the chromedriver executable to use
BROWSER_PATH full path to a Chrome/Chromium binary, when it isn't the system default

⚠️ The chromedriver MAJOR version must match your installed Chrome. A mismatch fails late and opaquely, with Failed to extract static image from browser session. Get a matched pair from Chrome for Testing. On macOS, brew install --cask chromedriver is disabled (it does not pass the Gatekeeper check), so download it from Chrome for Testing instead.

πŸ’‘ In qsv 23.0.1 and earlier, prebuilt binaries carried an absolute chromedriver path baked in at build time, pointing into the build machine's home directory β€” so static export failed out of the box and WEBDRIVER_PATH had to be set by hand. The probe order above replaces that (#4620).

Output, offline use & file size

A viz chart is a self-contained HTML file: the plotly.js runtime is embedded, so the chart works with no network. Two caveats and two escape hatches:

  • Map basemaps still fetch tiles at view time. viz map and viz choropleth --map render MapLibre tile basemaps, which need the network when the page is opened (the white-bg style is the exception). The projection basemaps β€” viz geo and the default viz choropleth β€” are drawn locally and are genuinely offline.
  • Titles and labels are plain text. LaTeX (e.g. $\alpha$) is not typeset.
Want Set Effect
A smaller file for the web QSV_VIZ_CDN Loads plotly.js from its CDN (pinned to the embedded version) instead of inlining it β€” about 1.9 MB smaller, but the page needs network access to be viewed
An older browser, a strict-CSP embed, or greppable HTML QSV_VIZ_NO_COMPRESS Emits plain-text HTML instead of the default gzip+base64 payloads. Larger, but universally readable

By default the embedded plotly.js is gzip-compressed (~1.9 MB vs ~4.8 MB) and inflated in the browser. That needs DecompressionStream support β€” Chrome/Edge 80+, Firefox 113+, Safari 16.4+. QSV_VIZ_NO_COMPRESS is the fallback for anything older.

The other lever on file size is how much data is embedded: see Data size & downsampling and --preview-threshold for the data-viewer drawer.

Progress reporting

viz shows a spinner with per-phase status on stderr (loading statistics, inferring the data dictionary, computing correlations, rendering, …). It auto-hides when stderr is not a terminal β€” piped or redirected β€” and can be turned off by setting QSV_PROGRESSBAR to a falsy value (0/false/off).

Recent viz changes (24.0.0)

New in 24.0.0:

  • Bare-year time axes (#4685). A dictionary-tagged column of 4-digit years such as a program or fiscal year now drives the time-series panel as a yearly axis, where before it drew nothing unless the years were rewritten as 2019-01-01. On stderr it is listed as Program_Year (time axis) rather than as a bare "skipped", and a timestamp column that can't be the axis says why.
  • Year labels no longer drop out of mid-width time axes (#4686). The label-thinning script now measures label widths and uses an even stride, so all seven years of a 2019–2025 series show from about 400 px (about 600 px before).
  • --geojson census:state: automatic state boundaries (#4681). auto resolves columns of USPS codes, state FIPS or full state names (#4694) against the Census States layer, so viz smart --geojson auto --denominator census now works on state-level data. In viz smart, a state column is tried only after every county, ZIP, tract, place and city column has failed, so datasets that carry a state column for disambiguation keep their county maps.
  • --denominator census excludes and reports what it can't cover (#4680). Territory and military codes (AS, GU, MP, VI, AA/AE/AP, FM/MH/PW), and counties in states the ACS doesn't cover, used to abort the run. They are now left out of the rate and listed on stderr.
  • Region maps total the additive measure, not just count rows (#4683, #4684). The measure panel of a viz smart region-code map now shows each region's total of the dictionary's additive measure (e.g. payment dollars by state), with its own rate panel when a denominator is available. The per-region median is now only the fallback. Money ranks ahead of counts, and pre-aggregated data no longer gets a flat count map. See What the region map colors.
  • Seasonality rings: cycles are detected, not assumed (#4672). Hour of day, day of week and month of year are each scored, and every cycle that passes gets its own polar ring, side by side in one panel, strongest first. A dataset with no real cycle gets no panel.
    • Rings plot each slot relative to its local average (1.84Γ— average) instead of raw counts.
    • A cycle is drawn only if it repeats consistently, is large enough to see, and passes a significance test at the 1% level. Counts are first compared with a moving average one cycle wide, which removes trend and uneven date coverage, and monthly counts are adjusted for month length.
    • Each cycle needs at least three repetitions in the data: 3 days, 3 weeks or 3 years.
    • Date-only values in a datetime column, and 00:00:00 stamps that act as a "time unknown" default, are kept out of the hour ring. Real midnight rows in data recorded on the hour are kept.
    • The gallery already shows it: the Pittsburgh 311 and Allegheny dog-licence dashboards have all three rings.
  • Skew cues use moment skewness (G1), not Pearson's second coefficient. 3(meanβˆ’median)/stddev saturates on heavy tails, where money columns with a mean 41Γ— their median scored under the cut. On discrete counts it can also get the direction wrong. G1 drives the box/histogram skew hint, the box log-axis fallback and --max-charts ranking, with the cut at 0.5. Plain viz smart computes G1 in its own pass, and --smarter reads moarstats' new moment_skewness, so both classify identically. Pearson remains the fallback.
  • Panel-title hints fit the chart width instead of stopping at two. Hints that don't fit end the title in … and are listed on hover. They are ordered with what the chart can't show first: nulls, zeros, outlier pull on the mean, outlier share, then skew and Gini. Static exports keep what fits their cell.
  • Lorenz panels (--smarter) show the Hoover index next to Gini, drawn as a dashed segment at the largest gap below the equality line. It equals moarstats' hoover_index.
  • Plain viz smart computes the bimodality coefficient from exact moments, the same way moarstats --advanced does, so a column charts identically with or without --smarter.
  • Near-unique Float measures are no longer treated as IDs (#4653). Full-precision prices, coordinates and durations are all-unique as a matter of course. The correlation matrix, the measure-by-dimension panel and viz heatmap's auto mode now drop only near-unique Integer columns, so pages can gain heatmap columns and the "measure by dimension" panel. Known limit: integers of 20+ digits are typed Float by stats, so viz smart treats them as measures (#4657).
  • A dictionary inferred before that fix keeps the old classification. --dictionary infer (and so --bivariate) reuses <stem>.schema.json, so delete it or set QSV_VIZ_DICT_FRESH=1 for one run.
  • A unit is never stated twice β€” "Depth (km)" no longer becomes "Depth (km) (km)".
  • Choropleth click-to-filter pages are byte-stable across regenerations (#4645).
  • Nationwide Census boundaries no longer fail with "Census response is not valid JSON" (#4682). TIGERweb's firewall refused the full-resolution pages qsv requested for a country-wide dataset. Geometry is now generalized server-side (nationwide counties drop from ~300 MB to ~11 MB), a refused page is retried at half the page size, and a refusal is reported as a firewall rejection with its support ID. Boundaries cached by earlier builds are retired, so maps look very slightly smoother.
  • --denominator census on county data sends one request per state, not per county (#4698).
  • A constant date column no longer hides the time series β€” a publication date stamped on every row used to outrank the real event date.
  • A schema from readstat --dictionary is a ready-made --dictionary for SAS/Stata/SPSS data, built from the file's own labels with no LLM β€” see Hand-tuning the Data Dictionary.
  • Vendored DataTables bundle bumped to dt-3.1.3/b-4.1.2/cc-2.1.2/date-2.1.2/sb-2.1.1, packaging releases whose only change the data viewer sees is smoother scrollX trackpad scrolling.
  • Static export finds chromedriver itself β€” see Driver setup.

Earlier viz changes (23.0.1)

  • The dashboard explains what it left out. Omissions used to go to stderr only, so the artifact people keep and share carried no record of them β€” a recipient saw five of twelve columns charted with no way to learn about the other seven. The --dict-info drawer now carries a per-column "not charted" note and a dataset-level panel-budget note.
  • The extensive-vs-intensive guess reads the data's own language. Deciding whether a measure headlines a Total or a Mean is done by reading the column name (unit_price, failure_rate, avg_temp are intensive; revenue is not) β€” and that vocabulary was English-only, so a Spanish dataset headlined "Total de Precio Unitario". Also fixed: per capita is now recognized in camelCase, and no longer fires inside upper_capital_limit.
  • --geocode no longer searches every populated place on Earth, so a US dataset stops resolving abroad.
  • Region-constant columns β€” a population, an area, a fleet size that repeats identically on every row of a region β€” are no longer summed across rows; the KPI tile headlines the region-deduped total.
  • Hover fixes: a % in a hover no longer prints as %%, and a %{...}-shaped column header is no longer eaten. Map-hover gap-filling suppression is resolved per row rather than per column.
  • The tour no longer describes a logarithmic bar panel as linear, and always spotlights a frequency distribution (panel selection used to push one panel per distinct overview kind first, so per-column picks were truncated away entirely on a rich dashboard).
  • Vendored DataTables bundle bumped to dt-3.0.3/b-4.0.2/cc-2.0.1/date-2.0.0/sb-2.0.0 β€” upstream patch releases only, notably an empty-string ^$ search-regex fix.

See also

⚠️ **GitHub.com Fallback** ⚠️