Chart libraries have historically been told how to render: props in, pixels out. Picking which chart to render is someone else's problem: a designer, a BI tool, the user, now very often an LLM. Semiotic 3.6.0 shipped a different approach: every chart now knows what data shapes it serves,which questions it answers well, andhow settings change those answers. Apply a profile of your data and you get a ranked list of charts, each with a config and an auditable reason for that chart. Pair it with a profile of your audience and the ranking calibrates to the needs of who is actually reading.
Why a recommendation engine, and why now
"What chart should I use?" has been answered three ways for the last decade, and none of them have landed well:
- Statistical heuristics (Voyager, Lux, Vega-Lite's auto-encodings). Picks "interesting" axes through statistical tests. Doesn't model human comprehension and doesn't recognize that the same chart with different settings answers different questions. They are also so tightly wed to libraries with just a handful of charts that they completely ignore the value of increasing data literacy through exposure.
- Let the LLM decide. Plausible-looking recommendations, occasionally correct, no offline mode, no diagnostic surface, no way to disagree without rewriting the prompt.
- Schema lookup. Tells you what's valid ("LineChart needs xAccessor + yAccessor") but says nothing about whether a line chart is the right answer for what you're trying to show.
The new layer takes a fourth position:charts know what they're good for, and we make that knowledge inspectable, composable, and overridable. The output is an ordinary array of suggestions you can render, log, snapshot-test, diff against a previous version, or hand to an LLM as structured context. The engine never calls an LLM itself; an LLM can sit on top of the engine but can't replace it.
Same data, different question, different chart
Concretely, here's what "auditable reason" buys you. One quarterly-revenue-by-region dataset, fed through suggestCharts twice with different intents. The component the engine picks changes, the props it emits change, and the reasons string explains why. This is the same output string an LLM or a snapshot test or a log line would consume. This is a key point: The things we build for human users like aggregations and hints and suggestions are useful for AI and vice versa but also are useful for traditional observability and analytics.
Same dataset (eight quarters of revenue and profit), two different questions, two different chart picks. The reasons string below each chart is what the engine emitted same string the LLM, the logs, and a snapshot test would see.
intent: trend→ AreaChart
"how is revenue moving over time?"
reasons:Strong fit for trend (5/5); x = quarter, y = revenue
intent: correlation→ ConnectedScatterplot
"how do revenue and profit move together?"
reasons:Strong fit for correlation (5/5); x = quarter, y = revenue
A playground for the impatient
That fixed example only scratches the surface. Pick a dataset, pick an audience, type a natural-language question. Each change re-ranks the suggestions live. The "stretch your literacy" row shows charts the audience is unfamiliar with but the data actually supports and only appears when you've selected an audience that has growth targets.
Dataset
Audience
Type a question
intent: trenddetected by inferIntent from your question.
★ Top picks
LineChart5.0/5 · fam 5
Strong fit for trend (5/5); x = quarter, y = revenue
AreaChart3.6/5 · fam 4
Strong fit for trend (3/5); x = quarter, y = revenue
⚠ showing only the leading "region" series — for multi-series comparison use LineChart or DifferenceChart
ConnectedScatterplot3.0/5 · fam 3
Strong fit for trend (3/5); x = quarter, y = revenue
⚠ readers can confuse path direction without explicit start/end markers
Notice what changes as you switch audience: under Executive, BoxPlot and ViolinPlot drop out of the top picks even when the data favors them, because the descriptor'srubric.familiarity for those charts has been replaced by the executive profile's familiarity number ("not familiar"). The same charts then surface in the stretch row alongside the rationale "growing distribution literacy" and labeled as opt-in, not pushed as defaults. Under Data scientist, the same charts move upthe main ranking, and PieChart drops because the persona ships a decrease target.
Three primitives compose the whole thing
The runtime entry points are all in semiotic/ai. They share the same data contract (rows in, structured suggestions out) so consumers can pick which surface fits their UI.
suggestCharts - ranked single recommendations
Given a dataset and an optional intent, returns the top-ranked charts that fit.
import { suggestCharts } from "semiotic/ai" const suggestions = suggestCharts(data, { intent: "trend" }) // → [ // { component: "LineChart", variant: { key: "smooth" }, // score: 4.8, intentScores: { trend: 5, "compare-series": 4, ... }, // rubric: { familiarity: 5, accuracy: 4, precision: 4 }, // reasons: ["Strong fit for trend (5/5)", "x = month, y = revenue"], // caveats: [], // props: { data, xAccessor: "month", yAccessor: "revenue" } // }, // { component: "AreaChart", ... }, // ]Every suggestion has a runnable props object. Drop it into the matching chart and it renders. No second pass to derive accessors from the profile.
suggestDashboard - composite, multi-intent views
Given a dataset, return a set of complementary panels each covering a distinct analytical intent, diversified by chart family by default. The "show me a dashboard" function call.
import { suggestDashboard } from "semiotic/ai" const { panels, intentsCovered, intentsMissing, stretchPanels } = suggestDashboard(data, { maxPanels: 6 }) // panels: [ // { intent: "trend", suggestion: { component: "LineChart", ... } }, // { intent: "rank", suggestion: { component: "BarChart", ... } }, // { intent: "distribution", suggestion: { component: "BoxPlot", ... } }, // ... // ] // intentsMissing: ["geo"] // honest about what the data can't showIntents the dataset can't honestly cover land in intentsMissing rather than getting a forced low-scoring suggestion. Better to say "this data doesn't support geo" than to ship a misleading map.
useChartInterrogation - the chat surface
A headless React hook that lets users ask natural-language questions about a chart and get back annotations the chart can render. Bring your own LLM via the onQuerycallback; the hook supplies the LLM with the same structured suggestion context as the library APIs.
import { useChartInterrogation } from "semiotic/ai" const { ask, history, annotations, loading } = useChartInterrogation({ data, componentName: "LineChart", props: { xAccessor: "month", yAccessor: "revenue" }, includeSuggestions: true, // engine context lands in onQuery onQuery: async (query, ctx) => { // ctx.summary, ctx.profile, ctx.suggestions are all there const response = await callYourLLM({ question: query, summary: ctx.summary, alternatives: ctx.suggestions, }) return { answer: response.text, annotations: response.highlights } }, }) return ( <> <LineChart {...props} annotations={annotations} /> <YourChatUI history={history} loading={loading} onAsk={ask} /> </> )The audience layer - where this gets interesting
Every chart's descriptor carries a rubric.familiarity number (1 - 5). That number has always been a guess at "what a generic data-literate reader recognizes." In practice it's nonsense. A quant fund and a marketing org have completely different familiarity baselines. So 3.6.0 adds AudienceProfile: a serializable artifact your organization produces (through surveys, telemetry, training records, manager judgment) and the library consumes:
const acmeFinanceTeam = { name: "Acme Finance", familiarity: { BarChart: 5, LineChart: 5, PieChart: 5, Histogram: 4, BoxPlot: 2, ViolinPlot: 1, Heatmap: 3, // ...anything not listed falls back to the descriptor default }, targets: { PieChart: { direction: "decrease", weight: 1, reason: "moving from share-by-angle to share-by-length for accuracy", }, BoxPlot: { direction: "increase", weight: 2, reason: "we want the team reading distributions, not just means", }, }, exposureLevel: 1, // include stretch picks in a separate surface } suggestCharts(data, { audience: acmeFinanceTeam, intent: "rank" }) suggestDashboard(data, { audience: acmeFinanceTeam }) suggestStretchCharts(data, { audience: acmeFinanceTeam })The library does not measure familiarity. That's not its job and it would tempt feature creep that's hostile to embedded use. Your organization owns the measurement using whatever survey, telemetry, or judgment tool produced the numbers and the library consumes the result as data.
The bias is meaningful, not cosmetic. A target with weight 2 adds ±2.0 to the chart's composite score, on a scale that normally tops out around 5. Strong enough to reorder rankings; small enough that a clearly-wrong chart still loses on data fit. When a target fires, the suggestion's reasons[] gains the verbatim rationale string so the audience's policy is visible in the UI:"Acme Finance: we want the team reading distributions, not just means."
Stretch picks - the "yes, and" of data visualization
You should always give your stakeholders what they want but you can build literacy by giving them more complex charts alongside it. This is the literacy-growth mechanic the audience layer enables. suggestStretchCharts(data, { audience }) returns charts where:
- The data actually supports it (the chart's
fits() gate passes). - The audience's effective familiarity is at or below 3.
- Either the audience has flagged it as an increase target, OR its score is within reach of the top familiar pick.
Each stretch carries a replacing field (which familiar chart it could substitute for) and a rationale string. If you render them in their own labeled surface, not inline with the default recommendations, then the user gets to see "here's what you'd normally pick" alongside "here's a vocabulary expansion opportunity." The playground above splits them into two rows for exactly this reason.
We deliberately did not collapse stretches into the main ranking. A stretch pick isintentionally not the best familiar choice so surfacing it as "the recommendation" would mislead. But it is a viable option that a team or organization might find useful to deploy for other reasons in place of the higher-ranked chart.
When to reach for this, and when not
Reach for it when:
- You're building any UI that needs to answer "what chart should I use?" (including chart-picker dropdowns, dashboard generators, AI assistant plumbing, or any internal-tools surface where the user knows their data shape but not the canonical rendering).
- You want recommendations that work without an LLM and get richer with one. The structured context (reasons, caveats, profile, intent scores) is straight prompt input.
- You're shipping the library to a specific audience whose chart literacy is meaningfully different from "generic data-literate user" such as the executive view of an enterprise dashboard, a scientific notebook environment, or a teaching tool for students.
- You want to nudge audience adoption toward more analytically appropriate charts over time. The stretch surface gives you a place to surface charts you'd like to see used more, without forcing them into defaults. This is key. Your organization might only be comfortable with a few charts but you are failing them if you do not help them to grow their data visualization literacy further by exposing them to the new patterns (and therefore new opportunities) that other charts afford.
Don't reach for it when:
- You already know exactly what chart you want. The suggestion engine is for openquestions; if you've decided on a BarChart, just render a BarChart.
- Your data shape doesn't change. The engine's value is recomputing recommendations across different data; on a static fixture, you can hardcode the answer.
- You'd be tempted to use it as a wrapper that replaces user choice. The point of the stretch surface is that the user sees both. A default-only recommender that hides the familiar pick is the wrong shape.
Wiring it up
Single recommendation
import { suggestCharts, LineChart, BarChart, /* ... */ } from "semiotic/ai" const COMPONENT_MAP = { LineChart, BarChart, /* ... */ } function SuggestedChart({ data, intent }) { const [top] = suggestCharts(data, { intent, maxResults: 1 }) if (!top) return <p>No fitting chart.</p> const Component = COMPONENT_MAP[top.component] return <Component {...top.props} /> }Dashboard mode
function GeneratedDashboard({ data, audience }) { const { panels, intentsMissing, stretchPanels } = suggestDashboard(data, { audience }) return ( <> <div className="grid"> {panels.map(({ intent, suggestion }) => { const Component = COMPONENT_MAP[suggestion.component] return ( <Panel key={intent} title={intent}> <Component {...suggestion.props} /> </Panel> ) })} </div> {stretchPanels.length > 0 && ( <StretchRow stretches={stretchPanels} audience={audience} /> )} {intentsMissing.length > 0 && ( <p>Not covered: {intentsMissing.join(", ")}</p> )} </> ) }Natural-language intent inference
import { inferIntent, suggestCharts } from "semiotic/ai" function AskTheData({ data, question }) { const inferred = inferIntent(question) const top = suggestCharts(data, { intent: inferred?.intent, maxResults: 1 })[0] if (!top) return null const Component = COMPONENT_MAP[top.component] return ( <> <p>Detected intent: <code>{inferred?.intent ?? "(none)"}</code></p> <Component {...top.props} /> </> ) }inferIntent is a zero-dependency regex-pattern heuristic. It never calls out. Wraps cleanly with an LLM-backed alternative if your audience uses jargon the defaults don't cover.
Where this pattern shows up next
Three near-term applications stand out:
- Authoring assistants. A natural-language chart editor sitting on top of the engine. User types "compare regions over time"; the editor uses
inferIntent + suggestCharts to produce a starting config, and the user iterates. - Auto-dashboards.
suggestDashboard + a templated panel renderer = "drop in a CSV, get a sensible dashboard." Pair with audience profiles and the dashboard adapts to who's logged in. - Data-product onboarding. An organization with a literacy growth program can ship two views of the same data: the familiar one as default, the stretch one as opt-in, both rendered by the same engine with the same data, audited against the same adoption targets.
- Chart Suggestions - full reference for
suggestCharts, intents, capability descriptors. - Interrogation -
useChartInterrogation with annotation-returning onQuery. - Capability Matrix - the AI-readable inventory of which charts support which features (SSR, push, linked hover, etc.).