Suggestions usually answer the question "what chart for this data?" But the data you pass to suggestCharts() is often a sample — a few hundred rows pulled from a production dataset that has tens or hundreds of thousands. The right chart for the sample is almost never the right chart for production. Scale-aware suggestionslet you declare what the production data actually looks like — and get back a graduation of views that work at each band.
Why declare scale instead of measuring it
Semiotic doesn't profile the data to figure out its production scale, the same way it doesn't profile your team to figure out their chart literacy. Both are descriptive, not observational. Three reasons it matters:
- Samples lie. A 100-row sample of a million-row table picks a bar chart; a million rows wants a heatmap.
- Forward-looking. "We're shipping a dashboard that will run on a windowed view of the last 30 days, ~50k rows" — that's what to plan for, not the seed fixture.
- Org-level standardization. Your shop's definition of "small" might be 10k rows. The thresholds are overridable per profile.
Interactive demo
Pick a dataset, set an intent, and declare what the production scale will look like. The graduation row shows the top recommendation at every band; the panel below is the full ranked list at the band your declared (or measured) data lives in.
Region × product × revenue, exposed both as a flat tabular sample and as a hierarchy. The engine sees both shapes, so it can pick a treemap when the hierarchy is what matters and a heatmap when density does.
Graduation across scales
Same data, same intent, evaluated at each row band. The card outlined in primary is the band your declared (or measured) data falls into.
Tiny (≤3)your data
BigNumber ·5.8/5
revenue
600
−350(-36.8%)vs previous
Strong fit for part-to-whole (5/5)
Medium (~140)
Treemap ·5.4/5
Strong fit for part-to-whole (5/5)
rectangle area comparisons are less precise than length — prefer a bar chart for ranking
Huge (>5k)
semiotic.recipe.waffle.v0 ·5.3/5
(semiotic.recipe.waffle.v0 not in this page's preview map)
Strong fit for part-to-whole (5/5)
precise comparison may be harder than a bar chart
Top suggestions at effective scale
Full ranked list for 6 rows (Small (~15)) — measured from data.
Treemap5.40/5
scale band: small (measured) · cardinality: low
Strong fit for part-to-whole (5/5); 2 series detected on field "product"; 6 leaves is in the legible band for treemap
rectangle area comparisons are less precise than length — prefer a bar chart for ranking
semiotic.recipe.waffle.v05.25/5
scale band: small (measured) · cardinality: low
Strong fit for part-to-whole (5/5); 2 series detected on field "product"; Repeated units directly expose how categories compose a total.; Unit counts support broad comparisons, though bars remain more precise.; Why leave the catalog: A pie chart would show share but provide less unit-level evidence and less interaction surface.; Designed for visual reception; Portable registered recipe
precise comparison may be harder than a bar chart; individual cells can become navigation noise; too many categories; too many units; false precision; treating each cell as a separate semantic datum; Rounded cell allocation communicates only the precision supported by the unit count.; Use a ranked bar when exact pairwise comparison is the primary task.
StackedBarChart · 100% stacked5.00/5
scale band: small (measured) · cardinality: low
Strong fit for part-to-whole (5/5); 2 series detected on field "product"
only the bottom segment shares a baseline; others are harder to compare across categories; absolute magnitudes are no longer comparable across bars
PieChart · Pie4.40/5
scale band: small (measured) · cardinality: low
Strong fit for part-to-whole (4/5); 2 series detected on field "product"
angle comparisons are less accurate than length — prefer a bar chart unless part-to-whole is the explicit message
BarChart · Ranked4.00/5
scale band: small (measured) · cardinality: low
Strong fit for part-to-whole (3/5); 2 series detected on field "product"; 6 rows is in the sweet spot for this chart
Declared schema
The schema
TS
import type { DataScaleProfile, DataQualityProfile } from "semiotic/ai" const scale: DataScaleProfile = { // Production row count — declared, not measured. Accepts a band string // ("medium") or an exact number. rows: 50_000, // Per-field cardinality. Key for categorical charts: bar at 5 categories // vs treemap at 500. cardinality: { region: 12, status: "low" }, // How does the data grow? Affects streaming recommendations. growth: "windowed", // Org-level overrides — what counts as "small" / "medium" / "large". thresholds: { rows: { small: 1_000, medium: 100_000 } }, // Per-chart preferences. Like AudienceProfile.targets, but for scale. charts: { Heatmap: { minBand: "medium", reason: "cells dominate noise below 50 rows" }, PieChart: { maxBand: "small", reason: "pie at large scale is just noise" }, }, } const quality: DataQualityProfile = { completeness: { revenue: 0.98, cohort: 0.62 }, outliers: { revenue: 0.04 }, }
The graduation surface
suggestChartsGrouped(data, options) returns suggestions tiered by row band. It runs the engine once per band, pinning the row count to that band's representative midpoint, then returns the per-band ranked lists plus the effective scale view.
TS
import { suggestChartsGrouped } from "semiotic/ai" const grouped = suggestChartsGrouped(data, { intent: "trend", scale: { rows: 50_000 }, }) // Render the tier for the user's actual band, plus a "what about at 10×?" peek const todayTier = grouped[grouped.effective.rowBand][0] const tomorrowTier = grouped.huge[0]
How the bias composes
profileData(data) measures the sample (row count, distinct counts, field types).computeEffectiveScale(profile, scale) merges declared scale with measured fallbacks.- For each capability:
fits() still hard-gates first. intentScores evaluate against the profile.applyAudienceBias() shifts score by familiarity and adoption targets.applyScaleBias() callscapability.scaleFit(profile, effectiveScale, scale), applies per-chart preferences, and adds caveats from qualityFit().- Suggestions sort by the final composite. Each suggestion carries a
scaleRangetag.
Declaring a chart's scale fit
Capability authors expose scaleFit on their descriptor. ThescaleHints() helper covers the simple "sweet spot" case; for more nuanced logic, write the function yourself.
TS
import { scaleHints } from "semiotic/ai" export const BarChartCapability: ChartCapability = { // ... scaleFit: scaleHints({ cardinality: { sweetSpot: [3, 15], caveatAbove: 25 }, rows: { sweetSpot: [3, 200] }, }), } // Or with explicit logic: export const ForceDirectedGraphCapability: ChartCapability = { // ... scaleFit: (profile) => { const n = profile.network?.nodes.length ?? 0 if (n < 5) return { delta: -0.4, caveats: [`only ${n} nodes — overkill`] } if (n <= 100) return { delta: 0.5, reason: `${n} nodes — readable` } if (n <= 300) return { delta: 0 } return { delta: -0.8, caveats: [`${n} nodes — hairball; filter first`] } }, }
Thresholds and where they came from
The default row breakpoints sit at 3 / 25 / 250 / 5,000. They're starting points grounded in the visualization-perception literature:
- tiny ≤ 3 — single-value territory; recommendation often "a value, not a chart."
- small ≤ 25 — Miller's 7±2 plus a comfortable buffer; bar/pie/dot legible.
- medium ≤ 250 — Cleveland-McGill sweet spot for position-encoded marks.
- large ≤ 5,000 — Munzner ch. 10 quantitative encoding; dense scatter/heatmap/ridgeline still legible.
- huge > 5,000 — aggregation, sampling, density reduction required.
Override per profile with DataScaleProfile.thresholds. The defaults can — and should — bend to your shop's conventions.
The single-value gap (now filled)
At tiny scale, the engine used to only haveGaugeChart, and a gauge is misleading without an explicit min / max. BigNumber (under semiotic/value) ships as the honest answer for unbounded single-value data — and the capability layer wires it into suggestCharts /suggestChartsGrouped with a scaleFit boost that puts it ahead of GaugeChart at thetiny band. See/charts/big-number for the full component, and the roadmap entry for SingleValueFrame for what a future frame-backed version would inherit.
Next iteration: composition suggestions — sets of N charts where a BigNumber can host the others via itstrendSlot / chartSlot rather than the dashboard rendering them as peer cards. Documented in the roadmap; not part of this PR.