All docs

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 typeRequired rolesPromoted in sandbox charts.*?Notes
scatterplotx, yYes3D when view="3d" and a z role is provided
barchartVxYesVertical bars; y optional (defaults to count)
barchartHyYesHorizontal bars
linex, yYes
densitynone enforcedNoAccepted by create_chart; renderer reports missing-role errors at render time
gridnone enforcedNoAs above
stacksnone enforcedNoAs above
stripsnone enforcedNoAs above
treemapnone enforcedNoAs 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.

LayerLibraryOwnsWhere it’s wired
Spec composer(none, pure Python)The Insight: chart type, roles, scheme, signal values, viewtools/chart_builder.py:build_insight
Spec compiler@msrvida/sanddance-specs (via @msrvida/sanddance)Compiles an Insight to a Vega spec, computes scales/legendsfrontend/src/components/charts/sanddance.ts
Vega runtimevegaEvaluates the spec, drives signal updates, exposes data scopesSandDance.VegaMorphCharts.use(vega) at sanddance.ts:31
WebGL rendererMorphCharts (bundled inside @msrvida/sanddance)Draws cube marks, axes, gridlines; gamma-encodes color inputs in the pickgrid shaderConfigured 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:

  1. 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 loaded arrow_table (and no PostGIS-backed fallback) are rejected with an honest error.
  2. Converts the entry to a pandas DataFrame via _to_dataframe (tools/chart_handler.py:_to_dataframe), dropping the geometry column 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 an arrow_table.
  3. Strips empty role values from the model-supplied columns map so that a JSON-schema “any value” emission of "" for unused roles doesn’t blow up validation.
  4. Calls build_chart_card, then audit_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).
  5. Returns a ToolResult whose content includes 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-issuing create_chart five 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:

ShapePathNotes
v2-insightInsightViewer (lazy-imported)Live SandDance render against rows fetched via /fabric/datasets/{id}/data
v1-png<img> from image_b64Legacy matplotlib path; kept so historical conversations still render
anything elselogged warning + fallback panelForward-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_view attribution.
  • Atomic updates only, a selection lands at the gesture-complete boundary, never as streaming intermediate states.
  • clearSelection on 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, treemap flow through build_insight without 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 clean ValueError from the tool.
  • create_chart is 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” ToolResult doesn’t use the Schema hint: convention or raise HintException, so it currently renders to the model as Ok rather than a recoverable Hint. A test harness that distinguishes Hint from Err cannot yet tell “dataset not found” apart from “render succeeded” for this tool.
  • The legacy v1-png matplotlib path remains. New charts are always v2-insight, but historical conversations may carry v1 cards in structured_content. ChartCard.tsx still renders them. The path is small and stable; the cost of removing it is breaking replay of older conversations.
  • InsightViewer defensively guards against SandDance’s presenter going 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_id pattern is what makes Insight diffs work at all; tightening the contract is out of scope for the chart module alone.
  • signalValues defaults are duplicated between tools/chart_builder.py (backend build_insight, written into every Insight) and frontend/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

FileRole
tools/chart_handler.pycreate_chart system tool: dataset resolution, DataFrame conversion, audit, ToolResult assembly
tools/chart_builder.pyPure-Python Insight composer, build_chart_card, audit_chart_rows, validation
shared/insight_hash.pyStable hash of an Insight for snapshot dedup
shared/charts_sandbox.pycharts.* sandbox helpers injected into analyze_data namespace
tools/analysis.pySandbox wiring (make_charts_namespace), card surfacing path
frontend/src/components/cards/ChartCard.tsxCard-level discrimination between v1-png and v2-insight; expand/reset chrome
frontend/src/components/charts/sanddance.tsSandDance + Vega substrate loader, dark-mode color theming, Viewer factory
frontend/src/components/charts/InsightViewer.tsxViewer lifecycle, dataset fetch, imperative Insight push, auto-snapshot, cross-source filter
frontend/src/components/charts/ChartLegend.tsxLazy-loaded legend renderer
frontend/src/components/charts/selectionLayer.tsSession-scoped selection store (column predicates, not row ordinals)
frontend/src/components/charts/CubeDetailsPopover.tsxClick-to-detail popover anchored at cube coordinates
frontend/src/components/charts/ModifierHints.tsxModifier-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.cssDark-theme overrides for SandDance canvas overlays
  • 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