Semiotic charts ship capability descriptors alongside their components. Each chart declares what data shapes it serves, which intents it answers, what variants change those answers, and which props to use for a given dataset. TheuseChartSuggestions hook walks the registry and returns a ranked, ready-to-render list. Heuristic only — no LLM call. Pair withuseChartInterrogation to let an LLM re-rank or narrate.
Interactive demo
Pick a dataset and (optionally) an intent. The same profile is evaluated against every registered capability and its variants. The top suggestion's props drop straight into the matching chart.
Two regions, six months of revenue. Time x-axis, categorical series.
Top renderable suggestion: AreaChart · Stepped
Engine's actual top pick was semiotic.recipe.waffle.v0 — not included in this demo's render map. See the all-suggestions sidebar for the full ranking.
All suggestions (ranked)
semiotic.recipe.waffle.v04.6/5
fam 4 · acc 4 · prec 2
x = month, y = revenue; 2 series detected on field "region"; 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.
AreaChart · Stepped4.1/5
fam 4 · acc 3 · prec 3
x = month, y = revenue; 2 series detected on field "region"; 12 rows is in the sweet spot for this chart
showing only the leading "region" series — for multi-series comparison use LineChart or DifferenceChart
DifferenceChart4.0/5
fam 3 · acc 4 · prec 4
x = month, y = revenue; 2 series detected on field "region"
AreaChart · Smooth gradient3.6/5
fam 4 · acc 3 · prec 3
x = month, y = revenue; 2 series detected on field "region"; 12 rows is in the sweet spot for this chart
showing only the leading "region" series — for multi-series comparison use LineChart or DifferenceChart
LineChart · With alert threshold3.6/5
fam 5 · acc 4 · prec 4
x = month, y = revenue; 2 series detected on field "region"
LineChart · Smooth trend3.3/5
fam 5 · acc 4 · prec 3
x = month, y = revenue; 2 series detected on field "region"
smoothing hides individual outliers
Shape profile
{ "rowCount": 12, "primary": { "x": "month", "y": "revenue", "series": "region" }, "seriesCount": 2, "uniqueXCount": 6, "hasRepeatedX": true, "monotonicX": false, "hasTimeAxis": false }How it composes
profileData(data) infers candidate x/y/series/category fields, distinct counts, monotonicity, and structure (hierarchy/network/geo).- For each capability:
fits(profile) is a hard gate (returns null to pass). intentScores are evaluated (numbers or profile-aware functions).- Variants apply additive
intentDeltas and rubricDeltas. - Suggestions are sorted by the requested intent (or mean across intents).
buildProps(profile, variant) returns spreadable props for the chart.
Implementation
JSX
import { useChartSuggestions, LineChart, BarChart, /* ... */ } from "semiotic/ai" const COMPONENT_MAP = { LineChart, BarChart, /* ... */ } function SuggestedChart({ data, intent }) { const { suggestions } = useChartSuggestions(data, { intent }) const top = suggestions[0] if (!top) return <p>No fitting chart for this data.</p> const Component = COMPONENT_MAP[top.component] return <Component {...top.props} /> }
Charts know what they're good for
Each chart's capability lives next to its TSX file (e.g.LineChart.capability.ts). It declares fits,intentScores, variants, caveats, andbuildProps. Variants encode the idea thatsettings change what a chart is good for — a stacked area with thestreamgraph variant boosts trend readability but penalizespart-to-whole (because totals become unreadable). Those tradeoffs surface in the suggestion's intentScores, caveats, andreasons.
Tying in interrogation
Set includeSuggestions: true on useChartInterrogation and the same ranked list lands in the LLM's context.suggestions. Use it to answer questions like "would another chart show this better?" without re-deriving rules.
Receivability — can the audience receive this chart?
Familiarity asks whether an audience knows a chart. Receivability asks whether they can perceive it in their channel — a different axis. An 8-slice pie is familiar to everyone and illegible to a screen reader. Declare the channel with receptionModality and the recommender audits each candidate, folding theaccessibility audit's verdict into the score:
TS
import { suggestCharts } from "semiotic/ai" const ranked = suggestCharts(data, { intent: "part-to-whole", audience: { receptionModality: "screen-reader" }, // visual | screen-reader | sonified | agent }) // A many-slice pie drops below a bar chart for this audience. The reason and // the receivability caveats ride the same fields as everything else: ranked.find(s => s.component === "PieChart") // .reasons → ["…", "Harder to receive via a screen reader: data density is inappropriate"] // .caveats → ["…", "8 slices in a part-to-whole chart. Chartability suggests ≤ 5 categories…"]
The visual path is unchanged and pays no audit cost — receivability only engages for a non-visual channel. Under the hood, applyAudienceBiasfolds a receivabilityBias penalty derived from the audit, and the receivability findings are distilled by accessibilityCaveats into the suggestion's caveats[] — so an agent reads perceptual and reception caveats from one array, not two. See thedescription layer and thereader-grounding payload for the rest of the reception surface.
Adding a custom capability
TS
import { registerChartCapability } from "semiotic/ai" registerChartCapability({ component: "MyDomainChart", family: "categorical", importPath: "semiotic", rubric: { familiarity: 2, accuracy: 4, precision: 4 }, fits: (profile) => profile.primary.category ? null : "needs a category field", intentScores: { "compare-categories": 5, "rank": 4 }, buildProps: (profile) => ({ data: profile.data, categoryAccessor: profile.primary.category, valueAccessor: profile.primary.y, }), })