Charts
Interactive charts: how a dataframe becomes an insight that renders identically in chat, the artifact panel, and focus mode.
Interactive charts in Eunice flow from a pandas DataFrame on the backend through a pure-Python “Insight” composer to a SandDance/Vega/MorphCharts renderer in the browser. The Insight is the contract: a JSON-serializable description of what to draw, hashed for snapshot dedup, and rendered identically wherever a chart card appears (inline chat stream, artifact panel, focus mode). This page describes how that path is wired today on Eunice-dev, where structural debt lives, and how chart output composes with the platform’s contract layers.
The model never sees the rendered image, it sees a textual summary from the tool result and trusts the platform to display the chart to the user. The “did the user see this?” signal (Chart rendered and shown to user: <title>) closes that loop and is load-bearing for preventing duplicate create_chart calls.
Supported chart types
Chart types are declared in tools/chart_builder.py as the ChartType literal and enforced in _VALID_CHARTS.
| Chart type | Required roles | Promoted in sandbox charts.*? | Notes |
|---|---|---|---|
scatterplot | x, y | Yes | 3D when view="3d" and a z role is provided |
barchartV | x | Yes | Vertical bars; y optional (defaults to count) |
barchartH | y | Yes | Horizontal bars |
line | x, y | Yes | |
density | none enforced | No | Accepted by create_chart; renderer reports missing-role errors at render time |
grid | none enforced | No | As above |
stacks | none enforced | No | As above |
strips | none enforced | No | As above |
treemap | none enforced | No | As above |
_REQUIRED_ROLES (tools/chart_builder.py) only constrains the four chart types it lists (scatterplot, barchartV, barchartH, line). The other five flow through unvalidated, they are real chart shapes SandDance can draw, but Eunice has not promoted them through the sandbox API yet. Errors land at render time as SandDance spec failures rather than as Python ValueErrors.
Valid role names are fixed in _VALID_ROLES (tools/chart_builder.py): uid, x, y, z, color, size, group, facet, facetV, sort. Unknown roles raise.
Library boundaries
Three libraries cooperate on the rendering side. The split matters for debugging and for any future renderer swap.
| Layer | Library | Owns | Where it’s wired |
|---|---|---|---|
| Spec composer | (none, pure Python) | The Insight: chart type, roles, scheme, signal values, view | tools/chart_builder.py:build_insight |
| Spec compiler | @msrvida/sanddance-specs (via @msrvida/sanddance) | Compiles an Insight to a Vega spec, computes scales/legends | frontend/src/components/charts/sanddance.ts |
| Vega runtime | vega | Evaluates the spec, drives signal updates, exposes data scopes | SandDance.VegaMorphCharts.use(vega) at sanddance.ts:31 |
| WebGL renderer | MorphCharts (bundled inside @msrvida/sanddance) | Draws cube marks, axes, gridlines; gamma-encodes color inputs in the pickgrid shader | Configured via EUNICE_DARK_COLORS at sanddance.ts:43-78 |
SandDance is framework-agnostic; Vega is injected once per session via use(vega) at sanddance.ts:28-33. The MorphCharts WebGL renderer treats color inputs as linear-space RGB and gamma-encodes them on write, this is why backgroundColor is #000000 (only pure black survives pow(color, 1/2.2) unchanged). Notes inline in sanddance.ts:48-78 capture the discovery.
The backend has no SandDance dependency. The Insight is a plain dict that mirrors @msrvida/sanddance-specs’ Insight interface; nothing about the spec is computed or validated in Python beyond required-role checks. This keeps tools/chart_builder.py pure and testable, and lets us swap renderers later without touching the tool handler.
How a chart flows from backend to ChartCard
There are two entry points to chart creation, both of which converge on build_chart_card.
Entry point 1, create_chart system tool
The tool handler _exec_create_chart lives in tools/chart_handler.py. It:
- Resolves the dataset from the conversation catalog via
_resolve_dataset(tools/chart_handler.py:_resolve_dataset), falling back from id lookup to name lookup. Datasets without a loadedarrow_table(and no PostGIS-backed fallback) are rejected with an honest error. - Converts the entry to a pandas DataFrame via
_to_dataframe(tools/chart_handler.py:_to_dataframe), dropping thegeometrycolumn if present, SandDance plots scalars, not WKB blobs. Spatial visualization belongs to the map module. PostGIS-backed entries (HIFLD, NHGIS, energystar_*) read their non-geometry columns directly from PostGIS instead of anarrow_table. - Strips empty role values from the model-supplied
columnsmap so that a JSON-schema “any value” emission of""for unused roles doesn’t blow up validation. - Calls
build_chart_card, thenaudit_chart_rows(tools/chart_builder.py:audit_chart_rows) to count how many rows will actually render. Rows with null x/y/z get silently dropped by SandDance; the audit surfaces the honest rendered count and recommends remediation (e.g.log10(x+1)for zero-bearing columns). - Returns a
ToolResultwhosecontentincludes the rendered phrase, any audit warning, and the canonical ”✓ Chart rendered and shown to user: <title>” signal (or “Chart updated in place” for an in-place update). That last line is the cue that prevents the model from re-issuingcreate_chartfive or six times in a row when it can’t see the rendered output itself.
Entry point 2, charts.* sandbox helpers
When the model calls analyze_data, the executor imports make_charts_namespace from shared/charts_sandbox.py and injects a charts object into the sandbox namespace. The helpers (charts.scatter, charts.bar, charts.line) compose the same Insight via build_chart_card and emit a chart card directly from the sandbox. Cards accumulate on context["_sandbox_card_collector"] and are folded into the returned ToolResult.cards at the end of the analyze_data/preview_data handlers. Matplotlib, pylab, seaborn, and plotly are all blocked from the sandbox (ANALYSIS_BLOCKED_PATTERNS in tools/analysis.py); v2 charts are the only public charting API in analyze_data.
The point of having both paths is composition: create_chart is callable as a discrete tool when the model has already loaded a catalog dataset; charts.* is callable mid-DataFrame-manipulation inside analyze_data without a round trip. Both produce identical card payloads.
Card payload contract
build_chart_card (tools/chart_builder.py:build_chart_card) returns the v2 shape:
{
"type": "chart",
"data": {
"title": str,
"shape": "v2-insight",
"insight": { ... SandDance Insight ... },
"setup": { ... },
"eunice": {
"dataset_id": str,
"insight_hash": "sha256:...",
"snapshot_file_id": null,
"selection_scope_id": str // defaults to dataset_id
}
}
}
insight_hash (shared/insight_hash) is computed once on the backend and used by the snapshot endpoint for idempotent capture-on-quiescent-render. selection_scope_id defaults to dataset_id so two charts of the same dataset share a selection scope; expanded mode can override for per-panel isolation.
The Vega signalValues defaults Text_ScaleSignal=0.7 and Text_AngleXSignal=90 (tools/chart_builder.py:build_insight) are tuned for EPA-scale datasets with hundreds of categorical x-values. SandDance’s stock defaults (1.2 / 30°) made labels overlap badly on 200+ ticks. The frontend mirrors the same floor via DEFAULT_SIGNAL_VALUES in frontend/src/components/charts/InsightViewer.tsx; values already in the Insight win.
Frontend rendering
ChartCard at frontend/src/components/cards/ChartCard.tsx discriminates on data.shape:
| Shape | Path | Notes |
|---|---|---|
v2-insight | InsightViewer (lazy-imported) | Live SandDance render against rows fetched via /fabric/datasets/{id}/data |
v1-png | <img> from image_b64 | Legacy matplotlib path; kept so historical conversations still render |
| anything else | logged warning + fallback panel | Forward-compat for asymmetric backend/frontend rollouts (ChartCard.tsx, the console.warn('[ChartCard] unknown shape...') branch) |
InsightViewer (frontend/src/components/charts/InsightViewer.tsx) owns the Viewer lifecycle: lazy-load the substrate on first mount (~380 KB gzipped, paid once per session), fetch dataset rows, instantiate the Viewer keyed by dataset_id, push Insight diffs through setInsight imperatively (never via React reconciliation), and auto-snapshot on first quiescent render. The wrapper deliberately exposes no chrome, legends, sliders, action buttons all come from SandDance’s canvas overlays. The RotateCcw reset button in ChartCard.tsx clears any active axis-click filter against the selection scope; it appeared after the field observation that SandDance’s axis-click trims the view but offers no UI to invert.
ChartLegend (alongside InsightViewer under frontend/src/components/charts/) is also lazy-loaded.
Selection layer
Charts publish column-predicate selections, not row ordinals, into a shared zustand store at frontend/src/components/charts/selectionLayer.ts. Predicates survive data changes and are what the LLM reads. When the user clicks an axis label, the scope’s selection is updated; useCrossSourceFilter in InsightViewer.tsx applies it as a Viewer filter (same-source AND cross-source land here, by design). The chat send pipeline serializes a snapshot into ChatRequest.selection_state and the backend renders it as [SELECTION_STATE] in the prompt. This is the “click the chart and the LLM knows” loop, and it is load-bearing for closing the analyst feedback cycle.
Invariants (see header comment in selectionLayer.ts):
- Single publisher per scope; concurrent same-source writes are last-write-wins by timestamp; cross-source races resolve by
source_viewattribution. - Atomic updates only, a selection lands at the gesture-complete boundary, never as streaming intermediate states.
clearSelectionon an empty scope is a no-op.
Expanded mode
Focus / expanded mode lives in frontend/src/components/charts/expanded/ (ExpandedChartMode.tsx, ChartPanel.tsx, ColorPanel.tsx, DataPanel.tsx, SettingsPanel.tsx, SnapshotsPanel.tsx, TransitionPanel.tsx, FilterChips.tsx, SearchPanel.tsx, plus a filterParser.test.ts test suite). This is the panel-based UI host. ChartCard.tsx dispatches a catalyst:focus-artifact event when the user clicks the maximize button; expanded mode subscribes. App.tsx renders the expanded shell for the active tab’s focused artifact (see Frontend for the per-tab focus-state architecture).
Honest data: the row-count audit
audit_chart_rows (tools/chart_builder.py:audit_chart_rows) exists because SandDance silently drops rows with null axis values. Before the audit, a chart of “34 rows” could render two cubes and the tool result would still say “Rendered (34 rows)”. The audit returns {total, rendered, dropped, null_by_role, warning, severity}; the handler interpolates the honest phrase into the tool result content and appends a remediation hint for common cases (log10 of zero, aggregations on empty groups). Severity fail (all rows would drop) suppresses the “shown to user” signal so the model knows it actually failed.
This is the chart-side instance of a platform-wide rule: when the rendering layer transforms data silently, the tool result must surface the transformation so the model can reason about what the user actually sees. The same principle drives the Scratch/Save contract and the Knowledge Layer Contract, see Tool System for the broader picture.
Composition with the Uniform Tool Contract
The Uniform Tool Contract, Ok | Hint | Err envelopes, the @uniform_contract wrapper, and the registry adapter that translates envelopes back to ToolResult for the orchestrator, is live, and the chart tools are covered by it. Full coverage of every registered tool is the invariant the CI ratchet enforces, rather than a state already reached: a small number of newer tools are registered nowhere and bypass the contract at dispatch. See Tool System for the full contract definition.
The migration was deliberately dispatch-boundary, not source-rewrite: ToolResult was not deleted and remains the shape handlers return and the orchestrator consumes internally. tools/chart_handler.py calls migrate_tool(_exec_create_chart, "create_chart", is_tabular=False) at import time (engine/contract_decorator.py:migrate_tool), which registers a ToolSpec rather than changing the handler’s body. ToolRegistry wraps any spec-registered handler with @uniform_contract at registration time, so the envelope is constructed automatically at dispatch from whatever _exec_create_chart already returns:
The legacy-translation rule (engine/contract_decorator.py:_translate_tool_result) checks, in order: a Schema hint: / Fetch hint: prefix on either ToolResult.error or .content → Hint; a plain .error → Err; otherwise → Ok (with effects.cards carrying whatever the ToolResult.cards held).
_exec_create_chart’s dataset-resolution failure (tools/chart_handler.py:_resolve_dataset) returns ToolResult(content=f"Dataset '{name}' not found in catalog....") with error left unset and no Schema hint: / Fetch hint: prefix, so today it translates to Ok, not Hint, under the bulk wrapper. _exec_create_chart does not raise HintException anywhere in the current source. This is a real gap for anyone finishing the chart tool’s migration, not a docs error: the mechanical bulk migration made every tool contract-shaped, but create_chart still needs the resolver rewritten to use the hint convention (or HintException) before “dataset not found” reads as a recoverable correction to the model instead of a success.
tools/analysis.py migrates analyze_data and preview_data the same way (migrate_tool(_exec_analyze_data, "analyze_data", is_tabular=True)), so the charts.* sandbox helpers ride the same contract via their host tool.
The row-count audit and the canonical “shown to user” signal are unaffected either way, they’re plain text produced inside the handler before it ever reaches the wrapper, so nothing about audit_chart_rows or the ”✓ Chart rendered and shown to user” phrase changed with the migration.
Structural debt
Items load-bearing for a developer landing here:
- Five chart types accept inputs unvalidated.
density,grid,stacks,strips,treemapflow throughbuild_insightwithout required-role checks. SandDance will report missing roles at render time, but the failure surfaces as a runtime spec error in the browser rather than a cleanValueErrorfrom the tool. create_chartis bulk-migrated to the Uniform Tool Contract, but its resolver isn’t hint-aware yet. The tool is registered under@uniform_contract(see above), but_resolve_dataset’s “not found”ToolResultdoesn’t use theSchema hint:convention or raiseHintException, so it currently renders to the model asOkrather than a recoverableHint. A test harness that distinguishesHintfromErrcannot yet tell “dataset not found” apart from “render succeeded” for this tool.- The legacy
v1-pngmatplotlib path remains. New charts are always v2-insight, but historical conversations may carry v1 cards instructured_content.ChartCard.tsxstill renders them. The path is small and stable; the cost of removing it is breaking replay of older conversations. InsightViewerdefensively guards against SandDance’spresentergoing null when the host React tree unmounts and remounts. This catches tab-switch crashes in the artifact panel but indicates the Viewer lifecycle is fragile under React’s render model. The imperative key-on-dataset_idpattern is what makes Insight diffs work at all; tightening the contract is out of scope for the chart module alone.signalValuesdefaults are duplicated betweentools/chart_builder.py(backendbuild_insight, written into every Insight) andfrontend/src/components/charts/InsightViewer.tsx(DEFAULT_SIGNAL_VALUES, a frontend floor for older payloads that omit them). Both must stay in sync until backend coverage is provably 100%.
Key file map
| File | Role |
|---|---|
tools/chart_handler.py | create_chart system tool: dataset resolution, DataFrame conversion, audit, ToolResult assembly |
tools/chart_builder.py | Pure-Python Insight composer, build_chart_card, audit_chart_rows, validation |
shared/insight_hash.py | Stable hash of an Insight for snapshot dedup |
shared/charts_sandbox.py | charts.* sandbox helpers injected into analyze_data namespace |
tools/analysis.py | Sandbox wiring (make_charts_namespace), card surfacing path |
frontend/src/components/cards/ChartCard.tsx | Card-level discrimination between v1-png and v2-insight; expand/reset chrome |
frontend/src/components/charts/sanddance.ts | SandDance + Vega substrate loader, dark-mode color theming, Viewer factory |
frontend/src/components/charts/InsightViewer.tsx | Viewer lifecycle, dataset fetch, imperative Insight push, auto-snapshot, cross-source filter |
frontend/src/components/charts/ChartLegend.tsx | Lazy-loaded legend renderer |
frontend/src/components/charts/selectionLayer.ts | Session-scoped selection store (column predicates, not row ordinals) |
frontend/src/components/charts/CubeDetailsPopover.tsx | Click-to-detail popover anchored at cube coordinates |
frontend/src/components/charts/ModifierHints.tsx | Modifier-key affordances overlay |
frontend/src/components/charts/expanded/ | Focus / panel-based UI: chart, color, data, search, settings, snapshots, transition panels |
frontend/src/components/charts/sanddance-eunice.css | Dark-theme overrides for SandDance canvas overlays |
Related
- Tool System,
ToolRegistry, dispatch path, Uniform Tool Contract - Engine, orchestrator, turn lifecycle, effect accumulator
- Frontend, artifact system, card rendering, Display Protocol
- Glossary, Insight, selection scope, snapshot, expanded mode