From 0a996ae14aa68b69c1364c6fa53814f2b1789deb Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Sun, 24 May 2026 20:47:20 -0700 Subject: [PATCH 01/11] Add AI interrogation and chart-suggestion features Introduce a headless conversational interrogation API and a chart capability/suggestion layer under semiotic/ai. Adds useChartInterrogation, data summarization, profileData, suggestCharts, scoreChart, useChartSuggestions, registration APIs, MCP tooling (interrogateChart/suggestCharts) and server integration. Includes many new TypeScript implementations and tests, a large set of chart capability descriptors, docs and examples (blog entries, pages, and README/guide updates), new scorecard scripts and a capability-scorecard JSON, an IMPROVEMENTS roadmap, and minor metadata updates (package.json, changelog entry for 3.6.0, server.json). Enables LLM-driven "chat with the chart" workflows and heuristic chart recommendations. --- .clinerules | 58 + .cursorrules | 58 + .github/copilot-instructions.md | 58 + .windsurfrules | 58 + CHANGELOG.md | 2 + CLAUDE.md | 58 + IMPROVEMENTS.md | 72 + ai/capability-scorecard.json | 1211 +++++++++++++++++ ai/dist/mcp-server.js | 247 ++++ ai/mcp-server.ts | 331 ++++- ai/schema.json | 2 +- docs/public/blog/feed.xml | 28 +- .../og/charts-that-know-what-theyre-for.png | Bin 0 -> 58757 bytes docs/public/blog/og/release-3-5-4.png | Bin 0 -> 67422 bytes docs/public/llms-full.txt | 58 + docs/src/App.js | 24 +- docs/src/blog/components/BlogEntryView.js | 34 + docs/src/blog/entries-meta.js | 56 +- docs/src/blog/entries.js | 33 +- .../blog/entries/anchored-conversations.js | 668 +++++++++ .../charts-that-know-what-theyre-for.js | 793 +++++++++++ .../entries/live-conversational-dashboard.js | 628 +++++++++ docs/src/blog/entries/multimodal-response.js | 418 ++++++ docs/src/blog/entries/release-3-5-4.js | 123 +- docs/src/components/navData.js | 18 +- docs/src/pages/features/CapabilitiesPage.js | 8 +- docs/src/pages/features/InterrogationPage.js | 260 ++++ .../pages/features/ObservationHooksPage.js | 10 +- docs/src/pages/features/PushApiPage.js | 2 +- docs/src/pages/features/SerializationPage.js | 8 +- docs/src/pages/features/SuggestionsPage.js | 348 +++++ .../pages/features/VegaLiteTranslatorPage.js | 7 +- package-lock.json | 4 +- package.json | 17 +- scripts/check-blog-entry-sync.mjs | 27 +- scripts/check-capability-coverage.mjs | 114 ++ scripts/run-capability-scorecard.mjs | 69 + scripts/scorecard-dev.ts | 26 + server.json | 4 +- src/components/ai/audienceProfile.test.ts | 163 +++ src/components/ai/audienceProfile.ts | 143 ++ src/components/ai/audiences.ts | 224 +++ src/components/ai/chartCapabilities.ts | 193 +++ src/components/ai/chartCapabilityTypes.ts | 219 +++ src/components/ai/diffProfile.test.ts | 68 + src/components/ai/diffProfile.ts | 131 ++ src/components/ai/inferIntent.test.ts | 51 + src/components/ai/inferIntent.ts | 180 +++ src/components/ai/intents.ts | 147 ++ src/components/ai/profileData.test.ts | 58 + src/components/ai/profileData.ts | 365 +++++ src/components/ai/qualityFixtures.ts | 395 ++++++ src/components/ai/qualityScorecard.test.ts | 47 + src/components/ai/qualityScorecard.ts | 228 ++++ src/components/ai/repairChartConfig.test.ts | 77 ++ src/components/ai/repairChartConfig.ts | 122 ++ src/components/ai/streamingTypes.ts | 73 + src/components/ai/suggestCharts.test.ts | 205 +++ src/components/ai/suggestCharts.ts | 312 +++++ src/components/ai/suggestDashboard.test.ts | 92 ++ src/components/ai/suggestDashboard.ts | 223 +++ src/components/ai/suggestStreamCharts.test.ts | 95 ++ src/components/ai/suggestStreamCharts.ts | 167 +++ .../ai/suggestStretchCharts.test.ts | 126 ++ src/components/ai/suggestStretchCharts.ts | 158 +++ src/components/ai/useChartSuggestions.ts | 53 + .../charts/geo/ChoroplethMap.capability.ts | 23 + .../geo/DistanceCartogram.capability.ts | 23 + .../charts/geo/FlowMap.capability.ts | 23 + .../geo/ProportionalSymbolMap.capability.ts | 25 + .../charts/network/ChordDiagram.capability.ts | 27 + .../charts/network/CirclePack.capability.ts | 25 + .../network/ForceDirectedGraph.capability.ts | 33 + .../charts/network/OrbitDiagram.capability.ts | 22 + .../network/ProcessSankey.capability.ts | 31 + .../network/SankeyDiagram.capability.ts | 28 + .../charts/network/TreeDiagram.capability.ts | 25 + .../charts/network/Treemap.capability.ts | 26 + .../charts/ordinal/BarChart.capability.ts | 63 + .../charts/ordinal/BoxPlot.capability.ts | 30 + .../charts/ordinal/DonutChart.capability.ts | 29 + .../charts/ordinal/DotPlot.capability.ts | 34 + .../charts/ordinal/FunnelChart.capability.ts | 35 + .../charts/ordinal/GaugeChart.capability.ts | 34 + .../ordinal/GroupedBarChart.capability.ts | 32 + .../charts/ordinal/Histogram.capability.ts | 48 + .../charts/ordinal/Histogram.test.tsx | 17 + src/components/charts/ordinal/Histogram.tsx | 14 +- .../charts/ordinal/LikertChart.capability.ts | 34 + .../charts/ordinal/PieChart.capability.ts | 50 + .../ordinal/RidgelinePlot.capability.ts | 30 + .../ordinal/StackedBarChart.capability.ts | 53 + .../charts/ordinal/SwarmPlot.capability.ts | 29 + .../ordinal/SwimlaneChart.capability.ts | 30 + .../charts/ordinal/ViolinPlot.capability.ts | 39 + .../realtime/RealtimeHeatmap.capability.ts | 35 + .../realtime/RealtimeHistogram.capability.ts | 27 + .../realtime/RealtimeLineChart.capability.ts | 46 + .../realtime/RealtimeSwarmChart.capability.ts | 31 + .../RealtimeWaterfallChart.capability.ts | 31 + .../realtime/TemporalHistogram.capability.ts | 37 + .../charts/xy/AreaChart.capability.ts | 61 + .../charts/xy/BubbleChart.capability.ts | 32 + .../charts/xy/CandlestickChart.capability.ts | 39 + .../xy/ConnectedScatterplot.capability.ts | 32 + .../charts/xy/DifferenceChart.capability.ts | 38 + .../charts/xy/Heatmap.capability.ts | 86 ++ .../charts/xy/LineChart.capability.ts | 103 ++ .../charts/xy/MinimapChart.capability.ts | 31 + .../xy/MultiAxisLineChart.capability.ts | 42 + .../charts/xy/QuadrantChart.capability.ts | 42 + .../charts/xy/Scatterplot.capability.ts | 62 + .../charts/xy/StackedAreaChart.capability.ts | 69 + src/components/data/DataSummarizer.test.ts | 97 ++ src/components/data/DataSummarizer.ts | 184 +++ src/components/semiotic-ai.ts | 169 +++ src/components/store/useChartFocus.test.tsx | 103 ++ src/components/store/useChartFocus.ts | 83 ++ .../store/useChartInterrogation.test.tsx | 247 ++++ src/components/store/useChartInterrogation.ts | 270 ++++ 120 files changed, 13052 insertions(+), 112 deletions(-) create mode 100644 IMPROVEMENTS.md create mode 100644 ai/capability-scorecard.json create mode 100644 docs/public/blog/og/charts-that-know-what-theyre-for.png create mode 100644 docs/public/blog/og/release-3-5-4.png create mode 100644 docs/src/blog/entries/anchored-conversations.js create mode 100644 docs/src/blog/entries/charts-that-know-what-theyre-for.js create mode 100644 docs/src/blog/entries/live-conversational-dashboard.js create mode 100644 docs/src/blog/entries/multimodal-response.js create mode 100644 docs/src/pages/features/InterrogationPage.js create mode 100644 docs/src/pages/features/SuggestionsPage.js create mode 100644 scripts/check-capability-coverage.mjs create mode 100644 scripts/run-capability-scorecard.mjs create mode 100644 scripts/scorecard-dev.ts create mode 100644 src/components/ai/audienceProfile.test.ts create mode 100644 src/components/ai/audienceProfile.ts create mode 100644 src/components/ai/audiences.ts create mode 100644 src/components/ai/chartCapabilities.ts create mode 100644 src/components/ai/chartCapabilityTypes.ts create mode 100644 src/components/ai/diffProfile.test.ts create mode 100644 src/components/ai/diffProfile.ts create mode 100644 src/components/ai/inferIntent.test.ts create mode 100644 src/components/ai/inferIntent.ts create mode 100644 src/components/ai/intents.ts create mode 100644 src/components/ai/profileData.test.ts create mode 100644 src/components/ai/profileData.ts create mode 100644 src/components/ai/qualityFixtures.ts create mode 100644 src/components/ai/qualityScorecard.test.ts create mode 100644 src/components/ai/qualityScorecard.ts create mode 100644 src/components/ai/repairChartConfig.test.ts create mode 100644 src/components/ai/repairChartConfig.ts create mode 100644 src/components/ai/streamingTypes.ts create mode 100644 src/components/ai/suggestCharts.test.ts create mode 100644 src/components/ai/suggestCharts.ts create mode 100644 src/components/ai/suggestDashboard.test.ts create mode 100644 src/components/ai/suggestDashboard.ts create mode 100644 src/components/ai/suggestStreamCharts.test.ts create mode 100644 src/components/ai/suggestStreamCharts.ts create mode 100644 src/components/ai/suggestStretchCharts.test.ts create mode 100644 src/components/ai/suggestStretchCharts.ts create mode 100644 src/components/ai/useChartSuggestions.ts create mode 100644 src/components/charts/geo/ChoroplethMap.capability.ts create mode 100644 src/components/charts/geo/DistanceCartogram.capability.ts create mode 100644 src/components/charts/geo/FlowMap.capability.ts create mode 100644 src/components/charts/geo/ProportionalSymbolMap.capability.ts create mode 100644 src/components/charts/network/ChordDiagram.capability.ts create mode 100644 src/components/charts/network/CirclePack.capability.ts create mode 100644 src/components/charts/network/ForceDirectedGraph.capability.ts create mode 100644 src/components/charts/network/OrbitDiagram.capability.ts create mode 100644 src/components/charts/network/ProcessSankey.capability.ts create mode 100644 src/components/charts/network/SankeyDiagram.capability.ts create mode 100644 src/components/charts/network/TreeDiagram.capability.ts create mode 100644 src/components/charts/network/Treemap.capability.ts create mode 100644 src/components/charts/ordinal/BarChart.capability.ts create mode 100644 src/components/charts/ordinal/BoxPlot.capability.ts create mode 100644 src/components/charts/ordinal/DonutChart.capability.ts create mode 100644 src/components/charts/ordinal/DotPlot.capability.ts create mode 100644 src/components/charts/ordinal/FunnelChart.capability.ts create mode 100644 src/components/charts/ordinal/GaugeChart.capability.ts create mode 100644 src/components/charts/ordinal/GroupedBarChart.capability.ts create mode 100644 src/components/charts/ordinal/Histogram.capability.ts create mode 100644 src/components/charts/ordinal/LikertChart.capability.ts create mode 100644 src/components/charts/ordinal/PieChart.capability.ts create mode 100644 src/components/charts/ordinal/RidgelinePlot.capability.ts create mode 100644 src/components/charts/ordinal/StackedBarChart.capability.ts create mode 100644 src/components/charts/ordinal/SwarmPlot.capability.ts create mode 100644 src/components/charts/ordinal/SwimlaneChart.capability.ts create mode 100644 src/components/charts/ordinal/ViolinPlot.capability.ts create mode 100644 src/components/charts/realtime/RealtimeHeatmap.capability.ts create mode 100644 src/components/charts/realtime/RealtimeHistogram.capability.ts create mode 100644 src/components/charts/realtime/RealtimeLineChart.capability.ts create mode 100644 src/components/charts/realtime/RealtimeSwarmChart.capability.ts create mode 100644 src/components/charts/realtime/RealtimeWaterfallChart.capability.ts create mode 100644 src/components/charts/realtime/TemporalHistogram.capability.ts create mode 100644 src/components/charts/xy/AreaChart.capability.ts create mode 100644 src/components/charts/xy/BubbleChart.capability.ts create mode 100644 src/components/charts/xy/CandlestickChart.capability.ts create mode 100644 src/components/charts/xy/ConnectedScatterplot.capability.ts create mode 100644 src/components/charts/xy/DifferenceChart.capability.ts create mode 100644 src/components/charts/xy/Heatmap.capability.ts create mode 100644 src/components/charts/xy/LineChart.capability.ts create mode 100644 src/components/charts/xy/MinimapChart.capability.ts create mode 100644 src/components/charts/xy/MultiAxisLineChart.capability.ts create mode 100644 src/components/charts/xy/QuadrantChart.capability.ts create mode 100644 src/components/charts/xy/Scatterplot.capability.ts create mode 100644 src/components/charts/xy/StackedAreaChart.capability.ts create mode 100644 src/components/data/DataSummarizer.test.ts create mode 100644 src/components/data/DataSummarizer.ts create mode 100644 src/components/store/useChartFocus.test.tsx create mode 100644 src/components/store/useChartFocus.ts create mode 100644 src/components/store/useChartInterrogation.test.tsx create mode 100644 src/components/store/useChartInterrogation.ts diff --git a/.clinerules b/.clinerules index b333fda8..fd75036b 100644 --- a/.clinerules +++ b/.clinerules @@ -267,6 +267,64 @@ Canvas scene builders read CSS variables via `getComputedStyle` on the canvas DO ## AI Features `onObservation`/`useChartObserver`, `toConfig`/`fromConfig`/`toURL`/`fromURL`/`copyConfig`/`configToJSX`, `validateProps(component, props)`, `diagnoseConfig(component, props)`, `exportChart(div, { format })`, `npx semiotic-ai --doctor` +### Conversational Interrogation (`semiotic/ai`) +Headless hook for "chat with the chart" interactions. The library ships no UI — bring your own chat surface. +- **`useChartInterrogation({ data, onQuery, componentName?, props?, initialAnnotations? })`** → `{ ask(query), history, summary, annotations, loading, error, reset }` +- **`onQuery: (query, context) => Promise<{ answer, annotations? }>`** — call your LLM here. `context` is `{ data, summary, componentName?, props? }`. +- **`summary`**: LLM-friendly statistical summary (`rowCount`, per-field `{ min, max, mean, median }` for numerics, top-k for categoricals, ISO range for dates). Available before any ask(). +- **`annotations`**: Merged `initialAnnotations` + latest AI response. Wire to the chart's `annotations` prop for visual highlighting. +- **`summarizeData(data, options?)`**: Standalone for server-side prompting or batch jobs. +- **MCP Tool**: `interrogateChart(component, props, query)` returns the same statistical summary and AI-facing instructions. + +```jsx +import { LineChart, useChartInterrogation } from "semiotic/ai" + +function InterrogatableChart({ data }) { + const { ask, history, annotations, loading } = useChartInterrogation({ + data, + componentName: "LineChart", + props: { xAccessor: "month", yAccessor: "revenue" }, + onQuery: async (query, { summary }) => { + const res = await myLLMCall(query, summary) + return { answer: res.text, annotations: res.highlights } + }, + }) + return ( + <> + + + + ) +} +``` + +### Chart Capability Layer (`semiotic/ai`) +Heuristic chart-suggestion engine. Charts ship capability descriptors next to their TSX files; the engine ranks them against a profiled dataset by intent. No LLM call required. + +- **`profileData(data, { rawInput?, seriesField? })`** → `ChartDataProfile` (extends `DataSummary`): candidate fields per role (x/y/series/category/size/time), distinct counts, monotonicity, structure detection (hierarchy/network/geo). +- **`suggestCharts(data, { intent?, allow?, deny?, maxResults?, includeVariants?, minScore? })`** → ranked `Suggestion[]` with `{ component, family, importPath, variant?, score, intentScores, rubric, reasons, caveats, props }`. `props` is spreadable directly into the matching chart. +- **`scoreChart(component, data, { intent?, variantKey? })`** → evaluate a specific chart for a dataset (does it fit, how well, why/why not). +- **`useChartSuggestions(data, options)`** → memoized React hook returning `{ suggestions, profile }`. +- **`registerChartCapability(capability)`** / **`unregisterChartCapability(name)`** — runtime registration for custom charts. +- **Intent taxonomy**: 13 built-in intents (`trend`, `compare-series`, `compare-categories`, `rank`, `part-to-whole`, `distribution`, `correlation`, `flow`, `hierarchy`, `geo`, `outlier-detection`, `composition-over-time`, `change-detection`). Extend via `registerIntent(descriptor)`. +- **Capability authoring**: create `Foo.capability.ts` next to `Foo.tsx`, then append to the registry in `src/components/ai/chartCapabilities.ts`. Each capability declares `family`, `rubric` (familiarity/accuracy/precision 1-5), `fits(profile)` gate, `intentScores`, optional `variants` with `intentDeltas`, and `buildProps(profile, variant)`. +- **Variants encode that settings change what a chart is good for**: e.g. `StackedAreaChart`'s `streamgraph` variant boosts trend but penalizes part-to-whole. +- **Interrogation tie-in**: pass `includeSuggestions: true` to `useChartInterrogation` and the same ranked list lands in `context.suggestions` for the LLM. +- **MCP tool**: `suggestCharts(data, intent?)` returns the ranked list as structured content. + +```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 null + const Component = COMPONENT_MAP[top.component] + return +} +``` + ## AI Behavior Contracts diff --git a/.cursorrules b/.cursorrules index b333fda8..fd75036b 100644 --- a/.cursorrules +++ b/.cursorrules @@ -267,6 +267,64 @@ Canvas scene builders read CSS variables via `getComputedStyle` on the canvas DO ## AI Features `onObservation`/`useChartObserver`, `toConfig`/`fromConfig`/`toURL`/`fromURL`/`copyConfig`/`configToJSX`, `validateProps(component, props)`, `diagnoseConfig(component, props)`, `exportChart(div, { format })`, `npx semiotic-ai --doctor` +### Conversational Interrogation (`semiotic/ai`) +Headless hook for "chat with the chart" interactions. The library ships no UI — bring your own chat surface. +- **`useChartInterrogation({ data, onQuery, componentName?, props?, initialAnnotations? })`** → `{ ask(query), history, summary, annotations, loading, error, reset }` +- **`onQuery: (query, context) => Promise<{ answer, annotations? }>`** — call your LLM here. `context` is `{ data, summary, componentName?, props? }`. +- **`summary`**: LLM-friendly statistical summary (`rowCount`, per-field `{ min, max, mean, median }` for numerics, top-k for categoricals, ISO range for dates). Available before any ask(). +- **`annotations`**: Merged `initialAnnotations` + latest AI response. Wire to the chart's `annotations` prop for visual highlighting. +- **`summarizeData(data, options?)`**: Standalone for server-side prompting or batch jobs. +- **MCP Tool**: `interrogateChart(component, props, query)` returns the same statistical summary and AI-facing instructions. + +```jsx +import { LineChart, useChartInterrogation } from "semiotic/ai" + +function InterrogatableChart({ data }) { + const { ask, history, annotations, loading } = useChartInterrogation({ + data, + componentName: "LineChart", + props: { xAccessor: "month", yAccessor: "revenue" }, + onQuery: async (query, { summary }) => { + const res = await myLLMCall(query, summary) + return { answer: res.text, annotations: res.highlights } + }, + }) + return ( + <> + + + + ) +} +``` + +### Chart Capability Layer (`semiotic/ai`) +Heuristic chart-suggestion engine. Charts ship capability descriptors next to their TSX files; the engine ranks them against a profiled dataset by intent. No LLM call required. + +- **`profileData(data, { rawInput?, seriesField? })`** → `ChartDataProfile` (extends `DataSummary`): candidate fields per role (x/y/series/category/size/time), distinct counts, monotonicity, structure detection (hierarchy/network/geo). +- **`suggestCharts(data, { intent?, allow?, deny?, maxResults?, includeVariants?, minScore? })`** → ranked `Suggestion[]` with `{ component, family, importPath, variant?, score, intentScores, rubric, reasons, caveats, props }`. `props` is spreadable directly into the matching chart. +- **`scoreChart(component, data, { intent?, variantKey? })`** → evaluate a specific chart for a dataset (does it fit, how well, why/why not). +- **`useChartSuggestions(data, options)`** → memoized React hook returning `{ suggestions, profile }`. +- **`registerChartCapability(capability)`** / **`unregisterChartCapability(name)`** — runtime registration for custom charts. +- **Intent taxonomy**: 13 built-in intents (`trend`, `compare-series`, `compare-categories`, `rank`, `part-to-whole`, `distribution`, `correlation`, `flow`, `hierarchy`, `geo`, `outlier-detection`, `composition-over-time`, `change-detection`). Extend via `registerIntent(descriptor)`. +- **Capability authoring**: create `Foo.capability.ts` next to `Foo.tsx`, then append to the registry in `src/components/ai/chartCapabilities.ts`. Each capability declares `family`, `rubric` (familiarity/accuracy/precision 1-5), `fits(profile)` gate, `intentScores`, optional `variants` with `intentDeltas`, and `buildProps(profile, variant)`. +- **Variants encode that settings change what a chart is good for**: e.g. `StackedAreaChart`'s `streamgraph` variant boosts trend but penalizes part-to-whole. +- **Interrogation tie-in**: pass `includeSuggestions: true` to `useChartInterrogation` and the same ranked list lands in `context.suggestions` for the LLM. +- **MCP tool**: `suggestCharts(data, intent?)` returns the ranked list as structured content. + +```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 null + const Component = COMPONENT_MAP[top.component] + return +} +``` + ## AI Behavior Contracts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b333fda8..fd75036b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -267,6 +267,64 @@ Canvas scene builders read CSS variables via `getComputedStyle` on the canvas DO ## AI Features `onObservation`/`useChartObserver`, `toConfig`/`fromConfig`/`toURL`/`fromURL`/`copyConfig`/`configToJSX`, `validateProps(component, props)`, `diagnoseConfig(component, props)`, `exportChart(div, { format })`, `npx semiotic-ai --doctor` +### Conversational Interrogation (`semiotic/ai`) +Headless hook for "chat with the chart" interactions. The library ships no UI — bring your own chat surface. +- **`useChartInterrogation({ data, onQuery, componentName?, props?, initialAnnotations? })`** → `{ ask(query), history, summary, annotations, loading, error, reset }` +- **`onQuery: (query, context) => Promise<{ answer, annotations? }>`** — call your LLM here. `context` is `{ data, summary, componentName?, props? }`. +- **`summary`**: LLM-friendly statistical summary (`rowCount`, per-field `{ min, max, mean, median }` for numerics, top-k for categoricals, ISO range for dates). Available before any ask(). +- **`annotations`**: Merged `initialAnnotations` + latest AI response. Wire to the chart's `annotations` prop for visual highlighting. +- **`summarizeData(data, options?)`**: Standalone for server-side prompting or batch jobs. +- **MCP Tool**: `interrogateChart(component, props, query)` returns the same statistical summary and AI-facing instructions. + +```jsx +import { LineChart, useChartInterrogation } from "semiotic/ai" + +function InterrogatableChart({ data }) { + const { ask, history, annotations, loading } = useChartInterrogation({ + data, + componentName: "LineChart", + props: { xAccessor: "month", yAccessor: "revenue" }, + onQuery: async (query, { summary }) => { + const res = await myLLMCall(query, summary) + return { answer: res.text, annotations: res.highlights } + }, + }) + return ( + <> + + + + ) +} +``` + +### Chart Capability Layer (`semiotic/ai`) +Heuristic chart-suggestion engine. Charts ship capability descriptors next to their TSX files; the engine ranks them against a profiled dataset by intent. No LLM call required. + +- **`profileData(data, { rawInput?, seriesField? })`** → `ChartDataProfile` (extends `DataSummary`): candidate fields per role (x/y/series/category/size/time), distinct counts, monotonicity, structure detection (hierarchy/network/geo). +- **`suggestCharts(data, { intent?, allow?, deny?, maxResults?, includeVariants?, minScore? })`** → ranked `Suggestion[]` with `{ component, family, importPath, variant?, score, intentScores, rubric, reasons, caveats, props }`. `props` is spreadable directly into the matching chart. +- **`scoreChart(component, data, { intent?, variantKey? })`** → evaluate a specific chart for a dataset (does it fit, how well, why/why not). +- **`useChartSuggestions(data, options)`** → memoized React hook returning `{ suggestions, profile }`. +- **`registerChartCapability(capability)`** / **`unregisterChartCapability(name)`** — runtime registration for custom charts. +- **Intent taxonomy**: 13 built-in intents (`trend`, `compare-series`, `compare-categories`, `rank`, `part-to-whole`, `distribution`, `correlation`, `flow`, `hierarchy`, `geo`, `outlier-detection`, `composition-over-time`, `change-detection`). Extend via `registerIntent(descriptor)`. +- **Capability authoring**: create `Foo.capability.ts` next to `Foo.tsx`, then append to the registry in `src/components/ai/chartCapabilities.ts`. Each capability declares `family`, `rubric` (familiarity/accuracy/precision 1-5), `fits(profile)` gate, `intentScores`, optional `variants` with `intentDeltas`, and `buildProps(profile, variant)`. +- **Variants encode that settings change what a chart is good for**: e.g. `StackedAreaChart`'s `streamgraph` variant boosts trend but penalizes part-to-whole. +- **Interrogation tie-in**: pass `includeSuggestions: true` to `useChartInterrogation` and the same ranked list lands in `context.suggestions` for the LLM. +- **MCP tool**: `suggestCharts(data, intent?)` returns the ranked list as structured content. + +```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 null + const Component = COMPONENT_MAP[top.component] + return +} +``` + ## AI Behavior Contracts diff --git a/.windsurfrules b/.windsurfrules index b333fda8..fd75036b 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -267,6 +267,64 @@ Canvas scene builders read CSS variables via `getComputedStyle` on the canvas DO ## AI Features `onObservation`/`useChartObserver`, `toConfig`/`fromConfig`/`toURL`/`fromURL`/`copyConfig`/`configToJSX`, `validateProps(component, props)`, `diagnoseConfig(component, props)`, `exportChart(div, { format })`, `npx semiotic-ai --doctor` +### Conversational Interrogation (`semiotic/ai`) +Headless hook for "chat with the chart" interactions. The library ships no UI — bring your own chat surface. +- **`useChartInterrogation({ data, onQuery, componentName?, props?, initialAnnotations? })`** → `{ ask(query), history, summary, annotations, loading, error, reset }` +- **`onQuery: (query, context) => Promise<{ answer, annotations? }>`** — call your LLM here. `context` is `{ data, summary, componentName?, props? }`. +- **`summary`**: LLM-friendly statistical summary (`rowCount`, per-field `{ min, max, mean, median }` for numerics, top-k for categoricals, ISO range for dates). Available before any ask(). +- **`annotations`**: Merged `initialAnnotations` + latest AI response. Wire to the chart's `annotations` prop for visual highlighting. +- **`summarizeData(data, options?)`**: Standalone for server-side prompting or batch jobs. +- **MCP Tool**: `interrogateChart(component, props, query)` returns the same statistical summary and AI-facing instructions. + +```jsx +import { LineChart, useChartInterrogation } from "semiotic/ai" + +function InterrogatableChart({ data }) { + const { ask, history, annotations, loading } = useChartInterrogation({ + data, + componentName: "LineChart", + props: { xAccessor: "month", yAccessor: "revenue" }, + onQuery: async (query, { summary }) => { + const res = await myLLMCall(query, summary) + return { answer: res.text, annotations: res.highlights } + }, + }) + return ( + <> + + + + ) +} +``` + +### Chart Capability Layer (`semiotic/ai`) +Heuristic chart-suggestion engine. Charts ship capability descriptors next to their TSX files; the engine ranks them against a profiled dataset by intent. No LLM call required. + +- **`profileData(data, { rawInput?, seriesField? })`** → `ChartDataProfile` (extends `DataSummary`): candidate fields per role (x/y/series/category/size/time), distinct counts, monotonicity, structure detection (hierarchy/network/geo). +- **`suggestCharts(data, { intent?, allow?, deny?, maxResults?, includeVariants?, minScore? })`** → ranked `Suggestion[]` with `{ component, family, importPath, variant?, score, intentScores, rubric, reasons, caveats, props }`. `props` is spreadable directly into the matching chart. +- **`scoreChart(component, data, { intent?, variantKey? })`** → evaluate a specific chart for a dataset (does it fit, how well, why/why not). +- **`useChartSuggestions(data, options)`** → memoized React hook returning `{ suggestions, profile }`. +- **`registerChartCapability(capability)`** / **`unregisterChartCapability(name)`** — runtime registration for custom charts. +- **Intent taxonomy**: 13 built-in intents (`trend`, `compare-series`, `compare-categories`, `rank`, `part-to-whole`, `distribution`, `correlation`, `flow`, `hierarchy`, `geo`, `outlier-detection`, `composition-over-time`, `change-detection`). Extend via `registerIntent(descriptor)`. +- **Capability authoring**: create `Foo.capability.ts` next to `Foo.tsx`, then append to the registry in `src/components/ai/chartCapabilities.ts`. Each capability declares `family`, `rubric` (familiarity/accuracy/precision 1-5), `fits(profile)` gate, `intentScores`, optional `variants` with `intentDeltas`, and `buildProps(profile, variant)`. +- **Variants encode that settings change what a chart is good for**: e.g. `StackedAreaChart`'s `streamgraph` variant boosts trend but penalizes part-to-whole. +- **Interrogation tie-in**: pass `includeSuggestions: true` to `useChartInterrogation` and the same ranked list lands in `context.suggestions` for the LLM. +- **MCP tool**: `suggestCharts(data, intent?)` returns the ranked list as structured content. + +```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 null + const Component = COMPONENT_MAP[top.component] + return +} +``` + ## AI Behavior Contracts diff --git a/CHANGELOG.md b/CHANGELOG.md index 68b2d0b8..7fea2938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.6.0] + ## [3.5.4] - 2026-05-21 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index b333fda8..fd75036b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -267,6 +267,64 @@ Canvas scene builders read CSS variables via `getComputedStyle` on the canvas DO ## AI Features `onObservation`/`useChartObserver`, `toConfig`/`fromConfig`/`toURL`/`fromURL`/`copyConfig`/`configToJSX`, `validateProps(component, props)`, `diagnoseConfig(component, props)`, `exportChart(div, { format })`, `npx semiotic-ai --doctor` +### Conversational Interrogation (`semiotic/ai`) +Headless hook for "chat with the chart" interactions. The library ships no UI — bring your own chat surface. +- **`useChartInterrogation({ data, onQuery, componentName?, props?, initialAnnotations? })`** → `{ ask(query), history, summary, annotations, loading, error, reset }` +- **`onQuery: (query, context) => Promise<{ answer, annotations? }>`** — call your LLM here. `context` is `{ data, summary, componentName?, props? }`. +- **`summary`**: LLM-friendly statistical summary (`rowCount`, per-field `{ min, max, mean, median }` for numerics, top-k for categoricals, ISO range for dates). Available before any ask(). +- **`annotations`**: Merged `initialAnnotations` + latest AI response. Wire to the chart's `annotations` prop for visual highlighting. +- **`summarizeData(data, options?)`**: Standalone for server-side prompting or batch jobs. +- **MCP Tool**: `interrogateChart(component, props, query)` returns the same statistical summary and AI-facing instructions. + +```jsx +import { LineChart, useChartInterrogation } from "semiotic/ai" + +function InterrogatableChart({ data }) { + const { ask, history, annotations, loading } = useChartInterrogation({ + data, + componentName: "LineChart", + props: { xAccessor: "month", yAccessor: "revenue" }, + onQuery: async (query, { summary }) => { + const res = await myLLMCall(query, summary) + return { answer: res.text, annotations: res.highlights } + }, + }) + return ( + <> + + + + ) +} +``` + +### Chart Capability Layer (`semiotic/ai`) +Heuristic chart-suggestion engine. Charts ship capability descriptors next to their TSX files; the engine ranks them against a profiled dataset by intent. No LLM call required. + +- **`profileData(data, { rawInput?, seriesField? })`** → `ChartDataProfile` (extends `DataSummary`): candidate fields per role (x/y/series/category/size/time), distinct counts, monotonicity, structure detection (hierarchy/network/geo). +- **`suggestCharts(data, { intent?, allow?, deny?, maxResults?, includeVariants?, minScore? })`** → ranked `Suggestion[]` with `{ component, family, importPath, variant?, score, intentScores, rubric, reasons, caveats, props }`. `props` is spreadable directly into the matching chart. +- **`scoreChart(component, data, { intent?, variantKey? })`** → evaluate a specific chart for a dataset (does it fit, how well, why/why not). +- **`useChartSuggestions(data, options)`** → memoized React hook returning `{ suggestions, profile }`. +- **`registerChartCapability(capability)`** / **`unregisterChartCapability(name)`** — runtime registration for custom charts. +- **Intent taxonomy**: 13 built-in intents (`trend`, `compare-series`, `compare-categories`, `rank`, `part-to-whole`, `distribution`, `correlation`, `flow`, `hierarchy`, `geo`, `outlier-detection`, `composition-over-time`, `change-detection`). Extend via `registerIntent(descriptor)`. +- **Capability authoring**: create `Foo.capability.ts` next to `Foo.tsx`, then append to the registry in `src/components/ai/chartCapabilities.ts`. Each capability declares `family`, `rubric` (familiarity/accuracy/precision 1-5), `fits(profile)` gate, `intentScores`, optional `variants` with `intentDeltas`, and `buildProps(profile, variant)`. +- **Variants encode that settings change what a chart is good for**: e.g. `StackedAreaChart`'s `streamgraph` variant boosts trend but penalizes part-to-whole. +- **Interrogation tie-in**: pass `includeSuggestions: true` to `useChartInterrogation` and the same ranked list lands in `context.suggestions` for the LLM. +- **MCP tool**: `suggestCharts(data, intent?)` returns the ranked list as structured content. + +```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 null + const Component = COMPONENT_MAP[top.component] + return +} +``` + ## AI Behavior Contracts diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 00000000..c78e3164 --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,72 @@ +# Semiotic: Strategic Roadmap & Areas for Improvement + +## Executive Summary +Semiotic is a Tier-1 React data visualization library that has successfully carved out a unique niche: **AI-assisted development**. Its deep integration with the Model Context Protocol (MCP), robust JSON schemas, and semantic "behavior contracts" make it the best-in-class choice for the LLM-driven future of software engineering. + +However, as the project matures, there are opportunities to evolve from being a **developer tool for building charts** into a **data platform for understanding visualizations**. + +--- + +## 1. Stakeholder Analysis + +### 🌍 For the Readers (Visualization Consumers) +*The people looking at the dashboards, monitoring live feeds, and trying to extract meaning from data.* + +* **Interactivity Depth**: While hover and brush are supported, there is a gap in "Exploratory Interactivity." Readers would benefit from built-in zoom/pan for XY charts, drill-down patterns, and "Filter-in-place" UI that doesn't require developer wiring. +* **Narrative & Insights**: Charts often show *what* happened, but not *why*. Automated labeling of "Key Insights" (e.g., "All-time high," "Outlier detected") should be first-class props rather than manual annotations. +* **Export Utility**: A standard "Export to CSV/JSON/PNG" button as a first-class feature of the HOC charts would empower non-technical readers. + +### 🛠 For the Developers (Maintainers) +*The contributors keeping the engine running and the ecosystem growing.* + +* **Local Development Friction**: Currently, the documentation site imports from the built `dist/` bundle. This requires a full rebuild of the library to see source changes in the docs. Migrating to a **Monorepo / Workspace** (e.g., Turborepo) where the docs site can alias to the `src/` directory would radically improve DX. +* **Build Complexity**: The `scripts/build.mjs` is a custom Rollup orchestration. While high-performance, it's a barrier to entry for new contributors. Modernizing this with a standard tool like **Vite** or **Rspack** for the website could simplify the setup. +* **Technical Debt in Frames**: The `StreamXYFrame` and `PipelineStore` are massive (1.7k+ lines). Decomposing these into smaller, functional modules (e.g., a standalone `ScaleManager`, `LayoutEngine`, and `InteractionManager`) would make the core logic easier to test and extend. + +### 🤝 For Integration Partners +*Companies and products embedding Semiotic or using its output.* + +* **Design System Synchronization**: Theming is currently proprietary. Supporting **W3C Design Tokens** or providing a direct **Tailwind CSS** plugin to map theme variables to utility classes would make integration into enterprise design systems seamless. +* **Headless Layout Engine**: Integration partners often want the *math* of Semiotic (the layouts, the scales, the bins) without the *DOM*. Exposing a "Headless" version of `PipelineStore` that works in pure Node or Worker environments without JSDOM would be a game-changer for data processing pipelines. +* **Bundle Optimization**: The bundle sizes (85KB+ for XY) are large for modern web standards. More aggressive code-splitting (e.g., lazy-loading specific chart types like Candlestick or Swarm) could reduce the initial load for simple use cases. + +--- + +## 2. Core Technical Improvements + +### 🎨 Graphical Functionality +* **Complex Axis Types**: Better support for non-linear scales (Log, Power) and "broken" axes for handling massive outliers. +* **Touch Optimization**: Improved gesture support for mobile readers (pinch-to-zoom, long-press for tooltips). +* **Statistical Overlays**: First-class support for LOESS smoothing, confidence intervals, and regression lines as simple props rather than complex annotation objects. + +### ⚡ Performance +* **OffscreenCanvas**: Offloading heavy canvas rendering to Web Workers for charts with >100k data points. +* **Virtualization**: Virtualizing the `AccessibleDataTable` to prevent DOM bloat when the reader toggle-opens the data view for huge datasets. + +### 🤖 AI Integration (The "Assistant" Layer) +* **Generative Layouts**: Allow the AI to suggest not just the *chart type*, but the *visual priority* (e.g., "Highlight the trend, de-emphasize the points"). +* **Prop Auto-Correction**: The `diagnoseConfig` tool is excellent. The next step is "Auto-Fix" — an AI tool that takes a broken config and returns a corrected one that is guaranteed to render. + +--- + +## 3. The "Hidden" High-Impact Feature: **Conversational Chart Interrogation** + +### The Concept +Today, Semiotic helps an AI **build** a chart. The high-impact move is to help an AI **explain** the chart to the reader. + +We propose a new component: `` or a prop `interactiveExplain`. + +### How it works: +1. **Context Awareness**: Because Semiotic knows the `schema`, the `data`, and the `intent` (via behavior contracts), it can provide a "narrow" context to an LLM. +2. **Narrative Interaction**: A reader can ask: *"What was the highest peak in March?"* +3. **Visual Feedback**: The AI doesn't just answer with text. It returns an **Annotation Object** that Semiotic dynamically renders onto the chart. The chart literally "highlights" what the AI is talking about. +4. **Why this is huge**: It bridges the gap between **Static Visualization** and **Data Science**. It makes every chart an interactive data analyst. It's the ultimate Accessibility feature: those who can't see the chart can *talk* to it to understand the trends. + +### Implementation Path: +* Leverage the existing `semiotic-mcp` server. +* Create a "Small Context" generator that extracts the statistical summary of the data (min, max, mean, outliers). +* Build a UI bridge that turns AI responses into Semiotic `annotations`. + +--- + +*Authored by Gemini CLI for Elijah Meeks* diff --git a/ai/capability-scorecard.json b/ai/capability-scorecard.json new file mode 100644 index 00000000..adec0c14 --- /dev/null +++ b/ai/capability-scorecard.json @@ -0,0 +1,1211 @@ +{ + "perCapability": [ + { + "component": "QuadrantChart", + "family": "relationship", + "fitsOn": 13, + "rejectedOn": 10, + "inTopThreeOn": 1, + "expertAgreementCount": 0, + "averageScore": 1.0769230769230769, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "MinimapChart", + "family": "time-series", + "fitsOn": 5, + "rejectedOn": 18, + "inTopThreeOn": 0, + "expertAgreementCount": 0, + "averageScore": 1.6, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "Heatmap", + "family": "relationship", + "fitsOn": 4, + "rejectedOn": 21, + "inTopThreeOn": 0, + "expertAgreementCount": 0, + "averageScore": 0, + "caveatCoverage": 0.5, + "variantUtilization": 1 + }, + { + "component": "SwarmPlot", + "family": "distribution", + "fitsOn": 4, + "rejectedOn": 19, + "inTopThreeOn": 0, + "expertAgreementCount": 0, + "averageScore": 1.75, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "LikertChart", + "family": "categorical", + "fitsOn": 2, + "rejectedOn": 21, + "inTopThreeOn": 1, + "expertAgreementCount": 0, + "averageScore": 3.5, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "SwimlaneChart", + "family": "categorical", + "fitsOn": 2, + "rejectedOn": 21, + "inTopThreeOn": 1, + "expertAgreementCount": 0, + "averageScore": 1.5, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "RidgelinePlot", + "family": "distribution", + "fitsOn": 2, + "rejectedOn": 21, + "inTopThreeOn": 0, + "expertAgreementCount": 0, + "averageScore": 3.5, + "caveatCoverage": 1, + "variantUtilization": 0 + }, + { + "component": "CandlestickChart", + "family": "time-series", + "fitsOn": 1, + "rejectedOn": 22, + "inTopThreeOn": 0, + "expertAgreementCount": 0, + "averageScore": 4, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "ForceDirectedGraph", + "family": "network", + "fitsOn": 1, + "rejectedOn": 22, + "inTopThreeOn": 1, + "expertAgreementCount": 0, + "averageScore": 3, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "CirclePack", + "family": "hierarchy", + "fitsOn": 1, + "rejectedOn": 22, + "inTopThreeOn": 0, + "expertAgreementCount": 0, + "averageScore": 4, + "caveatCoverage": 1, + "variantUtilization": 0 + }, + { + "component": "OrbitDiagram", + "family": "hierarchy", + "fitsOn": 1, + "rejectedOn": 22, + "inTopThreeOn": 0, + "expertAgreementCount": 0, + "averageScore": 3, + "caveatCoverage": 1, + "variantUtilization": 0 + }, + { + "component": "GaugeChart", + "family": "categorical", + "fitsOn": 0, + "rejectedOn": 23, + "inTopThreeOn": 0, + "expertAgreementCount": 0, + "averageScore": 0, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "ProcessSankey", + "family": "flow", + "fitsOn": 0, + "rejectedOn": 23, + "inTopThreeOn": 0, + "expertAgreementCount": 0, + "averageScore": 0, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "FlowMap", + "family": "geo", + "fitsOn": 0, + "rejectedOn": 23, + "inTopThreeOn": 0, + "expertAgreementCount": 0, + "averageScore": 0, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "DistanceCartogram", + "family": "geo", + "fitsOn": 0, + "rejectedOn": 23, + "inTopThreeOn": 0, + "expertAgreementCount": 0, + "averageScore": 0, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "DonutChart", + "family": "categorical", + "fitsOn": 8, + "rejectedOn": 15, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 1.25, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "ConnectedScatterplot", + "family": "relationship", + "fitsOn": 4, + "rejectedOn": 19, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 2.5, + "caveatCoverage": 1, + "variantUtilization": 0 + }, + { + "component": "BubbleChart", + "family": "relationship", + "fitsOn": 4, + "rejectedOn": 19, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 2, + "caveatCoverage": 1, + "variantUtilization": 0 + }, + { + "component": "ViolinPlot", + "family": "distribution", + "fitsOn": 4, + "rejectedOn": 21, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 4.5, + "caveatCoverage": 0, + "variantUtilization": 1 + }, + { + "component": "MultiAxisLineChart", + "family": "time-series", + "fitsOn": 3, + "rejectedOn": 20, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 2.3333333333333335, + "caveatCoverage": 1, + "variantUtilization": 0 + }, + { + "component": "GroupedBarChart", + "family": "categorical", + "fitsOn": 2, + "rejectedOn": 21, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 2, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "DifferenceChart", + "family": "time-series", + "fitsOn": 1, + "rejectedOn": 22, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 5, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "FunnelChart", + "family": "flow", + "fitsOn": 1, + "rejectedOn": 22, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 4, + "caveatCoverage": 1, + "variantUtilization": 0 + }, + { + "component": "SankeyDiagram", + "family": "flow", + "fitsOn": 1, + "rejectedOn": 22, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 5, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "ChordDiagram", + "family": "flow", + "fitsOn": 1, + "rejectedOn": 22, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 4, + "caveatCoverage": 1, + "variantUtilization": 0 + }, + { + "component": "Treemap", + "family": "hierarchy", + "fitsOn": 1, + "rejectedOn": 22, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 4, + "caveatCoverage": 1, + "variantUtilization": 0 + }, + { + "component": "ChoroplethMap", + "family": "geo", + "fitsOn": 1, + "rejectedOn": 22, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 5, + "caveatCoverage": 1, + "variantUtilization": 0 + }, + { + "component": "ProportionalSymbolMap", + "family": "geo", + "fitsOn": 1, + "rejectedOn": 22, + "inTopThreeOn": 1, + "expertAgreementCount": 1, + "averageScore": 4, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "AreaChart", + "family": "time-series", + "fitsOn": 24, + "rejectedOn": 15, + "inTopThreeOn": 4, + "expertAgreementCount": 2, + "averageScore": 2.625, + "caveatCoverage": 0, + "variantUtilization": 1 + }, + { + "component": "DotPlot", + "family": "categorical", + "fitsOn": 9, + "rejectedOn": 14, + "inTopThreeOn": 2, + "expertAgreementCount": 2, + "averageScore": 1.4444444444444444, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "BoxPlot", + "family": "distribution", + "fitsOn": 4, + "rejectedOn": 19, + "inTopThreeOn": 2, + "expertAgreementCount": 2, + "averageScore": 2.25, + "caveatCoverage": 0, + "variantUtilization": 0 + }, + { + "component": "TreeDiagram", + "family": "hierarchy", + "fitsOn": 2, + "rejectedOn": 22, + "inTopThreeOn": 2, + "expertAgreementCount": 2, + "averageScore": 5, + "caveatCoverage": 0, + "variantUtilization": 1 + }, + { + "component": "PieChart", + "family": "categorical", + "fitsOn": 16, + "rejectedOn": 15, + "inTopThreeOn": 3, + "expertAgreementCount": 3, + "averageScore": 1.5, + "caveatCoverage": 1, + "variantUtilization": 1 + }, + { + "component": "StackedAreaChart", + "family": "time-series", + "fitsOn": 12, + "rejectedOn": 19, + "inTopThreeOn": 3, + "expertAgreementCount": 3, + "averageScore": 3.0833333333333335, + "caveatCoverage": 1, + "variantUtilization": 1 + }, + { + "component": "StackedBarChart", + "family": "categorical", + "fitsOn": 4, + "rejectedOn": 21, + "inTopThreeOn": 3, + "expertAgreementCount": 3, + "averageScore": 3.25, + "caveatCoverage": 1, + "variantUtilization": 1 + }, + { + "component": "Histogram", + "family": "distribution", + "fitsOn": 32, + "rejectedOn": 7, + "inTopThreeOn": 5, + "expertAgreementCount": 4, + "averageScore": 0.75, + "caveatCoverage": 0, + "variantUtilization": 1 + }, + { + "component": "BarChart", + "family": "categorical", + "fitsOn": 27, + "rejectedOn": 14, + "inTopThreeOn": 6, + "expertAgreementCount": 4, + "averageScore": 2.074074074074074, + "caveatCoverage": 0, + "variantUtilization": 1 + }, + { + "component": "Scatterplot", + "family": "relationship", + "fitsOn": 26, + "rejectedOn": 10, + "inTopThreeOn": 8, + "expertAgreementCount": 8, + "averageScore": 2.3846153846153846, + "caveatCoverage": 0, + "variantUtilization": 1 + }, + { + "component": "LineChart", + "family": "time-series", + "fitsOn": 24, + "rejectedOn": 15, + "inTopThreeOn": 12, + "expertAgreementCount": 12, + "averageScore": 3.0833333333333335, + "caveatCoverage": 0.3333333333333333, + "variantUtilization": 1 + } + ], + "perFixture": [ + { + "fixture": "monthly revenue with regions, intent=trend", + "shape": "12 months × 3 regions, numeric month, numeric revenue", + "intent": "trend", + "expected": [ + "LineChart", + "AreaChart", + "MinimapChart" + ], + "topPick": { + "component": "LineChart", + "variantKey": "linear", + "score": 5 + }, + "topThree": [ + { + "component": "LineChart", + "variantKey": "linear", + "score": 5 + }, + { + "component": "LineChart", + "variantKey": "smooth", + "score": 5 + }, + { + "component": "AreaChart", + "variantKey": "smooth-gradient", + "score": 5 + } + ], + "fittingCount": 15, + "rejectedCount": 32, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "monthly revenue with regions, intent=compare-series", + "shape": "12 months × 3 regions", + "intent": "compare-series", + "expected": [ + "LineChart", + "GroupedBarChart" + ], + "topPick": { + "component": "LineChart", + "variantKey": "linear", + "score": 4 + }, + "topThree": [ + { + "component": "LineChart", + "variantKey": "linear", + "score": 4 + }, + { + "component": "LineChart", + "variantKey": "smooth", + "score": 4 + }, + { + "component": "LineChart", + "variantKey": "stepped-with-points", + "score": 4 + } + ], + "fittingCount": 15, + "rejectedCount": 32, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "monthly revenue with regions, intent=composition-over-time", + "shape": "12 months × 3 regions, additive", + "intent": "composition-over-time", + "expected": [ + "StackedAreaChart", + "StackedBarChart" + ], + "topPick": { + "component": "StackedAreaChart", + "variantKey": "baseline-zero", + "score": 5 + }, + "topThree": [ + { + "component": "StackedAreaChart", + "variantKey": "baseline-zero", + "score": 5 + }, + { + "component": "StackedAreaChart", + "variantKey": "centered", + "score": 5 + }, + { + "component": "StackedAreaChart", + "variantKey": "streamgraph", + "score": 5 + } + ], + "fittingCount": 15, + "rejectedCount": 32, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "monthly revenue single series, intent=trend", + "shape": "12 months, no series", + "intent": "trend", + "expected": [ + "LineChart", + "AreaChart" + ], + "topPick": { + "component": "LineChart", + "variantKey": "linear", + "score": 5 + }, + "topThree": [ + { + "component": "LineChart", + "variantKey": "linear", + "score": 5 + }, + { + "component": "LineChart", + "variantKey": "smooth", + "score": 5 + }, + { + "component": "AreaChart", + "variantKey": "smooth-gradient", + "score": 5 + } + ], + "fittingCount": 12, + "rejectedCount": 33, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "product sales, intent=rank", + "shape": "5 products, single numeric measure", + "intent": "rank", + "expected": [ + "BarChart", + "DotPlot" + ], + "topPick": { + "component": "BarChart", + "variantKey": "sorted-desc", + "score": 5 + }, + "topThree": [ + { + "component": "BarChart", + "variantKey": "sorted-desc", + "score": 5 + }, + { + "component": "BarChart", + "variantKey": "horizontal", + "score": 5 + }, + { + "component": "DotPlot", + "score": 5 + } + ], + "fittingCount": 7, + "rejectedCount": 35, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "product sales, intent=part-to-whole", + "shape": "5 products, single numeric measure", + "intent": "part-to-whole", + "expected": [ + "PieChart", + "DonutChart", + "BarChart" + ], + "topPick": { + "component": "PieChart", + "variantKey": "pie", + "score": 4 + }, + "topThree": [ + { + "component": "PieChart", + "variantKey": "pie", + "score": 4 + }, + { + "component": "PieChart", + "variantKey": "donut", + "score": 4 + }, + { + "component": "DonutChart", + "score": 4 + } + ], + "fittingCount": 7, + "rejectedCount": 35, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "satisfaction scores, intent=distribution", + "shape": "150 numeric observations across 3 cohorts", + "intent": "distribution", + "expected": [ + "Histogram", + "BoxPlot", + "ViolinPlot" + ], + "topPick": { + "component": "Histogram", + "variantKey": "count-bins", + "score": 5 + }, + "topThree": [ + { + "component": "Histogram", + "variantKey": "count-bins", + "score": 5 + }, + { + "component": "Histogram", + "variantKey": "share-bins", + "score": 5 + }, + { + "component": "BoxPlot", + "score": 5 + } + ], + "fittingCount": 18, + "rejectedCount": 27, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "satisfaction scores, intent=compare-categories", + "shape": "150 obs × 3 cohorts", + "intent": "compare-categories", + "expected": [ + "BoxPlot", + "ViolinPlot", + "SwarmPlot" + ], + "topPick": { + "component": "BoxPlot", + "score": 4 + }, + "topThree": [ + { + "component": "BoxPlot", + "score": 4 + }, + { + "component": "LikertChart", + "score": 4 + }, + { + "component": "ViolinPlot", + "variantKey": "density", + "score": 4 + } + ], + "fittingCount": 18, + "rejectedCount": 27, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "hours vs grade, intent=correlation", + "shape": "80 students, hours + grade", + "intent": "correlation", + "expected": [ + "Scatterplot" + ], + "topPick": { + "component": "Scatterplot", + "variantKey": "points", + "score": 5 + }, + "topThree": [ + { + "component": "Scatterplot", + "variantKey": "points", + "score": 5 + }, + { + "component": "Scatterplot", + "variantKey": "with-trend", + "score": 5 + }, + { + "component": "QuadrantChart", + "score": 3 + } + ], + "fittingCount": 5, + "rejectedCount": 36, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "hours vs grade, intent=outlier-detection", + "shape": "80 students", + "intent": "outlier-detection", + "expected": [ + "Scatterplot" + ], + "topPick": { + "component": "Scatterplot", + "variantKey": "points", + "score": 5 + }, + "topThree": [ + { + "component": "Scatterplot", + "variantKey": "points", + "score": 5 + }, + { + "component": "Scatterplot", + "variantKey": "with-trend", + "score": 5 + }, + { + "component": "Histogram", + "variantKey": "count-bins", + "score": 3 + } + ], + "fittingCount": 5, + "rejectedCount": 36, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "conversion funnel, intent=flow", + "shape": "4 stages, descending values", + "intent": "flow", + "expected": [ + "FunnelChart" + ], + "topPick": { + "component": "FunnelChart", + "score": 4 + }, + "topThree": [ + { + "component": "FunnelChart", + "score": 4 + }, + { + "component": "BarChart", + "variantKey": "sorted-desc", + "score": 0 + }, + { + "component": "BarChart", + "variantKey": "source-order", + "score": 0 + } + ], + "fittingCount": 8, + "rejectedCount": 34, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "org chart, intent=hierarchy", + "shape": "3-deep org tree", + "intent": "hierarchy", + "expected": [ + "TreeDiagram", + "Treemap", + "CirclePack" + ], + "topPick": { + "component": "TreeDiagram", + "variantKey": "vertical-tree", + "score": 5 + }, + "topThree": [ + { + "component": "TreeDiagram", + "variantKey": "vertical-tree", + "score": 5 + }, + { + "component": "TreeDiagram", + "variantKey": "horizontal-cluster", + "score": 5 + }, + { + "component": "Treemap", + "score": 4 + } + ], + "fittingCount": 5, + "rejectedCount": 35, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "approval workflow transitions, intent=flow", + "shape": "5 nodes / 4 weighted edges", + "intent": "flow", + "expected": [ + "SankeyDiagram", + "ChordDiagram" + ], + "topPick": { + "component": "SankeyDiagram", + "score": 5 + }, + "topThree": [ + { + "component": "SankeyDiagram", + "score": 5 + }, + { + "component": "ChordDiagram", + "score": 4 + }, + { + "component": "ForceDirectedGraph", + "score": 3 + } + ], + "fittingCount": 3, + "rejectedCount": 36, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "US states with values, intent=geo", + "shape": "3 polygon features with numeric values", + "intent": "geo", + "expected": [ + "ChoroplethMap", + "ProportionalSymbolMap" + ], + "topPick": { + "component": "ChoroplethMap", + "score": 5 + }, + "topThree": [ + { + "component": "ChoroplethMap", + "score": 5 + }, + { + "component": "ProportionalSymbolMap", + "score": 4 + } + ], + "fittingCount": 2, + "rejectedCount": 37, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "country economies, intent=correlation", + "shape": "10 countries × 3 numeric measures (gdp, hours, population)", + "intent": "correlation", + "expected": [ + "Scatterplot", + "BubbleChart" + ], + "topPick": { + "component": "Scatterplot", + "variantKey": "points", + "score": 5 + }, + "topThree": [ + { + "component": "Scatterplot", + "variantKey": "points", + "score": 5 + }, + { + "component": "Scatterplot", + "variantKey": "with-trend", + "score": 5 + }, + { + "component": "BubbleChart", + "score": 4 + } + ], + "fittingCount": 10, + "rejectedCount": 33, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "website metrics with 3 measures, intent=compare-series", + "shape": "24 months × 3 numeric measures with different ranges", + "intent": "compare-series", + "expected": [ + "MultiAxisLineChart", + "LineChart" + ], + "topPick": { + "component": "MultiAxisLineChart", + "score": 4 + }, + "topThree": [ + { + "component": "MultiAxisLineChart", + "score": 4 + }, + { + "component": "AreaChart", + "variantKey": "smooth-gradient", + "score": 2 + }, + { + "component": "AreaChart", + "variantKey": "linear", + "score": 2 + } + ], + "fittingCount": 14, + "rejectedCount": 31, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "sales by region and product, intent=compare-series", + "shape": "12 rows = 4 products × 3 regions", + "intent": "compare-series", + "expected": [ + "GroupedBarChart", + "StackedBarChart" + ], + "topPick": { + "component": "GroupedBarChart", + "score": 4 + }, + "topThree": [ + { + "component": "GroupedBarChart", + "score": 4 + }, + { + "component": "SwimlaneChart", + "score": 3 + }, + { + "component": "StackedBarChart", + "variantKey": "absolute", + "score": 2 + } + ], + "fittingCount": 17, + "rejectedCount": 28, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "sales by region and product, intent=part-to-whole", + "shape": "12 rows = 4 products × 3 regions", + "intent": "part-to-whole", + "expected": [ + "StackedBarChart", + "PieChart" + ], + "topPick": { + "component": "StackedBarChart", + "variantKey": "normalized", + "score": 5 + }, + "topThree": [ + { + "component": "StackedBarChart", + "variantKey": "normalized", + "score": 5 + }, + { + "component": "StackedBarChart", + "variantKey": "absolute", + "score": 4 + }, + { + "component": "PieChart", + "variantKey": "pie", + "score": 4 + } + ], + "fittingCount": 17, + "rejectedCount": 28, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "revenue vs expenses, intent=compare-series", + "shape": "48 rows = 24 months × 2 series", + "intent": "compare-series", + "expected": [ + "DifferenceChart", + "LineChart", + "GroupedBarChart" + ], + "topPick": { + "component": "DifferenceChart", + "score": 5 + }, + "topThree": [ + { + "component": "DifferenceChart", + "score": 5 + }, + { + "component": "LineChart", + "variantKey": "linear", + "score": 4 + }, + { + "component": "LineChart", + "variantKey": "smooth", + "score": 4 + } + ], + "fittingCount": 16, + "rejectedCount": 31, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "stock OHLC prices, intent=change-detection", + "shape": "30 days × open/high/low/close", + "intent": "change-detection", + "expected": [ + "CandlestickChart", + "LineChart" + ], + "topPick": { + "component": "LineChart", + "variantKey": "stepped-with-points", + "score": 5 + }, + "topThree": [ + { + "component": "LineChart", + "variantKey": "stepped-with-points", + "score": 5 + }, + { + "component": "LineChart", + "variantKey": "linear", + "score": 4 + }, + { + "component": "LineChart", + "variantKey": "smooth", + "score": 4 + } + ], + "fittingCount": 16, + "rejectedCount": 29, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "unemployment vs inflation by year, intent=correlation", + "shape": "20 years × 2 measures, ordered by year", + "intent": "correlation", + "expected": [ + "ConnectedScatterplot", + "Scatterplot" + ], + "topPick": { + "component": "Scatterplot", + "variantKey": "points", + "score": 5 + }, + "topThree": [ + { + "component": "Scatterplot", + "variantKey": "points", + "score": 5 + }, + { + "component": "Scatterplot", + "variantKey": "with-trend", + "score": 5 + }, + { + "component": "ConnectedScatterplot", + "score": 4 + } + ], + "fittingCount": 14, + "rejectedCount": 31, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "flat single column", + "shape": "50 rows, one numeric column", + "expected": [ + "Histogram" + ], + "topPick": { + "component": "Histogram", + "variantKey": "count-bins", + "score": 3 + }, + "topThree": [ + { + "component": "Histogram", + "variantKey": "count-bins", + "score": 3 + }, + { + "component": "Histogram", + "variantKey": "share-bins", + "score": 3 + } + ], + "fittingCount": 2, + "rejectedCount": 38, + "expertAgreement": true, + "noFitHonored": null + }, + { + "fixture": "sparse 3-row data, intent=rank", + "shape": "3 rows total", + "intent": "rank", + "expected": [ + "BarChart", + "DotPlot" + ], + "topPick": { + "component": "BarChart", + "variantKey": "sorted-desc", + "score": 5 + }, + "topThree": [ + { + "component": "BarChart", + "variantKey": "sorted-desc", + "score": 5 + }, + { + "component": "BarChart", + "variantKey": "horizontal", + "score": 5 + }, + { + "component": "DotPlot", + "score": 5 + } + ], + "fittingCount": 7, + "rejectedCount": 35, + "expertAgreement": true, + "noFitHonored": null + } + ], + "summary": { + "fixtureCount": 23, + "capabilityCount": 39, + "expertAgreementRate": 1, + "overallCaveatCoverage": 0.24596774193548387, + "overallVariantUtilization": 0.7056451612903226 + } +} \ No newline at end of file diff --git a/ai/dist/mcp-server.js b/ai/dist/mcp-server.js index f42e155d..a764a372 100755 --- a/ai/dist/mcp-server.js +++ b/ai/dist/mcp-server.js @@ -32743,6 +32743,166 @@ Dark-mode presets: ${THEME_PRESET_NAMES.filter((n) => n.includes("dark")).join(" content: [{ type: "text", text: usage.join("\n") }] }; } +async function suggestChartsHandler(args) { + const { data, intent, maxResults, allow, deny, audience } = args; + const intentArg = Array.isArray(intent) ? intent : intent ? [intent] : void 0; + const suggestions = (0, import_ai3.suggestCharts)(data, { + intent: intentArg, + allow, + deny, + maxResults: maxResults ?? 8, + audience + }); + const lines = [ + `${suggestions.length} suggestion${suggestions.length === 1 ? "" : "s"} for ${data.length} rows${intentArg ? ` (intent: ${intentArg.join(", ")})` : ""}:`, + "", + ...suggestions.map((s, i) => { + const variantTag = s.variant ? ` / ${s.variant.label}` : ""; + const reasons = s.reasons.length ? ` \u2014 ${s.reasons.join("; ")}` : ""; + const caveats = s.caveats.length ? ` + caveats: ${s.caveats.join("; ")}` : ""; + return `${i + 1}. ${s.component}${variantTag} (score ${s.score.toFixed(1)}/5, familiarity ${s.rubric.familiarity}, accuracy ${s.rubric.accuracy})${reasons}${caveats}`; + }) + ]; + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { suggestions } + }; +} +async function suggestStreamChartsHandler(args) { + const { schema: schema2, intent, maxResults } = args; + const intentArg = Array.isArray(intent) ? intent : intent ? [intent] : void 0; + const suggestions = (0, import_ai3.suggestStreamCharts)(schema2, { + intent: intentArg, + maxResults: maxResults ?? 8 + }); + const lines = [ + `${suggestions.length} stream chart suggestion${suggestions.length === 1 ? "" : "s"}${intentArg ? ` (intent: ${intentArg.join(", ")})` : ""}`, + ...schema2.throughput ? [`throughput: ${schema2.throughput}`] : [], + ...schema2.retention ? [`retention: ${schema2.retention}`] : [], + "", + ...suggestions.map((s, i) => { + const reasons = s.reasons.length ? ` \u2014 ${s.reasons.join("; ")}` : ""; + const caveats = s.caveats.length ? ` + caveats: ${s.caveats.join("; ")}` : ""; + return `${i + 1}. ${s.component} (score ${s.score.toFixed(1)}/5)${reasons}${caveats}`; + }) + ]; + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { suggestions, schema: schema2 } + }; +} +async function suggestDashboardHandler(args) { + const { data, intents, maxPanels, diversifyByFamily, audience } = args; + const dashboard = (0, import_ai3.suggestDashboard)(data, { + intents, + maxPanels: maxPanels ?? 6, + diversifyByFamily: diversifyByFamily !== false, + audience + }); + const lines = []; + lines.push(`Dashboard: ${dashboard.panels.length} panels covering ${dashboard.intentsCovered.join(", ") || "\u2014"}`); + if (dashboard.intentsMissing.length) { + lines.push(`Intents this data couldn't fill: ${dashboard.intentsMissing.join(", ")}`); + } + lines.push(""); + for (let i = 0; i < dashboard.panels.length; i++) { + const { intent, suggestion } = dashboard.panels[i]; + const variantTag = suggestion.variant ? ` / ${suggestion.variant.label}` : ""; + lines.push(`${i + 1}. [${intent}] ${suggestion.component}${variantTag} (score ${suggestion.score.toFixed(1)}/5)`); + if (suggestion.reasons.length) lines.push(` ${suggestion.reasons.join("; ")}`); + } + if (dashboard.stretchPanels.length > 0) { + lines.push(""); + lines.push(`Stretch picks (audience-unfamiliar but fitting):`); + for (const stretch of dashboard.stretchPanels) { + const variantTag = stretch.suggestion.variant ? ` / ${stretch.suggestion.variant.label}` : ""; + lines.push(` ${stretch.suggestion.component}${variantTag} (familiarity ${stretch.familiarity}) \u2014 ${stretch.rationale}`); + } + } + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: dashboard + }; +} +async function suggestStretchChartsHandler(args) { + const { data, audience, intent, maxResults } = args; + const intentArg = Array.isArray(intent) ? intent : intent ? [intent] : void 0; + const stretches = (0, import_ai3.suggestStretchCharts)(data, { + audience, + intent: intentArg, + maxResults: maxResults ?? 5 + }); + const lines = [ + `${stretches.length} stretch pick${stretches.length === 1 ? "" : "s"} for "${audience.name ?? "audience"}":`, + "", + ...stretches.map((s, i) => { + const variantTag = s.suggestion.variant ? ` / ${s.suggestion.variant.label}` : ""; + const replacing = s.replacing ? ` (could replace ${s.replacing})` : ""; + return `${i + 1}. ${s.suggestion.component}${variantTag} (familiarity ${s.familiarity}/5)${replacing} + ${s.rationale}`; + }) + ]; + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { stretches, audience: audience.name ?? null } + }; +} +async function repairChartConfigHandler(args) { + const { component, data, intent, maxAlternatives } = args; + const intentArg = Array.isArray(intent) ? intent : intent ? [intent] : void 0; + const result = (0, import_ai3.repairChartConfig)(component, data, { + intent: intentArg, + maxAlternatives: maxAlternatives ?? 3 + }); + const lines = []; + if (result.status === "ok") { + lines.push(`\u2705 ${component} fits this dataset \u2014 no repair needed.`); + } else if (result.status === "alternative") { + lines.push(`\u26A0 ${component} doesn't fit: ${result.reason}`); + lines.push(""); + lines.push(`Alternatives that fit${intentArg ? ` (ranked by intent: ${intentArg.join(", ")})` : ""}:`); + for (let i = 0; i < result.alternatives.length; i++) { + const s = result.alternatives[i]; + const variantTag = s.variant ? ` / ${s.variant.label}` : ""; + const reasons = s.reasons.length ? ` \u2014 ${s.reasons.join("; ")}` : ""; + lines.push(`${i + 1}. ${s.component}${variantTag} (score ${s.score.toFixed(1)}/5)${reasons}`); + } + } else { + lines.push(`\u2753 No capability registered for "${component}". Closest matches:`); + for (let i = 0; i < result.alternatives.length; i++) { + const s = result.alternatives[i]; + lines.push(`${i + 1}. ${s.component} (${s.family}, score ${s.score.toFixed(1)}/5)`); + } + } + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: result + }; +} +async function interrogateChartHandler(args) { + const { component, props, query } = args; + const data = props.data || props.nodes || []; + const summary = (0, import_ai3.summarizeData)(data); + const content = [ + { type: "text", text: `Statistical summary for ${component}: +${JSON.stringify(summary, null, 2)}` } + ]; + if (query) { + content.push({ + type: "text", + text: `User Question: "${query}" + +Contextual instructions: +1. Analyze the statistical summary to answer the question. +2. Return a natural language response. +3. Optionally suggest a JSON array of Semiotic annotations to visually highlight the answer on the chart (e.g. { type: "callout", x: "Mar", y: 1500, label: "Peak month" }). +4. Use the accessor names from the provided props (e.g. xAccessor, yAccessor).` + }); + } + return { content, structuredContent: { summary, component, props } }; +} function createServer2() { const srv = new McpServer({ name: "semiotic", @@ -32927,6 +33087,93 @@ function createServer2() { }, applyThemeHandler ); + srv.tool( + "interrogateChart", + "Conversational interrogation of a Semiotic chart. Extract a statistical summary and answer natural language questions about the data, trends, and outliers. Returns a summary and guidance for an AI to generate a textual answer and visual annotations.", + { + component: external_exports3.string().describe("Chart component name, e.g. 'LineChart'"), + props: external_exports3.record(external_exports3.string(), external_exports3.unknown()).describe("The full chart props including data"), + query: external_exports3.string().optional().describe("A natural language question about the chart data") + }, + interrogateChartHandler + ); + srv.tool( + "suggestStreamCharts", + "Recommend realtime/streaming Semiotic charts for a schema (not row data). Pass a schema describing field types plus optional throughput ('low'|'medium'|'high') and retention ('windowed'|'cumulative') hints; the engine ranks realtime charts (RealtimeLineChart, RealtimeHistogram, RealtimeHeatmap, RealtimeWaterfallChart, RealtimeSwarmChart, TemporalHistogram) by their fit. Use when the user is wiring up a live dashboard or monitoring view rather than visualizing a bounded dataset.", + { + schema: external_exports3.object({ + fields: external_exports3.array( + external_exports3.object({ + name: external_exports3.string(), + kind: external_exports3.enum(["numeric", "categorical", "date", "boolean"]), + role: external_exports3.enum(["x", "y", "value", "category", "series", "size"]).optional() + }) + ), + throughput: external_exports3.enum(["low", "medium", "high"]).optional(), + retention: external_exports3.enum(["windowed", "cumulative"]).optional() + }).describe("Stream schema \u2014 fields plus throughput/retention hints. No row data."), + intent: external_exports3.union([external_exports3.string(), external_exports3.array(external_exports3.string())]).optional().describe("Ranking intent."), + maxResults: external_exports3.number().int().min(1).max(20).optional() + }, + suggestStreamChartsHandler + ); + srv.tool( + "suggestDashboard", + "Generate a dashboard of complementary chart panels for a dataset \u2014 each panel answers a distinct analytical intent (trend, rank, distribution, correlation, etc.) and the engine diversifies by chart family by default. Heuristic only; no LLM call. Use when the user asks 'show me this data' or 'build me a dashboard' rather than picking one chart.", + { + data: external_exports3.array(external_exports3.record(external_exports3.string(), external_exports3.unknown())).describe("Row data \u2014 array of objects."), + intents: external_exports3.array(external_exports3.string()).optional().describe("Intents to cover. Omit to let the engine pick based on the data shape."), + maxPanels: external_exports3.number().int().min(1).max(12).optional().describe("Maximum panels (default 6)."), + diversifyByFamily: external_exports3.boolean().optional().describe("Prefer not to repeat chart families across panels (default true).") + }, + suggestDashboardHandler + ); + srv.tool( + "suggestStretchCharts", + "Recommend literacy-growth chart picks for a dataset given an AudienceProfile. Returns charts the data supports but the audience is unfamiliar with (familiarity \u2264 3, or \u2264 4 at exposureLevel 2), each paired with the familiar chart it could substitute for and a rationale. Use when the consumer wants to gently expose users to less familiar but more analytically appropriate visualizations.", + { + data: external_exports3.array(external_exports3.record(external_exports3.string(), external_exports3.unknown())).describe("Row data."), + audience: external_exports3.object({ + name: external_exports3.string().optional(), + familiarity: external_exports3.record(external_exports3.string(), external_exports3.number()).optional(), + targets: external_exports3.record( + external_exports3.string(), + external_exports3.object({ + direction: external_exports3.enum(["increase", "decrease"]), + weight: external_exports3.number().int().min(1).max(3).optional(), + reason: external_exports3.string().optional() + }) + ).optional(), + exposureLevel: external_exports3.union([external_exports3.literal(0), external_exports3.literal(1), external_exports3.literal(2)]).optional() + }).describe("Audience profile \u2014 familiarity, targets, exposure level."), + intent: external_exports3.union([external_exports3.string(), external_exports3.array(external_exports3.string())]).optional(), + maxResults: external_exports3.number().int().min(1).max(20).optional() + }, + suggestStretchChartsHandler + ); + srv.tool( + "repairChartConfig", + "Validate that a chart component is a sensible choice for a dataset, and if not, propose alternatives that fit. Use when a user asks for a specific chart and you want to confirm it's appropriate, or when you've drafted a config and want to verify it. Returns either ok (no change needed), alternative (chart doesn't fit; here are ranked replacements with rationale), or unknown (no capability registered).", + { + component: external_exports3.string().describe("Chart component name to validate, e.g. 'PieChart'"), + data: external_exports3.array(external_exports3.record(external_exports3.string(), external_exports3.unknown())).describe("Row data \u2014 array of objects."), + intent: external_exports3.union([external_exports3.string(), external_exports3.array(external_exports3.string())]).optional().describe("User intent \u2014 informs ranking of alternatives when the chart doesn't fit."), + maxAlternatives: external_exports3.number().int().min(1).max(10).optional().describe("Cap on alternatives returned (default 3).") + }, + repairChartConfigHandler + ); + srv.tool( + "suggestCharts", + "Recommend Semiotic charts for a dataset using heuristic capability descriptors. Each chart declares which data shapes it serves and which intents (trend, compare-categories, distribution, correlation, part-to-whole, etc.) it answers \u2014 the engine returns a ranked list with scores, reasons, caveats, and ready-to-use props. Heuristic only; no LLM call. Use the result as structured context when answering 'what chart should I use?' or generating chart code.", + { + data: external_exports3.array(external_exports3.record(external_exports3.string(), external_exports3.unknown())).describe("Row data \u2014 array of objects."), + intent: external_exports3.union([external_exports3.string(), external_exports3.array(external_exports3.string())]).optional().describe("Ranking intent. One of: trend, compare-series, compare-categories, rank, part-to-whole, distribution, correlation, flow, hierarchy, geo, outlier-detection, composition-over-time, change-detection. Custom intents accepted."), + maxResults: external_exports3.number().int().min(1).max(40).optional().describe("Cap on suggestions returned (default 8)."), + allow: external_exports3.array(external_exports3.string()).optional().describe("Restrict to these component names."), + deny: external_exports3.array(external_exports3.string()).optional().describe("Exclude these component names.") + }, + suggestChartsHandler + ); return srv; } var cliArgs = process.argv.slice(2); diff --git a/ai/mcp-server.ts b/ai/mcp-server.ts index a4cfbfa7..a8f2309c 100644 --- a/ai/mcp-server.ts +++ b/ai/mcp-server.ts @@ -32,7 +32,16 @@ import * as path from "path" import * as http from "http" import { renderHOCToSVG } from "./renderHOCToSVG" import { COMPONENT_REGISTRY } from "./componentRegistry" -import { diagnoseConfig } from "semiotic/ai" +import { + diagnoseConfig, + summarizeData, + suggestCharts as suggestChartsFromCapabilities, + repairChartConfig as repairChartConfigFromCapabilities, + suggestDashboard as suggestDashboardFromCapabilities, + suggestStreamCharts as suggestStreamChartsFromCapabilities, + suggestStretchCharts as suggestStretchChartsFromCapabilities, +} from "semiotic/ai" +import type { IntentId, StreamSchema, AudienceProfile } from "semiotic/ai" const { componentIndexFromSchema, @@ -432,6 +441,218 @@ async function applyThemeHandler(args: { name?: string }): Promise { } } +async function suggestChartsHandler(args: { + data: unknown[] + intent?: string | string[] + maxResults?: number + allow?: string[] + deny?: string[] + audience?: AudienceProfile +}): Promise { + const { data, intent, maxResults, allow, deny, audience } = args + const intentArg = (Array.isArray(intent) ? intent : intent ? [intent] : undefined) as + | IntentId[] + | undefined + + const suggestions = suggestChartsFromCapabilities(data as Record[], { + intent: intentArg, + allow, + deny, + maxResults: maxResults ?? 8, + audience, + }) + + const lines: string[] = [ + `${suggestions.length} suggestion${suggestions.length === 1 ? "" : "s"} for ${(data as unknown[]).length} rows${intentArg ? ` (intent: ${intentArg.join(", ")})` : ""}:`, + "", + ...suggestions.map((s, i) => { + const variantTag = s.variant ? ` / ${s.variant.label}` : "" + const reasons = s.reasons.length ? ` — ${s.reasons.join("; ")}` : "" + const caveats = s.caveats.length ? `\n caveats: ${s.caveats.join("; ")}` : "" + return `${i + 1}. ${s.component}${variantTag} (score ${s.score.toFixed(1)}/5, familiarity ${s.rubric.familiarity}, accuracy ${s.rubric.accuracy})${reasons}${caveats}` + }), + ] + + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { suggestions }, + } +} + +async function suggestStreamChartsHandler(args: { + schema: StreamSchema + intent?: string | string[] + maxResults?: number +}): Promise { + const { schema, intent, maxResults } = args + const intentArg = (Array.isArray(intent) ? intent : intent ? [intent] : undefined) as + | IntentId[] + | undefined + + const suggestions = suggestStreamChartsFromCapabilities(schema, { + intent: intentArg, + maxResults: maxResults ?? 8, + }) + + const lines: string[] = [ + `${suggestions.length} stream chart suggestion${suggestions.length === 1 ? "" : "s"}${intentArg ? ` (intent: ${intentArg.join(", ")})` : ""}`, + ...(schema.throughput ? [`throughput: ${schema.throughput}`] : []), + ...(schema.retention ? [`retention: ${schema.retention}`] : []), + "", + ...suggestions.map((s, i) => { + const reasons = s.reasons.length ? ` — ${s.reasons.join("; ")}` : "" + const caveats = s.caveats.length ? `\n caveats: ${s.caveats.join("; ")}` : "" + return `${i + 1}. ${s.component} (score ${s.score.toFixed(1)}/5)${reasons}${caveats}` + }), + ] + + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { suggestions, schema }, + } +} + +async function suggestDashboardHandler(args: { + data: unknown[] + intents?: string[] + maxPanels?: number + diversifyByFamily?: boolean + audience?: AudienceProfile +}): Promise { + const { data, intents, maxPanels, diversifyByFamily, audience } = args + const dashboard = suggestDashboardFromCapabilities(data as Record[], { + intents: intents as IntentId[] | undefined, + maxPanels: maxPanels ?? 6, + diversifyByFamily: diversifyByFamily !== false, + audience, + }) + + const lines: string[] = [] + lines.push(`Dashboard: ${dashboard.panels.length} panels covering ${dashboard.intentsCovered.join(", ") || "—"}`) + if (dashboard.intentsMissing.length) { + lines.push(`Intents this data couldn't fill: ${dashboard.intentsMissing.join(", ")}`) + } + lines.push("") + for (let i = 0; i < dashboard.panels.length; i++) { + const { intent, suggestion } = dashboard.panels[i] + const variantTag = suggestion.variant ? ` / ${suggestion.variant.label}` : "" + lines.push(`${i + 1}. [${intent}] ${suggestion.component}${variantTag} (score ${suggestion.score.toFixed(1)}/5)`) + if (suggestion.reasons.length) lines.push(` ${suggestion.reasons.join("; ")}`) + } + if (dashboard.stretchPanels.length > 0) { + lines.push("") + lines.push(`Stretch picks (audience-unfamiliar but fitting):`) + for (const stretch of dashboard.stretchPanels) { + const variantTag = stretch.suggestion.variant ? ` / ${stretch.suggestion.variant.label}` : "" + lines.push(` ${stretch.suggestion.component}${variantTag} (familiarity ${stretch.familiarity}) — ${stretch.rationale}`) + } + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: dashboard, + } +} + +async function suggestStretchChartsHandler(args: { + data: unknown[] + audience: AudienceProfile + intent?: string | string[] + maxResults?: number +}): Promise { + const { data, audience, intent, maxResults } = args + const intentArg = (Array.isArray(intent) ? intent : intent ? [intent] : undefined) as + | IntentId[] + | undefined + + const stretches = suggestStretchChartsFromCapabilities(data as Record[], { + audience, + intent: intentArg, + maxResults: maxResults ?? 5, + }) + + const lines: string[] = [ + `${stretches.length} stretch pick${stretches.length === 1 ? "" : "s"} for "${audience.name ?? "audience"}":`, + "", + ...stretches.map((s, i) => { + const variantTag = s.suggestion.variant ? ` / ${s.suggestion.variant.label}` : "" + const replacing = s.replacing ? ` (could replace ${s.replacing})` : "" + return `${i + 1}. ${s.suggestion.component}${variantTag} (familiarity ${s.familiarity}/5)${replacing}\n ${s.rationale}` + }), + ] + + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { stretches, audience: audience.name ?? null }, + } +} + +async function repairChartConfigHandler(args: { + component: string + data: unknown[] + intent?: string | string[] + maxAlternatives?: number +}): Promise { + const { component, data, intent, maxAlternatives } = args + const intentArg = (Array.isArray(intent) ? intent : intent ? [intent] : undefined) as + | IntentId[] + | undefined + + const result = repairChartConfigFromCapabilities(component, data as Record[], { + intent: intentArg, + maxAlternatives: maxAlternatives ?? 3, + }) + + const lines: string[] = [] + if (result.status === "ok") { + lines.push(`✅ ${component} fits this dataset — no repair needed.`) + } else if (result.status === "alternative") { + lines.push(`⚠ ${component} doesn't fit: ${result.reason}`) + lines.push("") + lines.push(`Alternatives that fit${intentArg ? ` (ranked by intent: ${intentArg.join(", ")})` : ""}:`) + for (let i = 0; i < result.alternatives.length; i++) { + const s = result.alternatives[i] + const variantTag = s.variant ? ` / ${s.variant.label}` : "" + const reasons = s.reasons.length ? ` — ${s.reasons.join("; ")}` : "" + lines.push(`${i + 1}. ${s.component}${variantTag} (score ${s.score.toFixed(1)}/5)${reasons}`) + } + } else { + lines.push(`❓ No capability registered for "${component}". Closest matches:`) + for (let i = 0; i < result.alternatives.length; i++) { + const s = result.alternatives[i] + lines.push(`${i + 1}. ${s.component} (${s.family}, score ${s.score.toFixed(1)}/5)`) + } + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: result, + } +} + +async function interrogateChartHandler(args: { + component: string + props: Record + query?: string +}): Promise { + const { component, props, query } = args + const data = (props.data as unknown[]) || (props.nodes as unknown[]) || [] + const summary = summarizeData(data as Record[]) + + const content: Array<{ type: "text"; text: string }> = [ + { type: "text", text: `Statistical summary for ${component}:\n${JSON.stringify(summary, null, 2)}` }, + ] + + if (query) { + content.push({ + type: "text", + text: `User Question: "${query}"\n\nContextual instructions:\n1. Analyze the statistical summary to answer the question.\n2. Return a natural language response.\n3. Optionally suggest a JSON array of Semiotic annotations to visually highlight the answer on the chart (e.g. { type: "callout", x: "Mar", y: 1500, label: "Peak month" }).\n4. Use the accessor names from the provided props (e.g. xAccessor, yAccessor).`, + }) + } + + return { content, structuredContent: { summary, component, props } } +} + // ── Server factory ─────────────────────────────────────────────────────── // Creates a fresh McpServer with all tools registered. // HTTP mode needs one instance per session (McpServer can only connect to one transport). @@ -635,6 +856,114 @@ function createServer(): McpServer { applyThemeHandler ) + srv.tool( + "interrogateChart", + "Conversational interrogation of a Semiotic chart. Extract a statistical summary and answer natural language questions about the data, trends, and outliers. Returns a summary and guidance for an AI to generate a textual answer and visual annotations.", + { + component: z.string().describe("Chart component name, e.g. 'LineChart'"), + props: z.record(z.string(), z.unknown()).describe("The full chart props including data"), + query: z.string().optional().describe("A natural language question about the chart data"), + }, + interrogateChartHandler + ) + + srv.tool( + "suggestStreamCharts", + "Recommend realtime/streaming Semiotic charts for a schema (not row data). Pass a schema describing field types plus optional throughput ('low'|'medium'|'high') and retention ('windowed'|'cumulative') hints; the engine ranks realtime charts (RealtimeLineChart, RealtimeHistogram, RealtimeHeatmap, RealtimeWaterfallChart, RealtimeSwarmChart, TemporalHistogram) by their fit. Use when the user is wiring up a live dashboard or monitoring view rather than visualizing a bounded dataset.", + { + schema: z + .object({ + fields: z.array( + z.object({ + name: z.string(), + kind: z.enum(["numeric", "categorical", "date", "boolean"]), + role: z.enum(["x", "y", "value", "category", "series", "size"]).optional(), + }), + ), + throughput: z.enum(["low", "medium", "high"]).optional(), + retention: z.enum(["windowed", "cumulative"]).optional(), + }) + .describe("Stream schema — fields plus throughput/retention hints. No row data."), + intent: z + .union([z.string(), z.array(z.string())]) + .optional() + .describe("Ranking intent."), + maxResults: z.number().int().min(1).max(20).optional(), + }, + suggestStreamChartsHandler + ) + + srv.tool( + "suggestDashboard", + "Generate a dashboard of complementary chart panels for a dataset — each panel answers a distinct analytical intent (trend, rank, distribution, correlation, etc.) and the engine diversifies by chart family by default. Heuristic only; no LLM call. Use when the user asks 'show me this data' or 'build me a dashboard' rather than picking one chart.", + { + data: z.array(z.record(z.string(), z.unknown())).describe("Row data — array of objects."), + intents: z.array(z.string()).optional().describe("Intents to cover. Omit to let the engine pick based on the data shape."), + maxPanels: z.number().int().min(1).max(12).optional().describe("Maximum panels (default 6)."), + diversifyByFamily: z.boolean().optional().describe("Prefer not to repeat chart families across panels (default true)."), + }, + suggestDashboardHandler + ) + + srv.tool( + "suggestStretchCharts", + "Recommend literacy-growth chart picks for a dataset given an AudienceProfile. Returns charts the data supports but the audience is unfamiliar with (familiarity ≤ 3, or ≤ 4 at exposureLevel 2), each paired with the familiar chart it could substitute for and a rationale. Use when the consumer wants to gently expose users to less familiar but more analytically appropriate visualizations.", + { + data: z.array(z.record(z.string(), z.unknown())).describe("Row data."), + audience: z + .object({ + name: z.string().optional(), + familiarity: z.record(z.string(), z.number()).optional(), + targets: z + .record( + z.string(), + z.object({ + direction: z.enum(["increase", "decrease"]), + weight: z.number().int().min(1).max(3).optional(), + reason: z.string().optional(), + }), + ) + .optional(), + exposureLevel: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(), + }) + .describe("Audience profile — familiarity, targets, exposure level."), + intent: z.union([z.string(), z.array(z.string())]).optional(), + maxResults: z.number().int().min(1).max(20).optional(), + }, + suggestStretchChartsHandler + ) + + srv.tool( + "repairChartConfig", + "Validate that a chart component is a sensible choice for a dataset, and if not, propose alternatives that fit. Use when a user asks for a specific chart and you want to confirm it's appropriate, or when you've drafted a config and want to verify it. Returns either ok (no change needed), alternative (chart doesn't fit; here are ranked replacements with rationale), or unknown (no capability registered).", + { + component: z.string().describe("Chart component name to validate, e.g. 'PieChart'"), + data: z.array(z.record(z.string(), z.unknown())).describe("Row data — array of objects."), + intent: z + .union([z.string(), z.array(z.string())]) + .optional() + .describe("User intent — informs ranking of alternatives when the chart doesn't fit."), + maxAlternatives: z.number().int().min(1).max(10).optional().describe("Cap on alternatives returned (default 3)."), + }, + repairChartConfigHandler + ) + + srv.tool( + "suggestCharts", + "Recommend Semiotic charts for a dataset using heuristic capability descriptors. Each chart declares which data shapes it serves and which intents (trend, compare-categories, distribution, correlation, part-to-whole, etc.) it answers — the engine returns a ranked list with scores, reasons, caveats, and ready-to-use props. Heuristic only; no LLM call. Use the result as structured context when answering 'what chart should I use?' or generating chart code.", + { + data: z.array(z.record(z.string(), z.unknown())).describe("Row data — array of objects."), + intent: z + .union([z.string(), z.array(z.string())]) + .optional() + .describe("Ranking intent. One of: trend, compare-series, compare-categories, rank, part-to-whole, distribution, correlation, flow, hierarchy, geo, outlier-detection, composition-over-time, change-detection. Custom intents accepted."), + maxResults: z.number().int().min(1).max(40).optional().describe("Cap on suggestions returned (default 8)."), + allow: z.array(z.string()).optional().describe("Restrict to these component names."), + deny: z.array(z.string()).optional().describe("Exclude these component names."), + }, + suggestChartsHandler + ) + return srv } diff --git a/ai/schema.json b/ai/schema.json index 2fee5f24..d118c38c 100644 --- a/ai/schema.json +++ b/ai/schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "name": "semiotic", - "version": "3.5.4", + "version": "3.6.0", "description": "React data visualization library for charts, networks, and beyond", "tools": [ { diff --git a/docs/public/blog/feed.xml b/docs/public/blog/feed.xml index cc6a5857..155eb52d 100644 --- a/docs/public/blog/feed.xml +++ b/docs/public/blog/feed.xml @@ -5,8 +5,30 @@ https://semiotic3.nteract.io/blog/ - 2026-05-18T00:00:00Z + 2026-05-24T00:00:00Z Semiotic + + Charts that know what they're for + https://semiotic3.nteract.io/blog/charts-that-know-what-theyre-for + + + 2026-05-24T00:00:00Z + 2026-05-24T00:00:00Z + Elijah Meeks + + Semiotic 3.6.0 ships a chart recommendation engine that's heuristic-first, LLM-optional, and audience-aware. Charts now carry descriptors that declare what data shapes they serve and which questions they answer; an AudienceProfile layers per-org familiarity and adoption targets on top; a separate 'stretch' surface grows literacy without forcing it. + + + Semiotic 3.5.4 + https://semiotic3.nteract.io/blog/release-3-5-4 + + + 2026-05-21T00:00:00Z + 2026-05-21T00:00:00Z + AI-Generated + + 3.5.4 adds a first-class asymmetric band encoding (with percentile fans) to LineChart and AreaChart, sharpens the axis surface with edge-anchored ticks, CSS-variable font sizes, and per-axis class names, ships loadingContent across every HOC, and collapses bounds + band into a single shared ribbon primitive. + Semiotic 3.5.3 https://semiotic3.nteract.io/blog/release-3-5-3 @@ -14,7 +36,7 @@ 2026-05-18T00:00:00Z 2026-05-18T00:00:00Z - Elijah Meeks + AI-Generated 3.5.3 adds DifferenceChart, exact axis ticks, Swimlane rounded ends, and ProcessSankey lifecycle timing; it also launches the docs blog, refreshes AI capabilities to 45 chart schemas, and wires new release gates for capability and blog metadata drift. @@ -50,7 +72,7 @@ 2026-05-10T00:00:00Z 2026-05-10T00:00:00Z - Elijah Meeks + AI-Generated 3.5.2 is mostly a factor-and-extend release: useSeriesFeatures / useEncodingDomain / useStreamStatus / useXYLineStyle hooks land, ProcessSankey inherits SankeyDiagram's canvas particle pipeline, regression-line sugar extends to five more charts, FlowMap joins the push family, and ai/capabilities.json indexes all 44 charts. diff --git a/docs/public/blog/og/charts-that-know-what-theyre-for.png b/docs/public/blog/og/charts-that-know-what-theyre-for.png new file mode 100644 index 0000000000000000000000000000000000000000..5cf94ce7e7601dd80e5d68498a932a84ccf4df03 GIT binary patch literal 58757 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 OPXT@nmM)|B`2PS)8-Gjy literal 0 HcmV?d00001 diff --git a/docs/public/blog/og/release-3-5-4.png b/docs/public/blog/og/release-3-5-4.png new file mode 100644 index 0000000000000000000000000000000000000000..4a5237d1ddd4f1b77576c306e5359cc4c0fa4310 GIT binary patch literal 67422 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 Promise<{ answer, annotations? }>`** — call your LLM here. `context` is `{ data, summary, componentName?, props? }`. +- **`summary`**: LLM-friendly statistical summary (`rowCount`, per-field `{ min, max, mean, median }` for numerics, top-k for categoricals, ISO range for dates). Available before any ask(). +- **`annotations`**: Merged `initialAnnotations` + latest AI response. Wire to the chart's `annotations` prop for visual highlighting. +- **`summarizeData(data, options?)`**: Standalone for server-side prompting or batch jobs. +- **MCP Tool**: `interrogateChart(component, props, query)` returns the same statistical summary and AI-facing instructions. + +```jsx +import { LineChart, useChartInterrogation } from "semiotic/ai" + +function InterrogatableChart({ data }) { + const { ask, history, annotations, loading } = useChartInterrogation({ + data, + componentName: "LineChart", + props: { xAccessor: "month", yAccessor: "revenue" }, + onQuery: async (query, { summary }) => { + const res = await myLLMCall(query, summary) + return { answer: res.text, annotations: res.highlights } + }, + }) + return ( + <> + + + + ) +} +``` + +### Chart Capability Layer (`semiotic/ai`) +Heuristic chart-suggestion engine. Charts ship capability descriptors next to their TSX files; the engine ranks them against a profiled dataset by intent. No LLM call required. + +- **`profileData(data, { rawInput?, seriesField? })`** → `ChartDataProfile` (extends `DataSummary`): candidate fields per role (x/y/series/category/size/time), distinct counts, monotonicity, structure detection (hierarchy/network/geo). +- **`suggestCharts(data, { intent?, allow?, deny?, maxResults?, includeVariants?, minScore? })`** → ranked `Suggestion[]` with `{ component, family, importPath, variant?, score, intentScores, rubric, reasons, caveats, props }`. `props` is spreadable directly into the matching chart. +- **`scoreChart(component, data, { intent?, variantKey? })`** → evaluate a specific chart for a dataset (does it fit, how well, why/why not). +- **`useChartSuggestions(data, options)`** → memoized React hook returning `{ suggestions, profile }`. +- **`registerChartCapability(capability)`** / **`unregisterChartCapability(name)`** — runtime registration for custom charts. +- **Intent taxonomy**: 13 built-in intents (`trend`, `compare-series`, `compare-categories`, `rank`, `part-to-whole`, `distribution`, `correlation`, `flow`, `hierarchy`, `geo`, `outlier-detection`, `composition-over-time`, `change-detection`). Extend via `registerIntent(descriptor)`. +- **Capability authoring**: create `Foo.capability.ts` next to `Foo.tsx`, then append to the registry in `src/components/ai/chartCapabilities.ts`. Each capability declares `family`, `rubric` (familiarity/accuracy/precision 1-5), `fits(profile)` gate, `intentScores`, optional `variants` with `intentDeltas`, and `buildProps(profile, variant)`. +- **Variants encode that settings change what a chart is good for**: e.g. `StackedAreaChart`'s `streamgraph` variant boosts trend but penalizes part-to-whole. +- **Interrogation tie-in**: pass `includeSuggestions: true` to `useChartInterrogation` and the same ranked list lands in `context.suggestions` for the LLM. +- **MCP tool**: `suggestCharts(data, intent?)` returns the ranked list as structured content. + +```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 null + const Component = COMPONENT_MAP[top.component] + return +} +``` + ## AI Behavior Contracts diff --git a/docs/src/App.js b/docs/src/App.js index f35e4de2..5a7ef6e3 100644 --- a/docs/src/App.js +++ b/docs/src/App.js @@ -94,6 +94,8 @@ import PerformancePage from "./pages/features/PerformancePage" import PushApiPage from "./pages/features/PushApiPage" import CustomChartsPage from "./pages/features/CustomChartsPage" import CapabilitiesPage from "./pages/features/CapabilitiesPage" +import InterrogationPage from "./pages/features/InterrogationPage" +import SuggestionsPage from "./pages/features/SuggestionsPage" // New cookbook pages import HomerunMapPage from "./pages/cookbook/HomerunMapPage" @@ -383,16 +385,32 @@ export default function DocsApp() { } /> } /> } /> - } /> - } /> - } /> } /> } /> } /> } /> + + + {/* Intelligence — AI/recommendation surface, separated from generic Features + in 3.5.x. Old /features/ paths redirect to /intelligence/ + via dedicated routes below. */} + }> + } /> } /> + } /> + } /> + } /> + } /> + {/* Redirects from old /features/ paths for the Intelligence pages */} + } /> + } /> + } /> + } /> + } /> + } /> + {/* Using Server-Side Rendering */} } /> } /> diff --git a/docs/src/blog/components/BlogEntryView.js b/docs/src/blog/components/BlogEntryView.js index 678fd8eb..afacf494 100644 --- a/docs/src/blog/components/BlogEntryView.js +++ b/docs/src/blog/components/BlogEntryView.js @@ -26,6 +26,15 @@ export default function BlogEntryView({ entry }) { (it owns the docs top bar). That collapsed the entry's title onto a 235-px column inside the flex header. */}
+ {entry.draft && ( +
+ DRAFT + + Unlisted — not in the blog index, RSS, or search engines. Flip{" "} + draft: true off in the entry's registry to publish. + +
+ )}

{entry.title}

{entry.subtitle}

@@ -126,4 +135,29 @@ const styles = { color: "var(--text-primary, #e5e7eb)", maxWidth: 860, }, + draftBanner: { + display: "flex", + alignItems: "center", + gap: 12, + background: "rgba(251, 191, 36, 0.12)", + border: "1px solid rgba(251, 191, 36, 0.35)", + borderRadius: 8, + padding: "10px 14px", + marginBottom: 24, + fontSize: 13, + color: "var(--text-secondary, #94a3b8)", + }, + draftBadge: { + background: "rgb(217, 119, 6)", + color: "white", + fontSize: 11, + fontWeight: 700, + letterSpacing: "0.08em", + padding: "3px 8px", + borderRadius: 4, + flexShrink: 0, + }, + draftNote: { + lineHeight: 1.5, + }, } diff --git a/docs/src/blog/entries-meta.js b/docs/src/blog/entries-meta.js index 511178a5..228a6b53 100644 --- a/docs/src/blog/entries-meta.js +++ b/docs/src/blog/entries-meta.js @@ -15,7 +15,57 @@ * scripts/check-blog-entry-sync.mjs). */ -export const blogEntriesMeta = [ +// Same shape as `entries.js`'s `allBlogEntries`. Drafts (entries with +// `draft: true`) are included here so the sync check and per-entry +// inspection still work; the build scripts filter at consumption time. +export const allBlogEntriesMeta = [ + { + slug: "live-conversational-dashboard", + title: "Live conversational dashboards", + subtitle: + "Streaming data + an AI watching alongside you + anchored annotations + a conversational follow-up surface. The class of product Semiotic's streaming-first runtime makes possible.", + author: "Elijah Meeks", + date: "2026-05-24", + tags: ["case-study", "realtime"], + excerpt: + "Static dashboards show the past; chat-with-chart makes the past interrogable. Live conversational dashboards add what's missing: an AI watching the stream as it arrives, narrating events anchored to the chart, with a chat surface for human follow-ups. Draft post on composing Semiotic's streaming runtime, interrogation hook, and annotation model into a single product.", + draft: true, + }, + { + slug: "anchored-conversations", + title: "Anchored conversations: when the AI knows which point you're asking about", + subtitle: + "Two-way point-anchored AI conversation: the user clicks, the AI answers about that specific point, and the answer lives on the chart as a clickable note.", + author: "Elijah Meeks", + date: "2026-05-24", + tags: ["case-study"], + excerpt: + "Chat-with-chart works, but the user has to verbalize which point they care about and the AI has to verbalize where the answer applies — both steps lose the spatial information that's already on screen. Draft post on bidirectional point-anchored AI conversation, with useChartFocus + useChartInterrogation as the building blocks.", + draft: true, + }, + { + slug: "multimodal-response", + title: "Multimodal response: chart as output channel", + subtitle: + "Text is half the answer. The other half — callouts, thresholds, bands, selections — lives on the chart, and LLMs already know how to ask for it.", + author: "Elijah Meeks", + date: "2026-05-24", + tags: ["case-study"], + excerpt: + "Modern LLM assistants treat text as the only output channel. When the question is about a chart, charts give us a parallel surface — callouts, threshold lines, bands, selections — that's both more honest and easier to read. Drafted exploration of what multimodal response means in practice.", + draft: true, + }, + { + slug: "charts-that-know-what-theyre-for", + title: "Charts that know what they're for", + subtitle: + "A heuristic-first chart recommendation engine with per-audience calibration, a literacy-growth surface, and ready-to-render props.", + author: "Elijah Meeks", + date: "2026-05-24", + tags: ["case-study"], + excerpt: + "Semiotic 3.6.0 ships a chart recommendation engine that's heuristic-first, LLM-optional, and audience-aware. Charts now carry descriptors that declare what data shapes they serve and which questions they answer; an AudienceProfile layers per-org familiarity and adoption targets on top; a separate 'stretch' surface grows literacy without forcing it.", + }, { slug: "release-3-5-4", title: "Semiotic 3.5.4", @@ -122,3 +172,7 @@ export const blogEntriesMeta = [ ogChart: { component: "OrbitDiagram" }, }, ] + +// Published-only mirror — what RSS, prerender, and OG-card emit. Anything +// marked `draft: true` in `allBlogEntriesMeta` is dropped. +export const blogEntriesMeta = allBlogEntriesMeta.filter((entry) => !entry.draft) diff --git a/docs/src/blog/entries.js b/docs/src/blog/entries.js index 1780d23c..074d3973 100644 --- a/docs/src/blog/entries.js +++ b/docs/src/blog/entries.js @@ -23,6 +23,11 @@ * // SSR-renderable spec via `ogChart` if you want a different * // chart than the first one shown in the entry. * ogChart?: { component: string, props: Record } + * + * // Set `draft: true` to keep an entry out of the index list, the + * // RSS feed, and SEO prerender meta. The route still resolves at + * // /blog// so authors can preview before publishing. + * draft?: boolean * } * * Tags vocabulary (additive — don't be precious): @@ -42,8 +47,21 @@ import DifferenceChartExplainer from "./entries/difference-chart.js" import QuadrantChartExplainer from "./entries/quadrant-chart.js" import FunnelChartExplainer from "./entries/funnel-chart.js" import OrbitDiagramExplainer from "./entries/orbit-diagram.js" +import ChartsThatKnow from "./entries/charts-that-know-what-theyre-for.js" +import MultimodalResponse from "./entries/multimodal-response.js" +import AnchoredConversations from "./entries/anchored-conversations.js" +import LiveDashboard from "./entries/live-conversational-dashboard.js" -export const blogEntries = [ +/** + * Every entry, drafts included. Consumers that need the full list (direct + * URL access, sync check) read this. Consumers that should NEVER surface + * drafts (index listing, RSS, SEO prerender) read `blogEntries` below. + */ +export const allBlogEntries = [ + LiveDashboard, + AnchoredConversations, + MultimodalResponse, + ChartsThatKnow, Release354, Release353, ProcessSankeyVsClassicSankey, @@ -55,8 +73,19 @@ export const blogEntries = [ OrbitDiagramExplainer, ] +/** + * Published entries — the canonical reader-facing list. Filters out anything + * marked `draft: true`. Used by the blog index, RSS feed, and prerender. + */ +export const blogEntries = allBlogEntries.filter((entry) => !entry.draft) + +/** + * Slug lookup intentionally returns drafts too. Drafts must be routable so + * authors can preview them before publishing — the listings and feeds are + * the surfaces that filter, not the URL space. + */ export function getEntry(slug) { - return blogEntries.find((e) => e.slug === slug) + return allBlogEntries.find((e) => e.slug === slug) } export function entriesByDateDesc() { diff --git a/docs/src/blog/entries/anchored-conversations.js b/docs/src/blog/entries/anchored-conversations.js new file mode 100644 index 00000000..409bb66f --- /dev/null +++ b/docs/src/blog/entries/anchored-conversations.js @@ -0,0 +1,668 @@ +import React, { useMemo, useState } from "react" +import { Link } from "react-router-dom" +import { LineChart } from "semiotic" + +// ─── Shared blog styling ────────────────────────────────────────────────── +const chartFrame = { + background: "var(--surface-1)", + borderRadius: 8, + padding: 16, + border: "1px solid var(--surface-3)", + overflow: "hidden", + margin: "20px 0", + position: "relative", +} + +const chatPanel = { + background: "var(--surface-2)", + borderRadius: 8, + padding: 12, + marginTop: 12, + fontSize: 13, + lineHeight: 1.5, + minHeight: 100, +} + +const userBubble = { + display: "inline-block", + background: "var(--accent)", + color: "white", + padding: "6px 12px", + borderRadius: "12px 12px 2px 12px", + marginBottom: 6, + maxWidth: "85%", +} + +const aiBubble = { + display: "inline-block", + background: "var(--surface-3)", + color: "var(--text)", + padding: "6px 12px", + borderRadius: "12px 12px 12px 2px", + marginBottom: 6, + maxWidth: "85%", + whiteSpace: "pre-wrap", +} + +const inputRow = { display: "flex", gap: 6, marginTop: 10 } +const inputStyle = { + flex: 1, + padding: "6px 10px", + borderRadius: 6, + border: "1px solid var(--surface-3)", + background: "var(--background)", + color: "var(--text)", + fontSize: 13, +} + +const buttonStyle = { + padding: "6px 14px", + borderRadius: 6, + border: "none", + background: "var(--accent)", + color: "white", + fontSize: 13, + fontWeight: 600, + cursor: "pointer", +} + +const focusBadge = { + display: "inline-block", + background: "rgba(94,234,212,0.15)", + color: "var(--accent)", + padding: "2px 8px", + borderRadius: 999, + fontSize: 11, + fontWeight: 700, + letterSpacing: "0.04em", + marginLeft: 8, +} + +// ─── Demo data ──────────────────────────────────────────────────────────── +const SALES_DATA = [ + { month: 1, revenue: 1100, label: "Jan" }, + { month: 2, revenue: 1180, label: "Feb" }, + { month: 3, revenue: 1320, label: "Mar" }, + { month: 4, revenue: 1450, label: "Apr" }, + { month: 5, revenue: 2200, label: "May" }, + { month: 6, revenue: 1610, label: "Jun" }, + { month: 7, revenue: 1720, label: "Jul" }, + { month: 8, revenue: 1830, label: "Aug" }, + { month: 9, revenue: 1950, label: "Sep" }, + { month: 10, revenue: 1380, label: "Oct" }, + { month: 11, revenue: 2080, label: "Nov" }, + { month: 12, revenue: 2240, label: "Dec" }, +] + +// Canned LLM stand-in. A real implementation calls a model with: +// { question, focus.datum, summary, profile } +// and the model returns the same shape. +function cannedAnchoredResponder(question, focus) { + const q = question.toLowerCase() + // No focus: encourage the user to point at something + if (!focus) { + return { + text: "Hover or click a point on the chart first — I'll answer about that specific point.", + annotation: null, + } + } + const { month, revenue, label } = focus.datum + // Specific known-shape questions get rich answers anchored to the point. + if (q.includes("why") || q.includes("explain")) { + if (month === 5) { + return { + text: `May's $2,200 was driven by a spring promotion — it's well above the smooth trend the rest of the year follows. Removing it, the trajectory is almost monotonic.`, + annotation: { + type: "callout", + month, + revenue, + label: "Promo-driven spike", + note: "Spring 2024 product launch + 15% sitewide discount. Not repeatable; treat as one-off in forecasts.", + dx: 30, + dy: -30, + }, + } + } + if (month === 10) { + return { + text: `October's $1,380 is the year's dip — a four-day outage at the start of the month is the likely cause. The Nov/Dec recovery suggests no lasting impact.`, + annotation: { + type: "callout", + month, + revenue, + label: "Outage week", + note: "Oct 2–5 platform outage. Recovered by mid-month; Nov/Dec returned to trend.", + dx: -30, + dy: 30, + }, + } + } + return { + text: `${label} (${revenue}) sits ${revenue > 1670 ? "above" : "below"} the year's $1,670 average. Without a known incident here, this looks like ordinary variance.`, + annotation: { + type: "callout", + month, + revenue, + label: `${label}: ${revenue > 1670 ? "above avg" : "below avg"}`, + note: `${revenue > 1670 ? "+" : ""}${revenue - 1670} vs. $1,670 average.`, + }, + } + } + if (q.includes("compare")) { + return { + text: `${label} ($${revenue}) compared to the year average of $1,670: a difference of ${revenue > 1670 ? "+" : ""}$${revenue - 1670}. Among ${SALES_DATA.length} months, ${SALES_DATA.filter((d) => d.revenue > revenue).length} were higher and ${SALES_DATA.filter((d) => d.revenue < revenue).length} were lower.`, + annotation: { + type: "callout", + month, + revenue, + label: `${label}`, + note: `Rank: ${SALES_DATA.slice().sort((a, b) => b.revenue - a.revenue).findIndex((d) => d.month === month) + 1} of ${SALES_DATA.length}.`, + }, + } + } + // Default: small, factual answer about the focused point + return { + text: `${label}: revenue $${revenue}. ${revenue > 1670 ? "Above" : "Below"} the $1,670 yearly average.`, + annotation: { + type: "callout", + month, + revenue, + label, + note: `${revenue > 1670 ? "Above" : "Below"} average month.`, + }, + } +} + +// ─── Comment marker overlay ────────────────────────────────────────────── +// Renders interactive markers on top of the chart for AI-anchored comments. +// Reads annotation entries that carry a `note` field (the AI's narrative +// rationale) and renders a hoverable dot positioned at the same x/y as the +// callout. This is the reusable pattern the post documents — copy it into +// your own consumer code. +function CommentOverlay({ annotations, scales }) { + const [openId, setOpenId] = useState(null) + if (!annotations || !scales) return null + const comments = annotations.filter((a) => a.note) + return ( + <> + {comments.map((c, i) => { + const key = `${c.month}-${i}` + const x = scales.x(c.month) + const y = scales.y(c.revenue) + return ( +
setOpenId(openId === key ? null : key)} + title="AI comment" + /> + ) + })} + {openId && + comments.map((c, i) => { + const key = `${c.month}-${i}` + if (key !== openId) return null + const x = scales.x(c.month) + const y = scales.y(c.revenue) + return ( +
+
+ AI note · {c.label || `month ${c.month}`} +
+
{c.note}
+ +
+ ) + })} + + ) +} + +function AnchoredDemo() { + const [focusIndex, setFocusIndex] = useState(null) + const [transcript, setTranscript] = useState([]) + const [annotations, setAnnotations] = useState([]) + const [input, setInput] = useState("Why is this point so different?") + + const focus = focusIndex == null ? null : { + datum: SALES_DATA[focusIndex], + source: "click", + } + + // The chart's internal linear scales mapped to the rendered pixel + // dimensions. In production code this comes from the chart's ref + // (`chart.current.getScales()`); here we hardcode them to keep the demo + // self-contained. + const PLOT = { left: 60, right: 30, top: 30, bottom: 40, width: 600, height: 280 } + const scales = useMemo(() => { + const innerW = PLOT.width - PLOT.left - PLOT.right + const innerH = PLOT.height - PLOT.top - PLOT.bottom + return { + x: (m) => PLOT.left + ((m - 1) / 11) * innerW, + y: (r) => PLOT.top + innerH - ((r - 800) / (2400 - 800)) * innerH, + } + }, []) + + const handleClick = (datum) => { + const idx = SALES_DATA.findIndex((d) => d.month === datum.month) + setFocusIndex(idx === focusIndex ? null : idx) + } + + const send = () => { + if (!input.trim()) return + const userText = input + const { text, annotation } = cannedAnchoredResponder(userText, focus) + setTranscript((t) => [...t, { role: "user", text: userText }, { role: "assistant", text }]) + if (annotation) { + setAnnotations((a) => { + // Replace any existing annotation for the same datum so re-asking + // about the same point updates the marker rather than stacking. + const keep = a.filter( + (x) => !(x.month === annotation.month && x.revenue === annotation.revenue), + ) + return [...keep, annotation] + }) + } + setInput("") + } + + const reset = () => { + setFocusIndex(null) + setTranscript([]) + setAnnotations([]) + } + + // Highlight ring on the currently focused point + const focusRing = focusIndex == null + ? null + : (() => { + const datum = SALES_DATA[focusIndex] + const x = scales.x(datum.month) + const y = scales.y(datum.revenue) + return ( +
+ ) + })() + + return ( +
+
+ + {focusRing} + +
+ +
+
+ + Anchored conversation + + {focus ? ( + + focused: {focus.datum.label} (${focus.datum.revenue}) + + ) : ( + + no focus — click a point + + )} +
+ {transcript.length === 0 && ( +
+ Click a chart point to focus on it, then ask a question. The answer comes back + both as text here AND as a clickable AI note anchored to that point. +
+ )} + {transcript.map((m, i) => ( +
+
{m.text}
+
+ ))} +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && send()} + placeholder='Try: "Why is this so high?", "Compare this to the average"' + style={inputStyle} + /> + + {(transcript.length > 0 || annotations.length > 0) && ( + + )} +
+
+ +
+ ) +} + +function Body() { + return ( + <> +

+ The most common AI-on-a-chart pattern today is "ask the chart" — type a question, get a + paragraph back. It works, but it's lossy in both directions: the user has to verbalize + which point they care about, and the AI has to verbalize where the answer applies. Both + steps lose the spatial information that's already on screen. There's a better shape: + let the user point at a data point, and let the AI annotate the + answer back onto it. A two-way anchored conversation. +

+ +

The loop in three frames

+
    +
  1. + User hovers or clicks a data point. The chart fires an observation + event; we capture which datum the user is looking at and pass it into the chat as the + "focus." +
  2. +
  3. + User asks a question. The LLM receives both the question AND the + focused datum. The prompt is "answer this question about this specific row" + — not "about the chart in general." +
  4. +
  5. + AI responds in two channels. Text in the transcript, anchored note + back on the chart at the same point. Future hover over that point shows the AI's + rationale; future questions in the same conversation can reference earlier comments. +
  6. +
+ +

Try it

+

+ Click any month's data point to focus on it. The dashed ring marks the focus; the chat + shows what's currently selected. Ask a question — the AI's answer arrives as a text + bubble AND a small turquoise dot on the chart. Click the dot to see the AI's anchored + note. Stack questions to build up a multi-point conversation. +

+ +

+ The interesting moves: click May and ask "why is this so high?" — the + AI cites the spring promotion. Click October and ask "why is this so low?" + — it cites the outage. Both rationales then live on the chart as clickable notes that + survive the rest of the conversation. The chart accumulates institutional knowledge + about itself. +

+ +

Why anchoring matters

+

+ Three things change once the conversation has a spatial anchor: +

+
    +
  • + Pronouns work. "Why is this one higher?" becomes a + well-formed question instead of a guessing game. The LLM doesn't have to triangulate + from prose what point you meant. +
  • +
  • + Comparisons get cheap. Click two points in succession and ask "what + changed between these?" — the AI compares them directly because both are explicit in + the context. +
  • +
  • + Answers persist where they're useful. The AI's rationale lives next + to the point it explains. When someone else looks at this chart next week, they + hover over October's dip and the explanation is right there — no re-asking, no + re-discovering. Charts become accumulating notebooks of why-the-data-looks-this-way. +
  • +
+ +

Building it

+

+ Semiotic ships the two primitives this needs: useChartInterrogation for the + conversation, useChartFocus for the point-of-interest signal. Wiring them + together is one component: +

+
+{`import { LineChart, ObservationProvider } from "semiotic"
+import { useChartFocus, useChartInterrogation } from "semiotic/ai"
+
+function AnchoredChart({ data }) {
+  // useChartFocus subscribes to the chart's observation store and returns
+  // the latest hover/click as { datum, x, y, source }. Returns null when
+  // the user has moved away or hasn't engaged yet.
+  const focus = useChartFocus({ chartId: "sales" })
+
+  const { ask, history, annotations } = useChartInterrogation({
+    data,
+    focus,                              // ← context.focus inside onQuery
+    onQuery: async (question, ctx) => {
+      // ctx.focus.datum is the row the user is asking about
+      const response = await yourLLMCall({
+        question,
+        focus:   ctx.focus,
+        summary: ctx.summary,
+      })
+      return {
+        answer: response.text,
+        // Return annotations with a \`note\` field — your overlay renders
+        // them as clickable AI-anchored comments on the chart.
+        annotations: response.highlights,
+      }
+    },
+  })
+
+  return (
+    
+       {}}        // any handler enables the store
+      />
+      
+    
+  )
+}`}
+      
+

+ The useChartFocus hook is opinionated about what counts as focus — + hover, click, and selection by default; hover-end and click-end{" "} + clear it. For a sticky-focus UI where hover doesn't count, pass{" "} + {`{ types: ["click", "click-end"] }`} and only clicks update the AI's + reference point. +

+ +

The other direction: AI comments anchored back

+

+ The interrogation hook already returns annotations to the chart's standard{" "} + annotations prop. The new piece is what those annotations can carry — not + just a label, but a note. An annotation like: +

+
+{`{
+  type: "callout",
+  month: 5,
+  revenue: 2200,
+  label: "Promo-driven spike",
+  note: "Spring 2024 product launch + 15% sitewide discount. Not repeatable; treat as one-off in forecasts."
+}`}
+      
+

+ The chart renders the callout natively. A small overlay (~30 lines, copyable from this + page) finds annotations with a note field and renders a clickable marker + that reveals the note on demand. The rationale lives on the chart; the rationale + doesn't crowd the chart unless someone asks for it. +

+

+ This is exactly the pattern{" "} + Advanced Annotations demonstrates with + the human-authored comment threads — the same UI shape, but populated by an LLM + instead of typed by a teammate. The chart doesn't care where the comments came from. +

+ +

Where to use this

+
    +
  • + Operations dashboards. An on-call engineer hovers over an anomaly + spike, asks "what happened here?" — the AI consults a runbook + incident history + + deploy log and leaves an anchored note. Next time someone sees the spike, the note + is already there. +
  • +
  • + Financial models. An analyst clicks a forecast point that looks + surprising, asks "why does the model show this?" — the AI walks through which inputs + drove this value most, leaves a note explaining the dominant terms. +
  • +
  • + Scientific exploration. A researcher clicks an outlier observation, + asks "is this an artifact?" — the AI references the run log, the calibration + history, similar past observations, and leaves a note classifying it. +
  • +
  • + Customer support / sales review. A rep hovers over a usage dip for a + specific account, asks "what's going on with this customer?" — the AI consults the + CRM history and leaves an anchored explanation that the next rep also sees. +
  • +
+

+ The pattern across all four: the chart is the primary surface, the AI is a + teammate annotating it. Not a chat window that happens to talk about charts; a + chart that accumulates explanations. +

+ +

Failure modes worth thinking about

+
    +
  • + Stale notes. Yesterday's AI explanation may be wrong today. Treat + annotated notes as ephemeral by default — easy to dismiss, optionally + time-stamped. A note that hasn't been refreshed in 30 days probably shouldn't be + surfaced with full confidence. +
  • +
  • + Anchoring drift. If the dataset gets re-aggregated (weekly to + monthly), the annotation's coordinates may no longer match anything meaningful. Tie + notes to a stable identity (datum.id, a deterministic hash of the row), not pixel + coordinates — the chart re-positions them on data shape changes. +
  • +
  • + Authority confusion. Human comments and AI comments need visual + differentiation. The convention this post uses — turquoise for AI, default for + human — is one option; an author field on each annotation is the more + rigorous one. The audience needs to know which voice they're reading. +
  • +
+ + +
    +
  • + Interrogation — the{" "} + useChartInterrogation hook and its focus option. +
  • +
  • + Observation Hooks —{" "} + useChartObserver and useChartFocus, the source of the + focus signal. +
  • +
  • + Advanced Annotations — the + original comment-thread-on-a-data-point pattern this post extends to AI. +
  • +
  • + Multimodal response: chart as output channel{" "} + — the broader frame this fits into. Anchored conversation is one specific multimodal + pattern. +
  • +
+ + ) +} + +export default { + slug: "anchored-conversations", + title: "Anchored conversations: when the AI knows which point you're asking about", + subtitle: + "Two-way point-anchored AI conversation: the user clicks, the AI answers about that specific point, and the answer lives on the chart as a clickable note.", + author: "Elijah Meeks", + date: "2026-05-24", + tags: ["case-study"], + excerpt: + "Chat-with-chart works, but the user has to verbalize which point they care about and the AI has to verbalize where the answer applies — both steps lose the spatial information that's already on screen. Draft post on bidirectional point-anchored AI conversation, with useChartFocus + useChartInterrogation as the building blocks.", + draft: true, + component: Body, +} diff --git a/docs/src/blog/entries/charts-that-know-what-theyre-for.js b/docs/src/blog/entries/charts-that-know-what-theyre-for.js new file mode 100644 index 00000000..50cf8e67 --- /dev/null +++ b/docs/src/blog/entries/charts-that-know-what-theyre-for.js @@ -0,0 +1,793 @@ +import React, { useMemo, useState } from "react" +import { Link } from "react-router-dom" +import { + AreaChart, + BarChart, + BoxPlot, + DonutChart, + DotPlot, + Histogram, + LineChart, + PieChart, + Scatterplot, + StackedAreaChart, + StackedBarChart, + SwarmPlot, + ThemeProvider, + ViolinPlot, +} from "semiotic" +import { + executivePersona, + dataScientistPersona, + inferIntent, + suggestCharts, + suggestStretchCharts, +} from "semiotic/ai" + +// ─── Styling shared with the rest of the blog ─── +const chartFrame = { + background: "var(--surface-1)", + borderRadius: 8, + padding: 16, + border: "1px solid var(--surface-3)", + overflow: "hidden", + margin: "20px 0", +} + +const playgroundFrame = { + ...chartFrame, + padding: 20, +} + +const controlsRow = { + display: "flex", + flexWrap: "wrap", + gap: 12, + alignItems: "flex-end", + marginBottom: 16, +} + +const controlGroup = { + display: "flex", + flexDirection: "column", + gap: 4, + fontSize: 12, + minWidth: 160, +} + +const labelStyle = { + textTransform: "uppercase", + letterSpacing: "0.06em", + fontSize: 10, + color: "var(--text-secondary)", + fontWeight: 700, +} + +const selectStyle = { + padding: "6px 10px", + borderRadius: 6, + border: "1px solid var(--surface-3)", + background: "var(--background)", + color: "var(--text)", + fontSize: 13, +} + +const inputStyle = { + ...selectStyle, + width: "100%", +} + +const intentBadge = { + display: "inline-block", + padding: "2px 10px", + borderRadius: 999, + background: "var(--accent)", + color: "white", + fontSize: 11, + fontWeight: 700, + letterSpacing: "0.04em", + textTransform: "uppercase", +} + +const cardGrid = { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", + gap: 12, +} + +const suggestionCard = { + background: "var(--background)", + border: "1px solid var(--surface-3)", + borderRadius: 8, + padding: 12, + display: "flex", + flexDirection: "column", + gap: 8, +} + +const stretchCard = { + ...suggestionCard, + background: "linear-gradient(180deg, rgba(123,97,255,0.08), transparent)", + border: "1px solid rgba(123,97,255,0.35)", +} + +const cardHeader = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + fontSize: 12, + fontWeight: 700, +} + +const sectionLabel = { + ...labelStyle, + marginTop: 24, + marginBottom: 8, + display: "flex", + alignItems: "center", + gap: 8, +} + +const stretchLabel = { + ...sectionLabel, + color: "rgb(123,97,255)", +} + +// ─── Sample datasets the playground rotates through ─── +const SAMPLE_DATASETS = { + "Quarterly revenue by region": Array.from({ length: 24 }, (_, i) => { + const region = ["EU", "NA", "APAC"][i % 3] + const quarter = Math.floor(i / 3) + 1 + return { + quarter, + revenue: 800 + i * 60 + Math.sin(i / 2) * 90, + region, + } + }), + "Product sales": [ + { product: "Widget", units: 480 }, + { product: "Gadget", units: 620 }, + { product: "Sprocket", units: 290 }, + { product: "Whatsit", units: 740 }, + { product: "Doohickey", units: 410 }, + ], + "Survey ratings by cohort": Array.from({ length: 150 }, (_, i) => ({ + respondent: i + 1, + satisfaction: Math.max(1, Math.min(10, 6 + Math.sin(i / 7) * 2 + Math.random() * 3 - 1)), + cohort: ["Beta", "GA", "Enterprise"][i % 3], + })), +} + +// Map Suggestion.component → renderable React component. Limited to the +// HOCs this post's sample datasets can produce — keeps the bundle tight. +const COMPONENT_MAP = { + LineChart, + AreaChart, + StackedAreaChart, + Scatterplot, + BarChart, + StackedBarChart, + DotPlot, + PieChart, + DonutChart, + Histogram, + BoxPlot, + ViolinPlot, + SwarmPlot, +} + +const AUDIENCES = { + Default: undefined, + Executive: executivePersona, + "Data scientist": dataScientistPersona, +} + +function renderSuggestion(suggestion, width = 240, height = 140) { + const Component = COMPONENT_MAP[suggestion.component] + if (!Component) { + return ( +
+ {suggestion.component} — preview not embedded +
+ ) + } + return ( + + ) +} + +function Playground() { + const [datasetName, setDatasetName] = useState("Quarterly revenue by region") + const [audienceName, setAudienceName] = useState("Default") + const [query, setQuery] = useState("show me the trend") + + const data = SAMPLE_DATASETS[datasetName] + const audience = AUDIENCES[audienceName] + const inferred = useMemo(() => inferIntent(query), [query]) + const intent = inferred?.intent + + const top = useMemo( + () => + suggestCharts(data, { + intent, + audience, + maxResults: 3, + includeVariants: false, + }), + [data, intent, audience], + ) + + const stretches = useMemo( + () => + audience + ? suggestStretchCharts(data, { + audience, + intent, + maxResults: 3, + }) + : [], + [data, intent, audience], + ) + + return ( +
+
+
+ Dataset + +
+
+ Audience + +
+
+ Type a question + setQuery(e.target.value)} + placeholder='e.g. "how is the distribution" or "which is biggest"' + style={inputStyle} + /> +
+
+ +
+ {intent ? ( + <> + intent: {intent} + + detected by inferIntent from your question. + + + ) : ( + + Type a phrase like "trend over time", "which is biggest", "show the distribution", or + "is there a correlation" — inferIntent will classify it. + + )} +
+ +
+ Top picks + {audience ? ( + + (familiar to {audienceName.toLowerCase()}) + + ) : null} +
+
+ {top.length === 0 && ( +
+ No charts fit this dataset for that intent. Try a different question or audience. +
+ )} + {top.map((s) => ( +
+
+ {s.component} + + {s.score.toFixed(1)}/5 · fam {s.rubric.familiarity} + +
+
{renderSuggestion(s)}
+ {s.reasons.length > 0 && ( +
+ {s.reasons.slice(0, 2).join("; ")} +
+ )} + {s.caveats.length > 0 && ( +
+ ⚠ {s.caveats[0]} +
+ )} +
+ ))} +
+ + {stretches.length > 0 && ( + <> +
+ 🎓 Stretch your literacy + + (charts {audienceName.toLowerCase()} doesn't use often, but the data supports) + +
+
+ {stretches.map((s) => ( +
+
+ {s.suggestion.component} + + fam {s.familiarity}/5 + {s.replacing ? ` · vs ${s.replacing}` : ""} + +
+
{renderSuggestion(s.suggestion)}
+
+ {s.rationale} +
+
+ ))} +
+ + )} +
+ ) +} + +function Body() { + return ( + <> +

+ Chart libraries have historically been told how to render — props in, pixels out. + Picking which chart to render has been someone else's problem: a designer's, a BI + tool's, an LLM's, the user's. Semiotic 3.6.0 ships a different bet: every chart now carries + a small descriptor that declares what data shapes it serves,{" "} + which questions it answers well, and{" "} + how settings shift those answers. Pair the descriptor with a profile of + your data and you get a ranked list of charts, each with a runnable prop config and an + auditable reason. Pair it with a profile of your audience and the ranking + calibrates to who's 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. +
  • +
  • + 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. +

+ +

A playground for the impatient

+

+ Three knobs: pick a dataset, pick an audience, and type a natural-language question. Each + change re-ranks the suggestions live. The "stretch your literacy" row only appears when + you've selected an audience that has growth targets — it shows charts the audience is + unfamiliar with but the data actually supports. +

+ +

+ 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's{" "} + rubric.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" — labeled as opt-in, not pushed + as defaults. Under Data scientist, the same charts move up + the 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, returns 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 show`}
+      
+

+ Intents 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 onQuery{" "} + callback; 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 (
+  <>
+    
+    
+  
+)`}
+      
+ +

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 an 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 — + 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 — give them what they want, AND

+

+ The literacy-growth mechanic the audience layer enables.{" "} + suggestStretchCharts(data, { audience }) returns charts where: +

+
    +
  1. + The data actually supports it (the chart's fits() gate passes). +
  2. +
  3. The audience's effective familiarity is at or below 3.
  4. +
  5. + Either the audience has flagged it as an increase target, OR its score is within + reach of the top familiar pick. +
  6. +
+

+ Each stretch carries a replacing field (which familiar chart it could + substitute for) and a rationale string. Render them in their own labeled + surface, not inline with the default recommendations — 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 is{" "} + intentionally not the best familiar choice — surfacing it as "the recommendation" + would mislead. Two labeled surfaces, the reader chooses. +

+ +

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" — the executive view of an enterprise + dashboard, a scientific notebook environment, 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. +
  • +
+

+ Don't reach for it when: +

+
    +
  • + You already know exactly what chart you want. The suggestion engine is for open{" "} + questions; 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 — the minimal cases

+

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 

No fitting chart.

+ const Component = COMPONENT_MAP[top.component] + return +}`} +
+ +

Dashboard mode

+
+        {`function GeneratedDashboard({ data, audience }) {
+  const { panels, intentsMissing, stretchPanels } = suggestDashboard(data, { audience })
+  return (
+    <>
+      
+ {panels.map(({ intent, suggestion }) => { + const Component = COMPONENT_MAP[suggestion.component] + return ( + + + + ) + })} +
+ {stretchPanels.length > 0 && ( + + )} + {intentsMissing.length > 0 && ( +

Not covered: {intentsMissing.join(", ")}

+ )} + + ) +}`} +
+ +

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 (
+    <>
+      

Detected intent: {inferred?.intent ?? "(none)"}

+ + + ) +}`} +
+

+ 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.). +
  • +
  • + Strategy memos in docs/strategy/: chart-capability-layer.md{" "} + (design rationale), authoring-capabilities.md (writing your own descriptor), + and audience-profiles.md (the calibration layer). +
  • +
+ + ) +} + +export default { + slug: "charts-that-know-what-theyre-for", + title: "Charts that know what they're for", + subtitle: + "A heuristic-first chart recommendation engine with per-audience calibration, a literacy-growth surface, and ready-to-render props.", + author: "Elijah Meeks", + date: "2026-05-24", + tags: ["case-study"], + excerpt: + "Semiotic 3.6.0 ships a chart recommendation engine that's heuristic-first, LLM-optional, and audience-aware. Charts now carry descriptors that declare what data shapes they serve and which questions they answer; an AudienceProfile layers per-org familiarity and adoption targets on top; a separate 'stretch' surface grows literacy without forcing it.", + component: Body, +} diff --git a/docs/src/blog/entries/live-conversational-dashboard.js b/docs/src/blog/entries/live-conversational-dashboard.js new file mode 100644 index 00000000..41b4596b --- /dev/null +++ b/docs/src/blog/entries/live-conversational-dashboard.js @@ -0,0 +1,628 @@ +import React, { useEffect, useMemo, useRef, useState } from "react" +import { Link } from "react-router-dom" +import { LineChart } from "semiotic" +import { useChartInterrogation } from "semiotic/ai" + +// ─── Styling ────────────────────────────────────────────────────────────── +const chartFrame = { + background: "var(--surface-1)", + borderRadius: 8, + padding: 16, + border: "1px solid var(--surface-3)", + overflow: "hidden", + margin: "20px 0", +} + +const dashboardGrid = { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) 320px", + gap: 16, +} + +const chatPanel = { + background: "var(--surface-2)", + borderRadius: 8, + padding: 12, + display: "flex", + flexDirection: "column", + height: 360, + fontSize: 12, +} + +const transcriptBox = { + flex: 1, + overflowY: "auto", + paddingRight: 4, + marginBottom: 8, +} + +const watcherBubble = { + background: "rgba(251, 191, 36, 0.18)", + border: "1px solid rgba(251, 191, 36, 0.45)", + borderRadius: 8, + padding: "6px 10px", + marginBottom: 8, + fontSize: 11, + lineHeight: 1.45, +} + +const userBubble = { + background: "var(--accent)", + color: "white", + borderRadius: "10px 10px 2px 10px", + padding: "6px 10px", + marginBottom: 6, + fontSize: 11, + alignSelf: "flex-end", + maxWidth: "85%", + display: "inline-block", +} + +const aiBubble = { + background: "var(--surface-3)", + color: "var(--text)", + borderRadius: "10px 10px 10px 2px", + padding: "6px 10px", + marginBottom: 6, + fontSize: 11, + maxWidth: "85%", + display: "inline-block", + whiteSpace: "pre-wrap", +} + +const inputRow = { display: "flex", gap: 4 } +const inputStyle = { + flex: 1, + padding: "5px 8px", + borderRadius: 4, + border: "1px solid var(--surface-3)", + background: "var(--background)", + color: "var(--text)", + fontSize: 11, +} +const buttonStyle = { + padding: "5px 10px", + borderRadius: 4, + border: "none", + background: "var(--accent)", + color: "white", + fontSize: 11, + fontWeight: 600, + cursor: "pointer", +} + +const controlBar = { + display: "flex", + alignItems: "center", + gap: 12, + marginBottom: 12, + fontSize: 12, +} + +const statusDot = { + display: "inline-block", + width: 8, + height: 8, + borderRadius: 999, + background: "var(--accent)", + animation: "pulse-dot 1.4s ease-in-out infinite", + marginRight: 6, + verticalAlign: "middle", +} + +// ─── Streaming demo ─────────────────────────────────────────────────────── +// Synthetic latency stream. Most values land in 80-180 ms; ~5% are spikes, +// ~2% are dips. The z-score watcher catches both. +function generateNext(tick) { + const base = 130 + Math.sin(tick / 8) * 20 + (Math.random() - 0.5) * 30 + const roll = Math.random() + if (roll > 0.95) return base + 350 + Math.random() * 300 // spike + if (roll < 0.03) return Math.max(30, base - 80 - Math.random() * 40) // dip + return base +} + +function rollingStats(values) { + if (values.length < 2) return { mean: 0, std: 0 } + const mean = values.reduce((a, b) => a + b, 0) / values.length + const variance = + values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length + return { mean, std: Math.sqrt(variance) } +} + +// Canned follow-up responder. In production this calls an LLM with the +// recent transcript + the focused event + the current rolling stats. +async function cannedFollowup(query, context) { + await new Promise((r) => setTimeout(r, 250)) + const q = query.toLowerCase() + const focus = context.focus + if (q.includes("baseline") || q.includes("normal")) { + return { + answer: `Current rolling baseline: ~130ms ±30ms. Watcher flags anything beyond 2.5σ — that's roughly under 50ms or over 220ms.`, + } + } + if (q.includes("why") && focus) { + return { + answer: `Most ${focus.datum.value > 300 ? "spikes" : "dips"} of this magnitude correlate with one of: a slow downstream call, a GC pause on the app server, or transient network congestion. Without trace IDs I can't be more specific — recommend cross-referencing the app log at that timestamp.`, + } + } + if (q.includes("trend") || q.includes("worsen") || q.includes("getting")) { + return { + answer: `Looking at the last ~30 seconds, latency is ${Math.random() > 0.5 ? "stable" : "drifting up slightly"} — but a streaming window this short makes trend claims unreliable. Recommend a longer history before declaring a trend.`, + } + } + if (q.includes("how many") || q.includes("count")) { + return { + answer: `Since you started watching, I've flagged ${context.summary.rowCount > 0 ? "several" : "no"} anomalies. The transcript above is your audit trail.`, + } + } + return { + answer: `I can riff on anomalies the watcher already flagged, compare to baseline, or describe recent trend. Ask "why" about a specific event, "what's the baseline?", or "is it getting worse?"`, + } +} + +function LiveDashboardDemo() { + const [points, setPoints] = useState([]) + const [paused, setPaused] = useState(false) + const [input, setInput] = useState("") + const tickRef = useRef(0) + const recentRef = useRef([]) + const lastFlagRef = useRef(-Infinity) + + const { ask, announce, history, annotations, reset } = useChartInterrogation({ + data: points, + onQuery: cannedFollowup, + }) + + // Auto-scroll the transcript as new messages arrive + const transcriptRef = useRef(null) + useEffect(() => { + if (transcriptRef.current) { + transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight + } + }, [history]) + + // The streaming loop. Generates one point per tick, runs the rolling + // z-score detector, and fires announce() when something deviates. + useEffect(() => { + if (paused) return undefined + const id = setInterval(() => { + const tick = (tickRef.current += 1) + const value = generateNext(tick) + const next = { ts: tick, value } + + setPoints((prev) => { + const updated = [...prev, next] + // Keep at most 120 points visible — about 50 seconds at 400ms cadence + return updated.length > 120 ? updated.slice(-120) : updated + }) + + // Z-score detector on the trailing 30 points + const buf = recentRef.current + buf.push(value) + if (buf.length > 30) buf.shift() + if (buf.length >= 15) { + const { mean, std } = rollingStats(buf.slice(0, -1)) // exclude the new point itself + if (std > 0) { + const z = (value - mean) / std + // Debounce: don't flag again within 5 ticks of the last flag + if (Math.abs(z) > 2.4 && tick - lastFlagRef.current > 5) { + lastFlagRef.current = tick + const direction = z > 0 ? "spike" : "dip" + const text = + `${direction === "spike" ? "⚠" : "⚡"} ${direction} at t=${tick}: ` + + `${Math.round(value)}ms (${z > 0 ? "+" : ""}${z.toFixed(1)}σ vs ${Math.round(mean)}ms baseline)` + const note = + z > 2.4 + ? "Sharp upward deviation. Likely candidates: a slow downstream call, GC pause, or congested network. Worth investigating if it recurs in this window." + : "Downward deviation. Often spurious — caching effects, fewer concurrent requests, or under-counted samples. Less actionable than spikes." + announce({ + text, + annotations: [ + { + type: "callout", + ts: tick, + value, + label: `${direction === "spike" ? "↑" : "↓"} ${Math.round(value)}ms`, + note, + source: "ai-watcher", + dx: direction === "spike" ? 20 : -20, + dy: direction === "spike" ? -30 : 30, + }, + ], + }) + } + } + } + }, 400) + return () => clearInterval(id) + }, [paused, announce]) + + // Visible window only — points already in state. We compute the visible + // chart domain from the buffer so the chart doesn't try to render an + // x-axis from 0 to infinity. + const xExtent = useMemo(() => { + if (points.length === 0) return [0, 1] + return [points[0].ts, points[points.length - 1].ts] + }, [points]) + + const submit = () => { + const trimmed = input.trim() + if (!trimmed) return + setInput("") + void ask(trimmed) + } + + const handleReset = () => { + setPaused(true) + setPoints([]) + recentRef.current = [] + lastFlagRef.current = -Infinity + tickRef.current = 0 + reset() + } + + return ( +
+
+ + + {paused ? "Paused" : "Watching"} — stream + z-score detector live + + + + + {points.length} points · {history.filter((m) => m.role === "assistant").length}{" "} + announcements + +
+ +
+
+ +
+ +
+
+ {history.length === 0 && ( +
+ Watcher will announce anomalies here in real-time. You can also ask follow-ups + ("why?", "what's baseline?", "trend?"). +
+ )} + {history.map((m, i) => { + // Distinguish AI-watcher proactive announcements from + // user-question responses. Convention: watcher messages start + // with the ⚠ or ⚡ glyph emitted above. + const isWatcher = + m.role === "assistant" && (m.text.startsWith("⚠") || m.text.startsWith("⚡")) + if (isWatcher) return
{m.text}
+ if (m.role === "user") { + return ( +
+
{m.text}
+
+ ) + } + return ( +
+
{m.text}
+
+ ) + })} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + placeholder='Ask a follow-up…' + style={inputStyle} + /> + +
+
+
+ +
+ ) +} + +function Body() { + return ( + <> +

+ Static dashboards show you the past. Conversational dashboards (the + chat-with-a-chart pattern) make the past interrogable. Live conversational + dashboards add the missing piece: an AI watching the stream alongside you, + proactively narrating events as they happen and anchoring its narration back onto + the chart. The chart accumulates context the moment something interesting occurs — + no waiting for someone to ask the question, no losing the moment to scroll. +

+ +

Three pieces composed into one product

+

+ This pattern is buildable only because Semiotic ships the three primitives it needs + as separate, composable things: +

+
    +
  • + A streaming runtime. Push API, observation hooks, decay encoding — + the chart is designed for data that arrives over time. +
  • +
  • + An interrogation hook with proactive announcements. The new{" "} + announce() method appends AI-initiated messages to the transcript and + adds annotations to the chart without going through a user question. A watcher can + call it as freely as a user can call ask(). +
  • +
  • + Anchored annotations. Every announcement can carry a callout, a + threshold, or a band — visual provenance for the AI's claims, attached to the + coordinates the claim is about. +
  • +
+

+ Compose them and you get a dashboard where the AI's "I saw that" is structurally + identical to the human's "ask about that" — both write to the same transcript, both + leave traces on the same chart, both feed the same conversation. +

+ +

Try it

+

+ Synthetic request-latency stream — a value arrives every 400ms. A rolling z-score + detector watches the last 30 points; anything beyond ±2.4σ gets announced. Each + announcement carries a callout on the chart (with a note explaining the deviation + category) and an entry in the transcript. Ask a follow-up like "why?",{" "} + "what's baseline?", or "is it getting worse?" and you'll get the + AI's response in the same transcript. Pause to inspect; reset to start over. +

+ +

+ The demo uses canned responders for the LLM side. In production you'd wire{" "} + onQuery to a real model and the announcement note field + would be the model's actual narrative — generated when the watcher fires, cached on + the annotation, displayed on hover. +

+ +

The watcher pattern

+

+ The detector here is intentionally simple: a rolling-window z-score with a debounce. + That's the right starting point for most monitoring workflows because it has zero + configuration, runs in O(window) per tick, and catches both spikes and dips. + Stronger detectors layer on top: +

+
    +
  • + Median absolute deviation (MAD) instead of stddev for non-Gaussian + streams — heavy-tailed metrics (request latency, error counts) often have outliers + that pull stddev around. MAD is robust. +
  • +
  • + Multi-window comparison. Compare the trailing 30 seconds to the + trailing 5 minutes. Flag when they diverge. Catches drift the rolling-window + detector misses. +
  • +
  • + Domain-aware thresholds. Latency over 1000ms is interesting at any + time, even if the rolling mean was 950ms. Add absolute thresholds on top of the + statistical ones. +
  • +
  • + Capability-layer-driven detection. The same chart that's currently + showing latency could be rendered as a Histogram instead — and the histogram-based + watcher would flag distribution-shape changes (bimodal becoming unimodal, tail + fattening). Pair{" "} + suggestStreamCharts with + watcher logic specific to each chart family. +
  • +
+

+ Whatever the detector, the pattern is the same: when it fires, call{" "} + announce() with text and annotations. The interrogation hook handles + the rest. +

+ +

The conversational side

+

+ Half of the dashboard is autonomous (watcher → announce). The other half is + reactive: the user reads an announcement, has a follow-up question, and asks. That + question lands in the same transcript with full context — recent announcements, the + statistical summary of the visible window, the user's currently-focused point if + any. +

+

+ The asymmetry is the feature. The watcher narrates broadly ("⚠ spike at t=42, 3.1σ + above baseline"); the user drills in ("which downstream call?"). The LLM gets both + signals on every turn — it knows what the watcher already said and what the user + wants to know now. +

+ +

When to deploy this

+
    +
  • + Production monitoring with on-call rotation. The AI is essentially + writing real-time handoff notes. When the next oncall takes over, the transcript + plus the chart's anchored notes are a complete record of "what happened during + my shift." +
  • +
  • + Financial trading desks. A watcher monitors instrument moves; the + AI annotates breakouts and breakdowns the moment they happen. Traders ask + follow-ups without leaving the chart. +
  • +
  • + IoT / industrial telemetry. Sensor streams from a factory floor. + The watcher flags pressure drops, vibration anomalies, temperature drift. Each gets + a timestamped anchored note that becomes the maintenance log. +
  • +
  • + Live experiments / lab readings. Researcher running an experiment; + the watcher flags when readings deviate from expected. The AI's anchored notes + become a real-time lab notebook. +
  • +
  • + Live data exploration sessions. Analyst exploring a new dataset + with streaming updates (a query that produces results progressively). The AI + narrates what it sees as the data arrives. +
  • +
+ +

Production considerations

+

+ The demo cuts corners for clarity. Real deployment needs to handle: +

+
    +
  • + Use RealtimeLineChart. The demo uses plain LineChart + with state-managed buffer because it's easier to read. In production, swap in + Semiotic's RealtimeLineChart — it has an imperative push API that bypasses React + re-renders, supports decay encoding, and handles particles. 30+ Hz streams are + comfortable. +
  • +
  • + Debounce the LLM call. The demo's announce() happens + synchronously when the detector fires — the "note" is canned. In production, + calling the LLM inside the detector loop will blow your budget. The right pattern: + announce immediately with a placeholder note ("detected, analyzing…"), then call + the LLM asynchronously, then update the annotation's note when the response lands. +
  • +
  • + Rate-limit announcements. A cascading-failure incident can fire + the detector dozens of times in seconds. The demo debounces by 5 ticks; production + needs adaptive backoff (collapse repeat announcements into "10 spikes in 30s"). +
  • +
  • + Sliding-window annotation lifecycle. When the chart's data window + slides, annotations referencing data that's been evicted should either age out or + migrate to a separate "recent events" panel. The demo lets them slide off — fine + for monitoring, wrong for a forensic timeline. +
  • +
  • + Persist the conversation. The transcript is in-memory. If the + oncall handoff is the use case, write it to durable storage. Semiotic doesn't ship + that path; bring your own. +
  • +
+ +

Failure modes worth thinking about

+
    +
  • + The watcher cries wolf. A misconfigured detector floods the + transcript with non-events. Users learn to ignore announcements. The fix is upstream + — tighter detectors, multi-signal confirmation — not "make the AI better at + phrasing the false alarms." +
  • +
  • + The watcher misses real events. A z-score detector misses gradual + drift. The transcript looks calm while the underlying system is slowly burning + down. Pair it with longer-window detectors and absolute thresholds. +
  • +
  • + The AI hallucinates causes. The watcher detected the spike; the + LLM is guessing what caused it. Make the AI's note explicitly tentative ("likely + candidates: …") and surface links to actual evidence (logs, traces) when available. + Never let the AI claim certainty it doesn't have. +
  • +
  • + Operator desensitization. Anything blinking and announcing + constantly gets tuned out. The watcher should be quiet most of the time. Better to + flag fewer real events than many maybe-events. +
  • +
+ +

Why this is hard to build outside Semiotic

+

+ The pattern requires three things that other chart libraries don't put together: +

+
    +
  1. + A streaming chart runtime that handles incremental data without + re-mounting (Semiotic's push API + decay). +
  2. +
  3. + An interrogation surface that accepts proactive AI input, not + just user queries (the announce() method). +
  4. +
  5. + An annotation model where AI-generated annotations are + first-class (callouts, thresholds, bands all work the same whether the human or + the AI added them). +
  6. +
+

+ Other libraries can be made to do this with enough custom plumbing — but only + because they treat each of the three concerns as out-of-scope. With Semiotic, all + three are in-scope, individually testable, and composable. The streaming-first + runtime is the load-bearing piece; everything else assembles around it. +

+ + +
    +
  • + Interrogation — the{" "} + useChartInterrogation hook, with the announce() method + added in this release. +
  • +
  • + Anchored conversations — the + user-side counterpart: point-of-focus + annotation-as-response. +
  • +
  • + Multimodal response: chart as output channel{" "} + — the broader frame this fits into. +
  • +
  • + RealtimeLineChart — the production + chart for streaming. Drop-in replacement for the demo's static buffer. +
  • +
+ + ) +} + +export default { + slug: "live-conversational-dashboard", + title: "Live conversational dashboards", + subtitle: + "Streaming data + an AI watching alongside you + anchored annotations + a conversational follow-up surface. The class of product Semiotic's streaming-first runtime makes possible.", + author: "Elijah Meeks", + date: "2026-05-24", + tags: ["case-study", "realtime"], + excerpt: + "Static dashboards show the past; chat-with-chart makes the past interrogable. Live conversational dashboards add what's missing: an AI watching the stream as it arrives, narrating events anchored to the chart, with a chat surface for human follow-ups. Draft post on composing Semiotic's streaming runtime, interrogation hook, and annotation model into a single product.", + draft: true, + component: Body, +} diff --git a/docs/src/blog/entries/multimodal-response.js b/docs/src/blog/entries/multimodal-response.js new file mode 100644 index 00000000..26aa1cfd --- /dev/null +++ b/docs/src/blog/entries/multimodal-response.js @@ -0,0 +1,418 @@ +import React, { useMemo, useState } from "react" +import { Link } from "react-router-dom" +import { LineChart } from "semiotic" + +// ─── Shared blog styling ────────────────────────────────────────────────── +const chartFrame = { + background: "var(--surface-1)", + borderRadius: 8, + padding: 16, + border: "1px solid var(--surface-3)", + overflow: "hidden", + margin: "20px 0", +} + +const controlsRow = { + display: "flex", + flexWrap: "wrap", + gap: 8, + margin: "12px 0 16px", +} + +const buttonStyle = { + padding: "6px 12px", + borderRadius: 999, + border: "1px solid var(--surface-3)", + background: "var(--background)", + color: "var(--text)", + fontSize: 12, + cursor: "pointer", + fontWeight: 600, +} + +const buttonActiveStyle = { + ...buttonStyle, + background: "var(--accent)", + color: "white", + borderColor: "var(--accent)", +} + +const transcriptStyle = { + background: "var(--surface-2)", + borderRadius: 8, + padding: 12, + fontSize: 13, + lineHeight: 1.5, + minHeight: 80, + marginTop: 12, +} + +const userBubble = { + display: "inline-block", + background: "var(--accent)", + color: "white", + padding: "6px 12px", + borderRadius: "12px 12px 2px 12px", + marginBottom: 8, + maxWidth: "85%", +} + +const aiBubble = { + display: "inline-block", + background: "var(--surface-3)", + color: "var(--text)", + padding: "6px 12px", + borderRadius: "12px 12px 12px 2px", + marginBottom: 8, + maxWidth: "85%", + whiteSpace: "pre-wrap", +} + +const aiSide = { display: "flex", justifyContent: "flex-start" } +const userSide = { display: "flex", justifyContent: "flex-end" } + +// ─── The multimodal-response demo ───────────────────────────────────────── +// Synthetic dataset — 12 months of revenue + visits, with two visible +// anomalies (a late-spring spike, an autumn dip) and an obvious trend. +const SALES_DATA = [ + { month: 1, revenue: 1100, visits: 8200, label: "Jan" }, + { month: 2, revenue: 1180, visits: 8700, label: "Feb" }, + { month: 3, revenue: 1320, visits: 9500, label: "Mar" }, + { month: 4, revenue: 1450, visits: 10100, label: "Apr" }, + { month: 5, revenue: 2200, visits: 14000, label: "May" }, // promo spike + { month: 6, revenue: 1610, visits: 11200, label: "Jun" }, + { month: 7, revenue: 1720, visits: 11800, label: "Jul" }, + { month: 8, revenue: 1830, visits: 12400, label: "Aug" }, + { month: 9, revenue: 1950, visits: 13100, label: "Sep" }, + { month: 10, revenue: 1380, visits: 9600, label: "Oct" }, // outage dip + { month: 11, revenue: 2080, visits: 13600, label: "Nov" }, + { month: 12, revenue: 2240, visits: 14400, label: "Dec" }, +] + +// Five pre-baked questions, each with the text answer AND the chart +// annotations the response renders. A real LLM-backed version would +// generate both; this demo is a stand-in to show the round trip. +const CANNED_RESPONSES = { + "When did revenue peak?": { + text: "Revenue peaked at $2,240 in December — the year's high point.", + annotations: [ + { type: "callout", month: 12, revenue: 2240, label: "Peak: $2,240", dx: -40, dy: -30 }, + ], + }, + "Were there any unusual months?": { + text: + "Two stand out: May ($2,200) was a promotion-driven spike well above the underlying trend, and October ($1,380) is the inverse — a sharp dip below where the trend was tracking. Both deserve a closer look.", + annotations: [ + { type: "callout", month: 5, revenue: 2200, label: "May spike", dx: 30, dy: -30 }, + { type: "callout", month: 10, revenue: 1380, label: "Oct dip", dx: -30, dy: 30 }, + ], + }, + "What's the overall trend?": { + text: + "Revenue is on a steady upward trend across the year, climbing from $1,100 in January to $2,240 in December — roughly doubling. Removing the May spike and October dip, the trend line is almost monotonic.", + annotations: [ + { type: "y-threshold", value: 1670, label: "Year average", color: "var(--accent)" }, + { type: "trend", lineBy: "all", color: "var(--semiotic-info)", label: "Trend" }, + ], + }, + "Which months were below average?": { + text: + "Six months sat below the $1,670 yearly average: January through April, June (just barely), and October. The first half of the year was the slower stretch.", + annotations: [ + { type: "y-threshold", value: 1670, label: "Average ($1,670)", color: "var(--text-secondary)" }, + { type: "band", y0: 0, y1: 1670, color: "rgba(94,234,212,0.06)" }, + ], + }, + "Compare May and December.": { + text: + "December ($2,240) edged out May ($2,200) by just $40 — but they're qualitatively different. May was a one-month promotion spike; December is the natural endpoint of a sustained climb. The same revenue, two different stories.", + annotations: [ + { type: "callout", month: 5, revenue: 2200, label: "May $2,200 (promo)", dx: 30, dy: -30 }, + { type: "callout", month: 12, revenue: 2240, label: "Dec $2,240 (trend)", dx: -50, dy: -30 }, + ], + }, +} + +function MultimodalDemo() { + const [askedQuestions, setAskedQuestions] = useState([]) + + const annotations = useMemo(() => { + return askedQuestions.flatMap((q) => CANNED_RESPONSES[q]?.annotations ?? []) + }, [askedQuestions]) + + const ask = (q) => { + setAskedQuestions((prev) => (prev.includes(q) ? prev : [...prev, q])) + } + + const reset = () => setAskedQuestions([]) + + return ( +
+ +
+ {Object.keys(CANNED_RESPONSES).map((q) => ( + + ))} + {askedQuestions.length > 0 && ( + + )} +
+
+ {askedQuestions.length === 0 && ( +
+ Click any question above. The text answer appears here; the visual answer appears on the + chart simultaneously. Stack multiple questions to see the annotations compose. +
+ )} + {askedQuestions.map((q, i) => ( +
+
+
{q}
+
+
+
{CANNED_RESPONSES[q].text}
+
+
+ ))} +
+
+ ) +} + +function Body() { + return ( + <> +

+ Modern LLMs are interfaces, not just text generators. When an assistant answers a question + about data, the answer can — and increasingly should — include visual artifacts: + highlights on the chart the user is looking at, regions of interest, threshold lines, + sub-selections, even a different chart entirely. We've been optimizing chat interfaces for + text output for two years. Charts give us a parallel output channel that's underused. +

+ +

Text is half the answer

+

+ The dominant LLM response pattern is a wall of prose. Even when the question is{" "} + "where's the peak in this chart?" the answer comes back as a paragraph: "The peak + appears to be around month 12 at approximately $2,240, which represents a notable + increase from..." — and the reader's eye has to leave the chart, parse the paragraph, find + the relevant month, look back at the chart, and locate the point. +

+

+ Every step of that loop is friction. The peak is in the chart. The model has + access to the chart's data. It can answer "where's the peak?" by drawing a circle around + the peak, with the prose as supporting detail. +

+ +

Demo: ask, see the chart respond

+

+ This is a canned version of the round trip. Each question button below pretends to ask a + small local LLM; the model's response is a { text, annotations }{" "} + object. The text goes into the transcript; the annotations land on the chart. You can + stack questions to see how multiple annotations compose. +

+ +

+ Click Were there any unusual months? first — that's the canonical version of + the example. The text names May and October as outliers; the chart simultaneously gets + callouts on those two points. Reading the text confirms what the chart already showed. + Reading the chart confirms what the text says. The two channels reinforce instead of + duplicating. +

+ +

Why this works, and why it doesn't break the chat metaphor

+

+ The chat surface stays familiar — there's a question and an answer in a transcript. What's + new is that the answer has two faces: +

+
    +
  • + Text in the transcript, for the parts that need words: nuance, + comparison, context, the "why" behind a value. +
  • +
  • + Annotations on the chart, for the parts that need pixels:{" "} + where the peak is, which months are below average, which two{" "} + observations the question is about. +
  • +
+

+ The split is not arbitrary. Some claims compress better as text ("revenue doubled"); + others compress better as space ("here's the threshold and here are the six months below + it"). When the model gets to choose, the answer fits the question's natural shape. +

+ +

The contract

+

+ Concretely, this is what an LLM-backed answer looks like with a chart library that can + render annotations: +

+
+{`async function onQuery(question, context) {
+  const response = await callYourLLM({
+    question,
+    chartSummary: context.summary,   // min/max/mean/median per field
+    chartData:    context.data,      // raw rows
+    intent:       inferIntent(question)?.intent,
+  })
+  return {
+    answer: response.text,
+    annotations: response.highlights,  // [{type: "callout", month: 5, revenue: 2200, label: "..."}, ...]
+  }
+}`}
+      
+

+ The LLM is asked for two things and returns two things. The text is rendered in the chat + transcript like any other LLM response; the annotations are passed through to the + chart's annotations prop. No extra plumbing — both already exist as + first-class chart concepts (callouts, thresholds, bands, trend lines, region highlights). +

+ +

A small annotation vocabulary the model can use

+

+ The chart library defines the vocabulary; the LLM picks from it. A useful starting set: +

+
    +
  • + callout — point a label at a specific observation. Use for{" "} + "this is the peak", "this is the outlier". +
  • +
  • + y-threshold / x-threshold — a horizontal or vertical + reference line. Use for "the average is here", "before this date". +
  • +
  • + band — a shaded region between two values. Use for "below target",{" "} + "within tolerance". +
  • +
  • + trend / envelope — a statistical overlay. Use for{" "} + "if we remove these outliers, the trend is...". +
  • +
  • + enclose / rect-enclose — wrap a set of observations in a hull + or rectangle. Use for "these three points form a cluster". +
  • +
+

+ Each is JSON-serializable. The LLM doesn't draw pixels — it emits structured intent and + the chart library handles the geometry. That's the right division of labor: language + models are good at saying which observations matter and why; chart + runtimes are good at converting that into pixels. +

+ +

Beyond annotations — the broader pattern

+

+ Annotations are the entry point. Once you accept that LLM responses can have a visual + face, the pattern extends: +

+
    +
  • + Selection responses. "Show me only the Q3 data" — the model returns a + filter the chart applies. Same brushing surface used by humans. +
  • +
  • + Chart-type swaps. "This isn't the right chart for that question" — the + model returns a new { component, props } spec the runtime mounts + in place of the current chart. The Semiotic capability layer can power this: the model + consults suggestCharts and picks the best alternative. +
  • +
  • + Linked follow-ups. "What about by region?" — the model returns a + companion chart that gets rendered alongside the current one, with its hover state + linked to the first. +
  • +
  • + Audience-calibrated responses. The same question to the same data + could return a BoxPlot for a data-science audience and a BarChart for an executive — the + model reads the audience profile and adjusts. (See{" "} + Chart Suggestions for how that calibration + works.) +
  • +
+

+ All of these are extensions of the same idea: the chart library is an output channel. + LLMs that ignore it are leaving the most expressive part of the surface dark. +

+ +

What to watch out for

+

+ Multimodal output isn't free of failure modes. Three to watch: +

+
    +
  • + Hallucinated annotations. A model that's wrong about the peak's + location is now visibly wrong, with a callout pointing at the wrong dot. The + fix is upstream: give the model the data summary and statistical context, not just the + chart props, so its claims are grounded. +
  • +
  • + Annotation clutter. A model that surfaces an annotation for every + question accumulates noise. Either give the model a reset signal or accept that the + chart needs a "clear annotations" affordance in the chat UI. +
  • +
  • + Mode confusion. Users will eventually ask follow-up questions about + the annotations themselves ("why did you highlight October?"). The chat history needs + to include the annotations alongside the text so the next turn has full context. +
  • +
+ + +
    +
  • + Interrogation — the{" "} + useChartInterrogation hook that ships this pattern as a first-class + surface. The annotation-return contract is exactly what powers the demo above. +
  • +
  • + Annotations — the chart-library side of the + vocabulary: every annotation type the LLM can emit. +
  • +
  • + Chart Suggestions — what powers the + chart-type-swap response mode mentioned above. +
  • +
+ + ) +} + +export default { + slug: "multimodal-response", + title: "Multimodal response: chart as output channel", + subtitle: + "Text is half the answer. The other half — callouts, thresholds, bands, selections — lives on the chart, and LLMs already know how to ask for it.", + author: "Elijah Meeks", + date: "2026-05-24", + tags: ["case-study"], + excerpt: + "Modern LLM assistants treat text as the only output channel. When the question is about a chart, charts give us a parallel surface — callouts, threshold lines, bands, selections — that's both more honest and easier to read. Drafted exploration of what multimodal response means in practice.", + draft: true, + component: Body, +} diff --git a/docs/src/blog/entries/release-3-5-4.js b/docs/src/blog/entries/release-3-5-4.js index c7dea717..7592d23f 100644 --- a/docs/src/blog/entries/release-3-5-4.js +++ b/docs/src/blog/entries/release-3-5-4.js @@ -5,14 +5,12 @@ function Body() { return ( <>

- 3.5.4 lands a real envelope encoding on{" "} - LineChart and{" "} - AreaChart, sharpens the axis surface - (edge-anchored ticks, CSS-variable font sizes, per-axis class names), and gives every - HOC a sibling to emptyContent with the new loadingContent{" "} - slot. Under the hood, boundsAccessor and band now share a - single ribbon primitive — one scene builder, one y-extent pass, one style cascade. - Full release notes are on{" "} + 3.5.4 lands a real envelope encoding on LineChart and{" "} + AreaChart, sharpens the axis surface (edge-anchored + ticks, CSS-variable font sizes, per-axis class names), and gives every HOC a sibling to{" "} + emptyContent with the new loadingContent slot. Under the hood,{" "} + boundsAccessor and band now share a single ribbon primitive — one + scene builder, one y-extent pass, one style cascade. Full release notes are on{" "} The new band prop on LineChart and AreaChart draws an asymmetric min/max envelope under the line/area, driven by independent y0Accessor and{" "} - y1Accessor. That's distinct from the existing{" "} - boundsAccessor (which is symmetric ±offset) and from{" "} - AreaChart.y0Accessor (which replaces the area baseline). Pass a single{" "} - BandConfig for one envelope or an array for percentile fans — p25/p75 - stacked on top of p10/p90 is the canonical shape. + y1Accessor. That's distinct from the existing boundsAccessor{" "} + (which is symmetric ±offset) and from AreaChart.y0Accessor (which replaces the + area baseline). Pass a single BandConfig for one envelope or an array for + percentile fans — p25/p75 stacked on top of p10/p90 is the canonical shape.

- Per-series by default: one ribbon per lineBy / colorBy{" "} - group, colored from the parent line at 0.2 fillOpacity. Pass{" "} - perSeries: false for an aggregate min/max envelope across all series. - Bands are non-interactive by default (hovers pass through to the line on top); set{" "} - interactive: true if the band should participate in hit testing. Band - y0/y1 values feed yExtent auto-derivation so a tall envelope can never - clip; explicit yExtent still wins. Live demo at{" "} + Per-series by default: one ribbon per lineBy / colorBy group, + colored from the parent line at 0.2 fillOpacity. Pass{" "} + perSeries: false for an aggregate min/max envelope across all series. Bands are + non-interactive by default (hovers pass through to the line on top); set{" "} + interactive: true if the band should participate in hit testing. Band y0/y1 + values feed yExtent auto-derivation so a tall envelope can never clip; explicit{" "} + yExtent still wins. Live demo at{" "} /charts/line-chart#band.

Tooltip enrichment covers every interaction surface: the hovered datum carries{" "} - band: {`{ y0, y1 }`} (first band) and bands: [...] (all - bands) on the pointer hover path, each allSeries[i].datum in multi-mode, - and the keyboard-navigation datum. The default tooltip auto-surfaces band rows when{" "} - band is configured without a custom tooltip — string accessors become - labels; function accessors fall back to low / high. + band: {`{ y0, y1 }`} (first band) and bands: [...] (all bands) on + the pointer hover path, each allSeries[i].datum in multi-mode, and the + keyboard-navigation datum. The default tooltip auto-surfaces band rows when{" "} + band is configured without a custom tooltip — string accessors become labels; + function accessors fall back to low / high.

Axis surface: edge anchors, CSS vars, per-axis targeting

@@ -58,27 +55,26 @@ function Body() { tickAnchor: "edges" on frameProps.axes[i] {" "} - — flips the leftmost tick's text-anchor to start and - the rightmost to end on horizontal axes (and{" "} - dominant-baseline to hanging / auto on - vertical axes) so edge labels can't overflow the plot. Pairs naturally with{" "} - axisExtent: "exact": exact pins the domain to the literal data - min/max; edges keeps the labels readable at those bounds. Edge detection is - pixel-based, so inverted y scales and reversed-x streaming charts anchor + — flips the leftmost tick's text-anchor to start and the + rightmost to end on horizontal axes (and dominant-baseline to{" "} + hanging / auto on vertical axes) so edge labels can't + overflow the plot. Pairs naturally with axisExtent: "exact": exact pins the + domain to the literal data min/max; edges keeps the labels readable at those bounds. Edge + detection is pixel-based, so inverted y scales and reversed-x streaming charts anchor correctly.
  • - --semiotic-tick-font-size and{" "} - --semiotic-axis-label-font-size CSS variables + --semiotic-tick-font-size and --semiotic-axis-label-font-size{" "} + CSS variables {" "} — emitted from the canonical theme typography fields (tickSize,{" "} - labelSize) alongside the existing tick/title font-family/size - variables. Both themeToCSS and ThemeProvider write them;{" "} - themeToTokens exports them as DTCG dimension tokens. SVG - axes consume the vars via inline style, so an override on any - ancestor ({`
    `}) - flows down without consumers needing !important. + labelSize) alongside the existing tick/title font-family/size variables. Both{" "} + themeToCSS and ThemeProvider write them;{" "} + themeToTokens exports them as DTCG dimension tokens. SVG axes + consume the vars via inline style, so an override on any ancestor ( + {`
    `}) flows down without + consumers needing !important.
  • @@ -88,39 +84,36 @@ function Body() { {``}{" "} inside .stream-axes. Style one axis at a time from external CSS:{" "} {`[data-orient='left'] text { font-size: 14px }`}. Tick text carries{" "} - semiotic-axis-tick, axis labels{" "} - semiotic-axis-label, and chart titles semiotic-chart-title{" "} - for class-based targeting. + semiotic-axis-tick, axis labels semiotic-axis-label, and chart + titles semiotic-chart-title for class-based targeting.
  • loadingContent on every HOC

    Sibling to emptyContent. When loading is true and{" "} - loadingContent is set, it renders in place of the default shimmer-bar - skeleton (wrapped in the same sized container so the chart slot stays reserved). - Pass loadingContent={`{false}`} to suppress the loading UI entirely — - the early-return becomes null and a consumer's outer loading state - takes over. Threaded through useChartSetup,{" "} - useNetworkChartSetup, and useCustomChartSetup; all 47 HOCs - accept it via BaseChartProps. + loadingContent is set, it renders in place of the default shimmer-bar skeleton + (wrapped in the same sized container so the chart slot stays reserved). Pass{" "} + loadingContent={`{false}`} to suppress the loading UI entirely — the + early-return becomes null and a consumer's outer loading state takes over. + Threaded through useChartSetup, useNetworkChartSetup, and{" "} + useCustomChartSetup; all 47 HOCs accept it via BaseChartProps.

    One ribbon primitive for bounds and band

    - Both public envelope APIs (boundsAccessor and band) now - normalize to a single resolvedRibbons: ResolvedRibbon[] array at the - PipelineStore layer, then flow through xySceneBuilders/ribbonScene.ts — - one scene builder, one y-extent expansion pass, one style cascade. The dedicated{" "} - boundsScene.ts and bandScene.ts modules are gone. Public - prop surfaces stay distinct (asymmetric pairs read better as band than - as a boundsAccessor union return type), but the implementation is no - longer duplicated. + Both public envelope APIs (boundsAccessor and band) now normalize + to a single resolvedRibbons: ResolvedRibbon[] array at the PipelineStore layer, + then flow through xySceneBuilders/ribbonScene.ts — one scene builder, one + y-extent expansion pass, one style cascade. The dedicated boundsScene.ts and{" "} + bandScene.ts modules are gone. Public prop surfaces stay distinct (asymmetric + pairs read better as band than as a boundsAccessor union return + type), but the implementation is no longer duplicated.

    - Two correctness wins fell out of the unification: bounds ribbons now skip datums - with null/NaN y (the coerced +null === 0 previously - rendered a ribbon around the implicit-zero "value" of a missing row), and a{" "} + Two correctness wins fell out of the unification: bounds ribbons now skip datums with + null/NaN y (the coerced +null === 0 previously rendered a ribbon + around the implicit-zero "value" of a missing row), and a{" "} kind: "bounds" | "band" discriminator on each ribbon restricts{" "} datum.band / datum.bands tooltip enrichment to band-sourced envelopes — bounds stays decorative-only, matching its prior contract. @@ -129,10 +122,10 @@ function Body() {

    Upgrade notes

    This release is additive. Consumers already using boundsAccessor get the - null/NaN-row fix for free; anything that relied on the implicit-zero ribbon behavior - should switch to filtering at the data layer. The website build now injects the Atom - feed {``} via the prerender step instead of source - HTML, which closes a parcel resolution failure on nested prerendered routes. + null/NaN-row fix for free; anything that relied on the implicit-zero ribbon behavior should + switch to filtering at the data layer. The website build now injects the Atom feed{" "} + {``} via the prerender step instead of source HTML, which + closes a parcel resolution failure on nested prerendered routes.

    ) diff --git a/docs/src/components/navData.js b/docs/src/components/navData.js index d24f377d..5256c03c 100644 --- a/docs/src/components/navData.js +++ b/docs/src/components/navData.js @@ -115,14 +115,22 @@ const navData = [ { title: "Chart Container", path: "/features/chart-container" }, { title: "Chart States", path: "/features/chart-states" }, { title: "Chart Modes", path: "/features/chart-modes" }, - { title: "AI Observation Hooks", path: "/features/observation-hooks" }, - { title: "Serialization", path: "/features/serialization" }, - { title: "Vega-Lite Translator", path: "/features/vega-lite" }, { title: "Streaming System Model", path: "/features/streaming-system-model" }, { title: "Performance", path: "/features/performance" }, { title: "Push API", path: "/features/push-api" }, - { title: "Custom Charts", path: "/features/custom-charts" }, - { title: "Capability Matrix", path: "/features/capabilities" } + { title: "Custom Charts", path: "/features/custom-charts" } + ] + }, + { + title: "Intelligence", + path: "/intelligence", + children: [ + { title: "Observation Hooks", path: "/intelligence/observation-hooks" }, + { title: "Capability Matrix", path: "/intelligence/capabilities" }, + { title: "Chart Suggestions", path: "/intelligence/suggestions" }, + { title: "Interrogation", path: "/intelligence/interrogation" }, + { title: "Serialization", path: "/intelligence/serialization" }, + { title: "Vega-Lite Translator", path: "/intelligence/vega-lite" } ] }, { diff --git a/docs/src/pages/features/CapabilitiesPage.js b/docs/src/pages/features/CapabilitiesPage.js index f087e539..8fea9af5 100644 --- a/docs/src/pages/features/CapabilitiesPage.js +++ b/docs/src/pages/features/CapabilitiesPage.js @@ -123,11 +123,11 @@ export default function CapabilitiesPage() {

    Every Semiotic chart declares a fixed set of capabilities — does it diff --git a/docs/src/pages/features/InterrogationPage.js b/docs/src/pages/features/InterrogationPage.js new file mode 100644 index 00000000..5a27d232 --- /dev/null +++ b/docs/src/pages/features/InterrogationPage.js @@ -0,0 +1,260 @@ +import React, { useState } from "react" +import { LineChart, useChartInterrogation } from "semiotic/ai" +import PageLayout from "../../components/PageLayout" +import CodeBlock from "../../components/CodeBlock" + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] +const salesData = [ + ...[1200, 2100, 1800, 3200, 2800, 4500].map((revenue, i) => ({ + month: i + 1, + monthLabel: MONTHS[i], + revenue, + category: "Software", + })), + ...[800, 1200, 1500, 1100, 1900, 2200].map((revenue, i) => ({ + month: i + 1, + monthLabel: MONTHS[i], + revenue, + category: "Hardware", + })), +] +const monthFormat = (m) => MONTHS[m - 1] ?? "" + +// Stand-in for a real LLM call. In production this would POST to your AI endpoint +// with the user's question and `context.summary`. The shape of the return value is +// the contract: `{ answer, annotations }`. +async function simulatedQuery(query, context) { + await new Promise((r) => setTimeout(r, 500)) + const q = query.toLowerCase() + const rev = context.summary.fields.revenue + if (q.includes("peak") || q.includes("highest")) { + return { + answer: `The peak revenue was $${rev?.max?.toLocaleString()} in June, driven by Software.`, + annotations: [{ type: "callout", month: 6, revenue: 4500, label: "Peak" }], + } + } + if (q.includes("software")) { + return { + answer: "Software more than tripled from Jan to Jun — a strong upward trend.", + annotations: [{ type: "trend", lineBy: "Software", label: "Software trend" }], + } + } + if (q.includes("hardware")) { + return { + answer: "Hardware grew steadily, peaking at $2,200 in June.", + annotations: [{ type: "callout", month: 6, revenue: 2200, label: "Hardware peak" }], + } + } + return { + answer: `Across ${context.summary.rowCount} rows, mean revenue is $${rev?.mean?.toFixed(0)}. Try asking about the peak, software, or hardware.`, + annotations: [], + } +} + +function ChatPanel({ history, loading, onAsk, placeholder }) { + const [input, setInput] = useState("") + const submit = (e) => { + e.preventDefault() + onAsk(input) + setInput("") + } + return ( +

    +
    + {history.length === 0 && ( +
    + Ask about trends, outliers, or specific data points. +
    + )} + {history.map((m, i) => ( +
    {m.text}
    + ))} + {loading &&
    Analyzing…
    } +
    +
    + setInput(e.target.value)} + placeholder={placeholder} + disabled={loading} + style={{ + flex: 1, + padding: "8px 12px", + borderRadius: 16, + border: "1px solid var(--surface-3)", + background: "var(--background)", + color: "var(--text)", + }} + /> + +
    +
    + ) +} + +function InterrogationDemo() { + const { ask, history, annotations, loading } = useChartInterrogation({ + data: salesData, + onQuery: simulatedQuery, + componentName: "LineChart", + props: { xAccessor: "month", yAccessor: "revenue", lineBy: "category" }, + }) + return ( +
    + + +
    + ) +} + +export default function InterrogationPage() { + return ( + +

    + Semiotic ships a headless hook, useChartInterrogation, that lets users + ask natural-language questions about a chart. It pairs an LLM-friendly{" "} + statistical summary of your data with a contract for{" "} + visual highlighting: your AI returns annotations, the chart renders them. +

    + +

    + The hook owns no UI. You bring your own chat surface — input box, transcript, panel, + whatever fits your product. The demo below is ~70 lines of plain React for context. +

    + +

    Interactive Demo

    +

    + The demo uses a canned onQuery in place of a real LLM. Try{" "} + "where is the peak?", "tell me about software", or{" "} + "hardware growth". +

    + +
    + +
    + +

    How it works

    +
      +
    1. Summarize: useChartInterrogation runs summarizeData on your data — min, max, mean, median, top categorical values, date ranges.
    2. +
    3. Ask: Your onQuery receives the question plus the summary and any props you passed. Call your LLM, return {`{ answer, annotations }`}.
    4. +
    5. Render: The hook merges your initial annotations with the AI's response and exposes the combined array — wire it to the chart's annotations prop.
    6. +
    + +

    Implementation

    + +{`import { LineChart, useChartInterrogation } from "semiotic/ai" + +function InterrogatableChart({ data }) { + const { ask, history, annotations, loading } = useChartInterrogation({ + data, + componentName: "LineChart", + props: { xAccessor: "month", yAccessor: "revenue" }, + onQuery: async (query, context) => { + const res = await fetch("/api/chat", { + method: "POST", + body: JSON.stringify({ query, summary: context.summary }), + }).then((r) => r.json()) + return { answer: res.text, annotations: res.highlights } + }, + }) + + return ( + <> + + + + ) +}`} + + +

    The statistical summary

    +

    + context.summary is the payload to send to an LLM. It's compact, typed, and + avoids shipping raw rows: +

    + +{`{ + "rowCount": 12, + "fields": { + "revenue": { + "type": "numeric", + "min": 800, + "max": 4500, + "mean": 2025, + "median": 1850 + }, + "category": { + "type": "categorical", + "distinctCount": 2, + "topValues": [ + { "value": "Software", "count": 6 }, + { "value": "Hardware", "count": 6 } + ] + } + } +}`} + + +

    + Use summarizeData directly if you want the summary without the hook — + for server-side prompting, batch jobs, or the interrogateChart MCP tool. +

    +
    + ) +} diff --git a/docs/src/pages/features/ObservationHooksPage.js b/docs/src/pages/features/ObservationHooksPage.js index 25ed8a89..d9eee449 100644 --- a/docs/src/pages/features/ObservationHooksPage.js +++ b/docs/src/pages/features/ObservationHooksPage.js @@ -219,13 +219,13 @@ function LinkedObserverDemo() { export default function ObservationHooksPage() { return (

    Every Semiotic chart accepts an onObservation callback that diff --git a/docs/src/pages/features/PushApiPage.js b/docs/src/pages/features/PushApiPage.js index 4821d78d..636bd027 100644 --- a/docs/src/pages/features/PushApiPage.js +++ b/docs/src/pages/features/PushApiPage.js @@ -250,7 +250,7 @@ export default function PushApiPage() { { label: "Push API", path: "/features/push-api" }, ]} prevPage={{ title: "Performance", path: "/features/performance" }} - nextPage={{ title: "Styling", path: "/theming/styling" }} + nextPage={{ title: "Observation Hooks", path: "/intelligence/observation-hooks" }} >

    The push API lets you imperatively add, remove, and update data on a chart diff --git a/docs/src/pages/features/SerializationPage.js b/docs/src/pages/features/SerializationPage.js index a9c88844..7a7092d2 100644 --- a/docs/src/pages/features/SerializationPage.js +++ b/docs/src/pages/features/SerializationPage.js @@ -308,11 +308,11 @@ export default function SerializationPage() {

    Serialize any chart's configuration to JSON, encode it as a URL for diff --git a/docs/src/pages/features/SuggestionsPage.js b/docs/src/pages/features/SuggestionsPage.js new file mode 100644 index 00000000..b5ead0d0 --- /dev/null +++ b/docs/src/pages/features/SuggestionsPage.js @@ -0,0 +1,348 @@ +import React, { useState } from "react" +import { + useChartSuggestions, + LineChart, + AreaChart, + StackedAreaChart, + Scatterplot, + ConnectedScatterplot, + BubbleChart, + QuadrantChart, + MultiAxisLineChart, + MinimapChart, + DifferenceChart, + CandlestickChart, + Heatmap, + BarChart, + GroupedBarChart, + StackedBarChart, + DotPlot, + Histogram, + BoxPlot, + SwarmPlot, + ViolinPlot, + RidgelinePlot, + PieChart, + DonutChart, + FunnelChart, + GaugeChart, + LikertChart, + SwimlaneChart, +} from "semiotic/ai" +import PageLayout from "../../components/PageLayout" +import CodeBlock from "../../components/CodeBlock" + +// Comprehensive map of HOC chart names → React components. Realtime, +// network, and geo families are intentionally omitted — the SuggestionsPage +// demo datasets are all row-shaped tabular data that won't trigger those. +// If the engine recommends a chart not listed here, the demo falls back to +// the next renderable suggestion (with a note that the top pick wasn't +// available in this surface). +const COMPONENT_MAP = { + LineChart, + AreaChart, + StackedAreaChart, + Scatterplot, + ConnectedScatterplot, + BubbleChart, + QuadrantChart, + MultiAxisLineChart, + MinimapChart, + DifferenceChart, + CandlestickChart, + Heatmap, + BarChart, + GroupedBarChart, + StackedBarChart, + DotPlot, + Histogram, + BoxPlot, + SwarmPlot, + ViolinPlot, + RidgelinePlot, + PieChart, + DonutChart, + FunnelChart, + GaugeChart, + LikertChart, + SwimlaneChart, +} + +const DATASETS = { + temporal: { + label: "Temporal multi-series", + description: "Two regions, six months of revenue. Time x-axis, categorical series.", + data: [ + ...[1200, 1400, 1100, 1700, 1900, 2200].map((revenue, i) => ({ month: i + 1, revenue, region: "EU" })), + ...[900, 1100, 1500, 1300, 1700, 2000].map((revenue, i) => ({ month: i + 1, revenue, region: "NA" })), + ], + }, + categorical: { + label: "Categorical totals", + description: "Four products, one numeric. Classic bar-chart shape.", + data: [ + { product: "Widget", units: 30 }, + { product: "Gadget", units: 50 }, + { product: "Sprocket", units: 20 }, + { product: "Whatsit", units: 45 }, + ], + }, + distribution: { + label: "Distribution", + description: "100 numeric observations — best read as a distribution.", + data: Array.from({ length: 100 }, (_, i) => ({ + observation: 50 + Math.sin(i / 7) * 18 + (i % 5 === 0 ? 25 : 0) + Math.random() * 6, + })), + }, + scatter: { + label: "Two-numeric relationship", + description: "x and y are both numeric without time semantics.", + data: Array.from({ length: 60 }, () => { + const x = Math.random() * 100 + return { x, y: x * 0.6 + Math.random() * 25 } + }), + }, +} + +const INTENTS = [ + { id: "", label: "Any intent" }, + { id: "trend", label: "Trend" }, + { id: "compare-categories", label: "Compare categories" }, + { id: "rank", label: "Rank" }, + { id: "part-to-whole", label: "Part to whole" }, + { id: "distribution", label: "Distribution" }, + { id: "correlation", label: "Correlation" }, + { id: "composition-over-time", label: "Composition over time" }, +] + +function SuggestionsDemo() { + const [datasetKey, setDatasetKey] = useState("temporal") + const [intent, setIntent] = useState("") + const dataset = DATASETS[datasetKey] + + const { suggestions, profile } = useChartSuggestions(dataset.data, { + intent: intent || undefined, + maxResults: 6, + includeVariants: true, + }) + + // Find the highest-ranked suggestion this surface can render. The engine's + // actual top pick is shown in the "All suggestions" sidebar regardless; + // the rendered preview falls back to the next renderable one if the very + // top isn't in this demo's COMPONENT_MAP. + const Top = suggestions.find((s) => COMPONENT_MAP[s.component]) ?? null + const Component = Top && COMPONENT_MAP[Top.component] + const trueTop = suggestions[0] + const topNotRenderable = trueTop && Top && trueTop.component !== Top.component + + return ( +

    +
    + + +
    + +

    {dataset.description}

    + +
    +
    + {Component && Top ? ( + <> +
    + {topNotRenderable ? "Top renderable suggestion: " : "Top suggestion: "} + {Top.component}{Top.variant ? ` · ${Top.variant.label}` : ""} +
    + {topNotRenderable && ( +
    + Engine's actual top pick was {trueTop.component} — not included + in this demo's render map. See the all-suggestions sidebar for the full ranking. +
    + )} + + + ) : ( +
    No fitting chart for this profile.
    + )} +
    + +
    +
    + All suggestions (ranked) +
    +
    + {suggestions.map((s, i) => ( +
    +
    + {s.component}{s.variant ? ` · ${s.variant.label}` : ""} + {s.score.toFixed(1)}/5 +
    +
    + fam {s.rubric.familiarity} · acc {s.rubric.accuracy} · prec {s.rubric.precision} +
    + {s.reasons.length > 0 && ( +
    + {s.reasons.join("; ")} +
    + )} + {s.caveats.length > 0 && ( +
    + {s.caveats.join("; ")} +
    + )} +
    + ))} +
    +
    +
    + +
    + Shape profile +
    {JSON.stringify({
    +          rowCount: profile.rowCount,
    +          primary: profile.primary,
    +          categoryCount: profile.categoryCount,
    +          seriesCount: profile.seriesCount,
    +          uniqueXCount: profile.uniqueXCount,
    +          hasRepeatedX: profile.hasRepeatedX,
    +          monotonicX: profile.monotonicX,
    +          hasTimeAxis: profile.hasTimeAxis,
    +        }, null, 2)}
    +
    +
    + ) +} + +export default function SuggestionsPage() { + return ( + +

    + 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. The{" "} + useChartSuggestions hook walks the registry and returns a ranked, ready-to-render + list. Heuristic only — no LLM call. Pair with{" "} + useChartInterrogation 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. +

    + + + +

    How it composes

    +
      +
    1. profileData(data) infers candidate x/y/series/category fields, distinct counts, monotonicity, and structure (hierarchy/network/geo).
    2. +
    3. For each capability: fits(profile) is a hard gate (returns null to pass).
    4. +
    5. intentScores are evaluated (numbers or profile-aware functions).
    6. +
    7. Variants apply additive intentDeltas and rubricDeltas.
    8. +
    9. Suggestions are sorted by the requested intent (or mean across intents).
    10. +
    11. buildProps(profile, variant) returns spreadable props for the chart.
    12. +
    + +

    Implementation

    + +{`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

    No fitting chart for this data.

    + const Component = COMPONENT_MAP[top.component] + return +}`} +
    + +

    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, and{" "} + buildProps. Variants encode the idea that{" "} + settings change what a chart is good for — a stacked area with the{" "} + streamgraph variant boosts trend readability but penalizes{" "} + part-to-whole (because totals become unreadable). Those tradeoffs surface in the + suggestion's intentScores, caveats, and{" "} + reasons. +

    + +

    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. +

    + +

    Adding a custom capability

    + +{`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, + }), +})`} + +
    + ) +} diff --git a/docs/src/pages/features/VegaLiteTranslatorPage.js b/docs/src/pages/features/VegaLiteTranslatorPage.js index c2f888e9..d28c0b37 100644 --- a/docs/src/pages/features/VegaLiteTranslatorPage.js +++ b/docs/src/pages/features/VegaLiteTranslatorPage.js @@ -236,10 +236,11 @@ export default function VegaLiteTranslatorPage() { {/* ── Why ────────────────────────────────────────────────────────── */}
    diff --git a/package-lock.json b/package-lock.json index 70d3830f..c22298f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "semiotic", - "version": "3.5.4", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "semiotic", - "version": "3.5.4", + "version": "3.6.0", "license": "Apache-2.0", "dependencies": { "d3-array": "^3.2.4", diff --git a/package.json b/package.json index 2f14f7ac..a0561c99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "semiotic", - "version": "3.5.4", + "version": "3.6.0", "mcpName": "io.github.nteract/semiotic", "description": "React data visualization library with built-in MCP server for AI-assisted chart generation", "main": "dist/semiotic.min.js", @@ -155,6 +155,8 @@ "check:ai-contracts": "node scripts/generate-ai-behavior-contracts.mjs --check", "check:ssr": "node scripts/check-ssr-alignment.js", "check:capabilities": "node scripts/check-capabilities.mjs", + "check:capability-coverage": "node scripts/check-capability-coverage.mjs", + "scorecard": "node scripts/run-capability-scorecard.mjs", "docs:capabilities": "node scripts/generate-capabilities-md.mjs && node scripts/generate-capabilities-json.mjs", "check:chart-specs": "npx tsx scripts/check-chart-specs.ts", "docs:chart-specs:schema": "npx tsx scripts/regenerate-schema.ts", @@ -165,8 +167,8 @@ "check:blog-entries": "node scripts/check-blog-entry-sync.mjs", "check:bundle-sizes": "node scripts/sync-bundle-sizes.mjs --check", "docs:bundle-sizes": "node scripts/sync-bundle-sizes.mjs", - "release:check": "npm run lint && npm run typescript && npm run typescript:tests && npm run typescript:mcp && npm run test && npm run check:chart-specs && npm run check:capabilities && npm run check:blog-entries && npm run check:claude-md-coverage && npm run check:context7 && npm run check:mcp-registry && npm run check:surface && npm run check:ai-contracts && npm run check:ssr && npm run check:test-quality && npm run check:jsdoc-coverage && npm run check:ai-examples-coverage && npm run dist:prod && npm run check:bundle-sizes && npm run size && npm run check:pack && npm pack --dry-run", - "prepublishOnly": "npm run lint && npm run typescript && npm run typescript:tests && npm run typescript:mcp && npm run test && npm run check:chart-specs && npm run check:capabilities && npm run check:blog-entries && npm run check:claude-md-coverage && npm run check:context7 && npm run check:mcp-registry && npm run check:surface && npm run check:ai-contracts && npm run check:ssr && npm run check:test-quality && npm run check:jsdoc-coverage && npm run check:ai-examples-coverage && rm -rf dist && npm run dist:prod && npm run check:bundle-sizes && npm run size" + "release:check": "npm run lint && npm run typescript && npm run typescript:tests && npm run typescript:mcp && npm run test && npm run check:chart-specs && npm run check:capabilities && npm run check:capability-coverage && npm run check:blog-entries && npm run check:claude-md-coverage && npm run check:context7 && npm run check:mcp-registry && npm run check:surface && npm run check:ai-contracts && npm run check:ssr && npm run check:test-quality && npm run check:jsdoc-coverage && npm run check:ai-examples-coverage && npm run dist:prod && npm run check:bundle-sizes && npm run size && npm run check:pack && npm pack --dry-run", + "prepublishOnly": "npm run lint && npm run typescript && npm run typescript:tests && npm run typescript:mcp && npm run test && npm run check:chart-specs && npm run check:capabilities && npm run check:capability-coverage && npm run check:blog-entries && npm run check:claude-md-coverage && npm run check:context7 && npm run check:mcp-registry && npm run check:surface && npm run check:ai-contracts && npm run check:ssr && npm run check:test-quality && npm run check:jsdoc-coverage && npm run check:ai-examples-coverage && rm -rf dist && npm run dist:prod && npm run check:bundle-sizes && npm run size" }, "targets": { "website": { @@ -181,9 +183,16 @@ }, "alias": { "semiotic": "./src/components/semiotic.ts", + "semiotic/ai": "./src/components/semiotic-ai.ts", + "semiotic/data": "./src/components/semiotic-data.ts", "semiotic/geo": "./src/components/semiotic-geo.ts", - "semiotic/utils": "./src/components/semiotic-utils.ts", + "semiotic/network": "./src/components/semiotic-network.ts", + "semiotic/ordinal": "./src/components/semiotic-ordinal.ts", + "semiotic/realtime": "./src/components/semiotic-realtime.ts", "semiotic/recipes": "./src/components/semiotic-recipes.ts", + "semiotic/themes": "./src/components/semiotic-themes.ts", + "semiotic/utils": "./src/components/semiotic-utils.ts", + "semiotic/xy": "./src/components/semiotic-xy.ts", "react-router-dom": "react-router" }, "repository": { diff --git a/scripts/check-blog-entry-sync.mjs b/scripts/check-blog-entry-sync.mjs index da46c38c..60af777f 100644 --- a/scripts/check-blog-entry-sync.mjs +++ b/scripts/check-blog-entry-sync.mjs @@ -44,6 +44,13 @@ function readOgChart(source) { return match ? { component: parseJsonString(match[1]) } : undefined } +function readDraftFlag(source) { + // Match `draft: true` (and `draft: false` for completeness). Absent → undefined. + const match = source.match(/draft:\s*(true|false)/m) + if (!match) return undefined + return match[1] === "true" +} + function parseEntryFile(path) { const source = readFileSync(path, "utf8") return { @@ -55,6 +62,7 @@ function parseEntryFile(path) { tags: readTags(source), excerpt: readStringField(source, "excerpt"), ogChart: readOgChart(source), + draft: readDraftFlag(source), } } @@ -64,8 +72,11 @@ function parseEntriesRegistry() { for (const match of source.matchAll(/import\s+([A-Za-z_$][\w$]*)\s+from\s+"\.\/entries\/([^"]+)"/g)) { imports.set(match[1], resolve(ROOT, "docs/src/blog/entries", match[2])) } - const arrayMatch = source.match(/export const blogEntries\s*=\s*\[([\s\S]*?)\]/m) - if (!arrayMatch) throw new Error("Could not find `export const blogEntries = [...]`") + // Match `allBlogEntries` (full list including drafts). `blogEntries` is + // derived via filter and so isn't a literal array — we always read the + // source-of-truth literal. + const arrayMatch = source.match(/export const allBlogEntries\s*=\s*\[([\s\S]*?)\]/m) + if (!arrayMatch) throw new Error("Could not find `export const allBlogEntries = [...]`") const names = [...arrayMatch[1].matchAll(/\b([A-Za-z_$][\w$]*)\b/g)].map((m) => m[1]) return names.map((name) => { const entryPath = imports.get(name) @@ -110,7 +121,9 @@ function objectBlocksFromArray(source, marker) { function parseMetaRegistry() { const source = readFileSync(META_JS, "utf8") - return objectBlocksFromArray(source, "blogEntriesMeta").map((block) => ({ + // Read `allBlogEntriesMeta` literal — `blogEntriesMeta` is the filtered + // alias and isn't an array literal at parse time. + return objectBlocksFromArray(source, "allBlogEntriesMeta").map((block) => ({ slug: readStringField(block, "slug"), title: readStringField(block, "title"), subtitle: readStringField(block, "subtitle"), @@ -119,6 +132,7 @@ function parseMetaRegistry() { tags: readTags(block), excerpt: readStringField(block, "excerpt"), ogChart: readOgChart(block), + draft: readDraftFlag(block), })) } @@ -167,6 +181,13 @@ for (let i = 0; i < max; i++) { if (fullOg !== mirrorOg) { fail(errors, `${full.name}.ogChart.component drift: entries.js=${JSON.stringify(fullOg)}, entries-meta.js=${JSON.stringify(mirrorOg)}`) } + // Treat absent and false as the same — `draft: false` and no `draft` field + // are equivalent (entry is published). + const fullDraft = full.draft === true + const mirrorDraft = mirror.draft === true + if (fullDraft !== mirrorDraft) { + fail(errors, `${full.name}.draft drift: entries.js=${fullDraft}, entries-meta.js=${mirrorDraft}`) + } } if (errors.length > 0) { diff --git a/scripts/check-capability-coverage.mjs b/scripts/check-capability-coverage.mjs new file mode 100644 index 00000000..1d22593f --- /dev/null +++ b/scripts/check-capability-coverage.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * Capability-descriptor coverage check. + * + * Every HOC chart listed in `ai/capabilities.json` should either: + * (a) have a colocated `Foo.capability.ts` descriptor registered in + * `src/components/ai/chartCapabilities.ts`, or + * (b) appear in the deliberate-exclusion list at the bottom of this file + * (with a reason — realtime, custom-layout, multi-chart). + * + * Drift in either direction is a CI error. + * + * Rationale lives in `docs/strategy/chart-capability-layer.md` § + * "Phase 2.6 — Capability coverage CI". + */ + +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const repoRoot = path.resolve(__dirname, "..") + +const errors = [] +const note = (msg) => errors.push(msg) + +// 1. Load the chart inventory from the existing capabilities.json +const capabilitiesPath = path.join(repoRoot, "ai", "capabilities.json") +const inventory = JSON.parse(fs.readFileSync(capabilitiesPath, "utf8")) +const allCharts = Object.keys(inventory.charts ?? {}).sort() + +// 2. Read the capability registry source and extract the components it imports. +const registryPath = path.join(repoRoot, "src", "components", "ai", "chartCapabilities.ts") +const registrySrc = fs.readFileSync(registryPath, "utf8") +const importedCapabilities = new Set() +const importRe = /import\s+\{\s*(\w+Capability)\s*\}\s+from\s+"[^"]+\/(\w+)\.capability"/g +let match +while ((match = importRe.exec(registrySrc)) !== null) { + const componentName = match[2] + importedCapabilities.add(componentName) +} + +// 3. Deliberate exclusions — kept in sync with the comment block in chartCapabilities.ts. +// Only includes charts that are in ai/capabilities.json. Custom-layout charts +// (XY/Ordinal/NetworkCustomChart) and LinkedCharts aren't in capabilities.json +// because they don't fit the standard chart-spec model. +const DELIBERATELY_EXCLUDED = new Map([ + ["RealtimeLineChart", "realtime — streaming source, static suggestion engine doesn't apply"], + ["RealtimeHistogram", "realtime — streaming source"], + ["TemporalHistogram", "realtime sibling — streaming source"], + ["RealtimeSwarmChart", "realtime"], + ["RealtimeWaterfallChart", "realtime"], + ["RealtimeHeatmap", "realtime"], + ["ScatterplotMatrix", "multi-chart composition — data shape is a tuple"], +]) + +// 4. Cross-check +const missing = [] +const unexpectedExclusion = [] +for (const chart of allCharts) { + const hasCapability = importedCapabilities.has(chart) + const isExcluded = DELIBERATELY_EXCLUDED.has(chart) + if (!hasCapability && !isExcluded) { + missing.push(chart) + } + if (hasCapability && isExcluded) { + unexpectedExclusion.push(chart) + } +} + +// 5. Charts in exclusion list but not in inventory (typo guard) +const inventorySet = new Set(allCharts) +const phantomExclusions = [] +for (const chart of DELIBERATELY_EXCLUDED.keys()) { + if (!inventorySet.has(chart)) phantomExclusions.push(chart) +} + +// 6. Capability files that aren't imported (orphans) +const colocatedFiles = [] +const chartDirs = ["xy", "ordinal", "network", "geo"] +for (const dir of chartDirs) { + const dirPath = path.join(repoRoot, "src", "components", "charts", dir) + if (!fs.existsSync(dirPath)) continue + for (const file of fs.readdirSync(dirPath)) { + if (file.endsWith(".capability.ts")) { + const componentName = file.replace(".capability.ts", "") + colocatedFiles.push(componentName) + } + } +} +const orphanFiles = colocatedFiles.filter((c) => !importedCapabilities.has(c)) + +if (missing.length) { + note(`Charts in ai/capabilities.json without a registered capability descriptor:\n ${missing.join(", ")}\n Either add a *.capability.ts file and register it in src/components/ai/chartCapabilities.ts, or add an entry to DELIBERATELY_EXCLUDED in this script with a reason.`) +} +if (unexpectedExclusion.length) { + note(`Charts that have a registered capability AND appear in DELIBERATELY_EXCLUDED:\n ${unexpectedExclusion.join(", ")}\n Remove them from one or the other.`) +} +if (phantomExclusions.length) { + note(`DELIBERATELY_EXCLUDED entries that don't match any chart in ai/capabilities.json (typo?):\n ${phantomExclusions.join(", ")}`) +} +if (orphanFiles.length) { + note(`Capability descriptor files on disk but not imported by the registry:\n ${orphanFiles.join(", ")}`) +} + +if (errors.length) { + console.error("❌ Capability coverage check failed:\n") + for (const e of errors) console.error(" - " + e + "\n") + process.exit(1) +} + +const coveredCount = allCharts.length - DELIBERATELY_EXCLUDED.size +console.log(`✅ Capability coverage: ${importedCapabilities.size} descriptors registered, ${DELIBERATELY_EXCLUDED.size} deliberate exclusions, ${allCharts.length} charts total.`) diff --git a/scripts/run-capability-scorecard.mjs b/scripts/run-capability-scorecard.mjs new file mode 100644 index 00000000..99862f5d --- /dev/null +++ b/scripts/run-capability-scorecard.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * Run the descriptor quality scorecard against the canonical fixture set. + * + * Prints a human-readable summary and writes the full report to + * `ai/capability-scorecard.json` for vizmart / tooling to consume. + * + * Not in `release:check` by default — the scorecard is a tuning tool, not + * a release gate. Run with `npm run scorecard`. + * + * Rationale: `docs/strategy/chart-capability-layer.md` § Phase 2.1 + V.8. + */ + +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +// Use the built dist — keeps the script Node-runnable without ts-node. +const { runQualityScorecard } = await import("../dist/semiotic-ai.module.min.js") +const { CANONICAL_FIXTURES } = await import("../dist/semiotic-ai.module.min.js") + +if (!runQualityScorecard || !CANONICAL_FIXTURES) { + console.error("❌ Scorecard helpers not found in dist/semiotic-ai.module.min.js — rebuild with `npm run dist`.") + process.exit(1) +} + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const repoRoot = path.resolve(__dirname, "..") + +const report = runQualityScorecard(CANONICAL_FIXTURES) + +// Write machine-readable copy +const outPath = path.join(repoRoot, "ai", "capability-scorecard.json") +fs.writeFileSync(outPath, JSON.stringify(report, null, 2)) + +// Human-readable summary +const fmtPct = (n) => `${(n * 100).toFixed(0)}%` +const fmtScore = (n) => n.toFixed(2) + +console.log("Capability Quality Scorecard") +console.log("============================") +console.log(`Fixtures evaluated: ${report.summary.fixtureCount}`) +console.log(`Capabilities tested: ${report.summary.capabilityCount}`) +console.log(`Expert agreement rate: ${fmtPct(report.summary.expertAgreementRate)}`) +console.log(`Overall caveat coverage: ${fmtPct(report.summary.overallCaveatCoverage)}`) +console.log(`Overall variant utilization: ${fmtPct(report.summary.overallVariantUtilization)}`) +console.log("") + +console.log("Per-fixture results:") +for (const f of report.perFixture) { + const top = f.topPick ? `${f.topPick.component}${f.topPick.variantKey ? "/" + f.topPick.variantKey : ""} (${fmtScore(f.topPick.score)})` : "—" + const agreement = f.expertAgreement === null ? " " : f.expertAgreement ? "✓ " : "✗ " + const intent = f.intent ? ` [${f.intent}]` : "" + console.log(` ${agreement}${f.fixture}${intent}`) + console.log(` top: ${top}, fitting=${f.fittingCount}, rejected=${f.rejectedCount}`) + if (f.expected && f.expected.length) { + console.log(` expected: ${f.expected.join(", ")}`) + } +} +console.log("") + +console.log("Weakest descriptors (sorted by expert-agreement count, ascending):") +const weakest = report.perCapability.slice(0, 12) +for (const c of weakest) { + console.log(` ${c.component.padEnd(28)} fits=${String(c.fitsOn).padStart(2)} reject=${String(c.rejectedOn).padStart(2)} top3=${String(c.inTopThreeOn).padStart(2)} agree=${c.expertAgreementCount} avg=${fmtScore(c.averageScore)} caveat=${fmtPct(c.caveatCoverage)} variant=${fmtPct(c.variantUtilization)}`) +} +console.log("") +console.log(`Full report written to: ${path.relative(repoRoot, outPath)}`) diff --git a/scripts/scorecard-dev.ts b/scripts/scorecard-dev.ts new file mode 100644 index 00000000..6a64b250 --- /dev/null +++ b/scripts/scorecard-dev.ts @@ -0,0 +1,26 @@ +// Dev-only: run the scorecard against TS source so we can iterate without +// waiting for full dist rebuilds. Invoked via npx tsx. +import { runQualityScorecard } from "../src/components/ai/qualityScorecard" +import { CANONICAL_FIXTURES } from "../src/components/ai/qualityFixtures" + +const report = runQualityScorecard(CANONICAL_FIXTURES) +const fmtPct = (n: number) => `${(n * 100).toFixed(0)}%` +const fmtScore = (n: number) => n.toFixed(2) + +console.log(`Expert agreement: ${fmtPct(report.summary.expertAgreementRate)} across ${report.summary.fixtureCount} fixtures`) +console.log(`Caveat coverage: ${fmtPct(report.summary.overallCaveatCoverage)}`) +console.log(`Variant util: ${fmtPct(report.summary.overallVariantUtilization)}`) +console.log("") + +console.log("Per-fixture:") +for (const f of report.perFixture) { + const top = f.topPick ? `${f.topPick.component}${f.topPick.variantKey ? "/" + f.topPick.variantKey : ""}` : "—" + const agree = f.expertAgreement === null ? " " : f.expertAgreement ? "✓" : "✗" + console.log(` ${agree} ${f.fixture.padEnd(60)} top=${top}`) +} +console.log("") + +console.log("Weakest descriptors:") +for (const c of report.perCapability.slice(0, 12)) { + console.log(` ${c.component.padEnd(28)} fits=${String(c.fitsOn).padStart(2)} rej=${String(c.rejectedOn).padStart(2)} top3=${String(c.inTopThreeOn).padStart(2)} agree=${c.expertAgreementCount} avg=${fmtScore(c.averageScore)}`) +} diff --git a/server.json b/server.json index 07b56b1d..da2f5d9f 100644 --- a/server.json +++ b/server.json @@ -8,13 +8,13 @@ "url": "https://github.com/nteract/semiotic", "source": "github" }, - "version": "3.5.4", + "version": "3.6.0", "packages": [ { "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "semiotic", - "version": "3.5.4", + "version": "3.6.0", "transport": { "type": "stdio" } diff --git a/src/components/ai/audienceProfile.test.ts b/src/components/ai/audienceProfile.test.ts new file mode 100644 index 00000000..6fdd8f0c --- /dev/null +++ b/src/components/ai/audienceProfile.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from "vitest" +import { applyAudienceBias, effectiveFamiliarity, stretchFamiliarityCeiling } from "./audienceProfile" +import type { AudienceProfile } from "./audienceProfile" +import { suggestCharts } from "./suggestCharts" +import { executivePersona, dataScientistPersona, analystPersona } from "./audiences" + +const baseRubric = { familiarity: 3, accuracy: 4, precision: 4 } + +describe("applyAudienceBias", () => { + it("returns identity when no audience is supplied", () => { + const r = applyAudienceBias(3.5, baseRubric, "BarChart", undefined) + expect(r.score).toBe(3.5) + expect(r.rubric).toEqual(baseRubric) + expect(r.appliedReason).toBeUndefined() + }) + + it("overrides familiarity when audience specifies it", () => { + const audience: AudienceProfile = { familiarity: { BarChart: 5 } } + const r = applyAudienceBias(3.5, baseRubric, "BarChart", audience) + expect(r.rubric.familiarity).toBe(5) + // Familiarity bias: (5 - 3) * 0.5 = +1.0 + expect(r.score).toBeCloseTo(4.5) + }) + + it("applies increase target as positive score delta", () => { + const audience: AudienceProfile = { + targets: { BoxPlot: { direction: "increase", weight: 2 } }, + } + const r = applyAudienceBias(3.0, baseRubric, "BoxPlot", audience) + // No familiarity override; target +1.0 * 2 = +2.0 + expect(r.score).toBe(5.0) + }) + + it("applies decrease target as negative score delta", () => { + const audience: AudienceProfile = { + targets: { PieChart: { direction: "decrease", weight: 3 } }, + } + const r = applyAudienceBias(4.5, baseRubric, "PieChart", audience) + // Target -1.0 * 3 = -3.0 + expect(r.score).toBeCloseTo(1.5) + }) + + it("combines familiarity + target", () => { + const audience: AudienceProfile = { + familiarity: { BoxPlot: 2 }, + targets: { BoxPlot: { direction: "increase", weight: 2 } }, + } + const r = applyAudienceBias(3.0, baseRubric, "BoxPlot", audience) + // Familiarity (2-3)*0.5 = -0.5; target +2.0 → +1.5 total + expect(r.score).toBeCloseTo(4.5) + expect(r.rubric.familiarity).toBe(2) + }) + + it("clamps target weight to 1..3", () => { + const audience: AudienceProfile = { + targets: { X: { direction: "increase", weight: 10 } }, + } + const r = applyAudienceBias(0, baseRubric, "X", audience) + expect(r.score).toBe(3) // 1.0 * 3 (clamped) + }) + + it("includes appliedReason when target fires", () => { + const audience: AudienceProfile = { + name: "Acme", + targets: { BoxPlot: { direction: "increase", reason: "we want distributions" } }, + } + const r = applyAudienceBias(3.0, baseRubric, "BoxPlot", audience) + expect(r.appliedReason).toContain("Acme") + expect(r.appliedReason).toContain("distributions") + }) +}) + +describe("effectiveFamiliarity", () => { + it("returns audience override when present", () => { + const audience: AudienceProfile = { familiarity: { BoxPlot: 5 } } + expect(effectiveFamiliarity("BoxPlot", 2, audience)).toBe(5) + }) + it("returns default when audience does not list the chart", () => { + const audience: AudienceProfile = { familiarity: { BarChart: 5 } } + expect(effectiveFamiliarity("BoxPlot", 2, audience)).toBe(2) + }) + it("returns default when no audience supplied", () => { + expect(effectiveFamiliarity("BoxPlot", 2, undefined)).toBe(2) + }) +}) + +describe("stretchFamiliarityCeiling", () => { + it("returns 3 for no audience or exposureLevel undefined/1", () => { + expect(stretchFamiliarityCeiling(undefined)).toBe(3) + expect(stretchFamiliarityCeiling({})).toBe(3) + expect(stretchFamiliarityCeiling({ exposureLevel: 1 })).toBe(3) + }) + it("returns 4 at exposureLevel 2", () => { + expect(stretchFamiliarityCeiling({ exposureLevel: 2 })).toBe(4) + }) +}) + +describe("suggestCharts × audience", () => { + const categorical = [ + { product: "A", units: 30 }, + { product: "B", units: 50 }, + { product: "C", units: 20 }, + { product: "D", units: 45 }, + ] + + it("data scientist persona meaningfully decreases PieChart for rank intent", () => { + const withoutAudience = suggestCharts(categorical, { intent: "rank", includeVariants: false }) + const withAudience = suggestCharts(categorical, { + intent: "rank", + audience: dataScientistPersona, + includeVariants: false, + // Lower minScore so we can see the biased score even if it goes negative + minScore: -10, + }) + const pieBase = withoutAudience.find((s) => s.component === "PieChart") + const pieAud = withAudience.find((s) => s.component === "PieChart") + expect(pieBase).toBeDefined() + expect(pieAud).toBeDefined() + if (pieBase && pieAud) { + // Data scientist: PieChart familiarity 3 (no shift) + decrease target weight 2 = -2.0 + expect(pieAud.score).toBeLessThan(pieBase.score - 1) + } + }) + + it("strong decrease targets can suppress a chart entirely below default minScore", () => { + // With the default minScore (0), PieChart's biased score for rank + // (1 - 2 = -1) falls below the floor and disappears from results. + const suggestions = suggestCharts(categorical, { + intent: "rank", + audience: dataScientistPersona, + includeVariants: false, + }) + expect(suggestions.find((s) => s.component === "PieChart")).toBeUndefined() + }) + + it("appends audience rationale to suggestion.reasons when a target fires", () => { + const suggestions = suggestCharts(categorical, { + audience: dataScientistPersona, + includeVariants: false, + }) + const pie = suggestions.find((s) => s.component === "PieChart") + if (pie) { + expect(pie.reasons.some((r) => r.toLowerCase().includes("length") || r.toLowerCase().includes("decrease"))).toBe(true) + } + }) + + it("returns the same ranking as no-audience when audience is empty", () => { + const a = suggestCharts(categorical, { intent: "rank", includeVariants: false }) + const b = suggestCharts(categorical, { intent: "rank", includeVariants: false, audience: {} }) + expect(a.map((s) => s.component)).toEqual(b.map((s) => s.component)) + }) + + it("preserves overall ranking quality — top pick remains valid", () => { + // BarChart should still win rank for the analyst even with a mild + // decrease-pie target, because BarChart is the correct answer. + const suggestions = suggestCharts(categorical, { + intent: "rank", + audience: analystPersona, + includeVariants: false, + }) + expect(suggestions[0].component).toBe("BarChart") + }) +}) diff --git a/src/components/ai/audienceProfile.ts b/src/components/ai/audienceProfile.ts new file mode 100644 index 00000000..2744ac48 --- /dev/null +++ b/src/components/ai/audienceProfile.ts @@ -0,0 +1,143 @@ +import type { ChartRubric } from "./chartCapabilityTypes" + +/** + * A serializable description of who's reading the charts and what the + * organization is trying to grow. + * + * Semiotic does not measure familiarity — it consumes measurements. Orgs + * produce an AudienceProfile through whatever channel makes sense (surveys, + * telemetry, manager judgment, training records) and pass it to the + * suggestion APIs. The library applies the bias and returns rankings that + * reflect the audience instead of a generic data-literate baseline. + * + * Strategy memo: docs/strategy/audience-profiles.md + */ +export interface AudienceProfile { + /** + * Display name. Surfaced in suggestion `reasons[]` when a target fires so + * users can see whose policy is influencing the ranking. + */ + name?: string + /** + * Per-chart familiarity override (1..5). Replaces the descriptor's + * `rubric.familiarity`. Charts not listed fall back to the descriptor. + * + * @example + * familiarity: { BarChart: 5, LineChart: 5, PieChart: 4, BoxPlot: 2 } + */ + familiarity?: Partial> + /** + * Adoption targets — which charts the org is trying to grow or reduce. + * The engine applies a meaningful score bias (±1..3 depending on weight) + * so growth targets win close calls and decrease targets fall back unless + * they're the only fit. + * + * @example + * targets: { + * PieChart: { direction: "decrease", weight: 1 }, + * BoxPlot: { direction: "increase", weight: 2, + * reason: "we want the team reading distributions, not means" } + * } + */ + targets?: Partial> + /** + * Controls visibility of stretch picks (unfamiliar-but-relevant charts). + * 0 — never surface stretches; familiar-only rankings + * 1 — surface in a separate `stretchSuggestions` list (default when audience set) + * 2 — same as 1 but lowers the familiarity threshold (≤4) for what counts as stretch, + * widening the menu + */ + exposureLevel?: 0 | 1 | 2 +} + +export interface AudienceTarget { + direction: "increase" | "decrease" + /** 1..3 — controls bias magnitude. Default 1. */ + weight?: number + /** Human-readable rationale. Surfaces in suggestion.reasons when the target fires. */ + reason?: string +} + +export interface AudienceBiasResult { + /** Composite score after audience adjustments. Unclamped — can range outside 0..5. */ + score: number + /** Effective rubric for the chart after audience overrides. */ + rubric: ChartRubric + /** Reason string to append to the suggestion when a target fired. */ + appliedReason?: string +} + +const FAMILIARITY_WEIGHT = 0.5 +const TARGET_WEIGHT = 1.0 + +/** + * Apply an AudienceProfile's bias to a chart's composite score and rubric. + * Pure function — used by both `suggestCharts` and `suggestStretchCharts`. + * + * Two terms compose additively: + * • Familiarity bias: (audienceFamiliarity − 3) × 0.5 + * — Range ±1.0. At familiarity 5 we add 1.0; at 1 we subtract 1.0. + * • Target bias: ±1.0 × weight + * — Range ±3.0 for weight=3. Strong enough to reorder rankings, + * not so strong that it overrides chart correctness for the data shape. + * + * Score is left unclamped so internal sorting reflects the magnitude of bias. + */ +export function applyAudienceBias( + baseScore: number, + baseRubric: ChartRubric, + component: string, + audience: AudienceProfile | undefined, +): AudienceBiasResult { + if (!audience) return { score: baseScore, rubric: baseRubric } + + const audienceFamiliarity = audience.familiarity?.[component] + const familiarity = audienceFamiliarity ?? baseRubric.familiarity + const target = audience.targets?.[component] + + let delta = 0 + if (audienceFamiliarity !== undefined) { + delta += (audienceFamiliarity - 3) * FAMILIARITY_WEIGHT + } + let appliedReason: string | undefined + if (target) { + const weight = Math.max(1, Math.min(3, target.weight ?? 1)) + const sign = target.direction === "increase" ? 1 : -1 + delta += sign * TARGET_WEIGHT * weight + if (target.reason) { + appliedReason = `${audience.name ? `${audience.name}: ` : ""}${target.reason}` + } else { + appliedReason = `${audience.name ? `${audience.name} ` : ""}target: ${target.direction} ${component}` + } + } + + return { + score: baseScore + delta, + rubric: { ...baseRubric, familiarity }, + appliedReason, + } +} + +/** + * Resolve the effective familiarity for a chart under an audience. Used by + * the stretch surface to decide whether a chart qualifies as "unfamiliar." + */ +export function effectiveFamiliarity( + component: string, + defaultFamiliarity: number, + audience: AudienceProfile | undefined, +): number { + if (!audience) return defaultFamiliarity + return audience.familiarity?.[component] ?? defaultFamiliarity +} + +/** + * Familiarity threshold for what counts as a "stretch" pick under this audience. + * Tighter for exposureLevel 1, wider for 2. Returns the highest familiarity a + * chart can have and still appear in the stretch surface. + */ +export function stretchFamiliarityCeiling(audience: AudienceProfile | undefined): number { + if (!audience) return 3 + if (audience.exposureLevel === 2) return 4 + return 3 +} diff --git a/src/components/ai/audiences.ts b/src/components/ai/audiences.ts new file mode 100644 index 00000000..7b7c36d4 --- /dev/null +++ b/src/components/ai/audiences.ts @@ -0,0 +1,224 @@ +import type { AudienceProfile } from "./audienceProfile" + +/** + * Three example AudienceProfile shapes. Not authoritative — these are + * sketches based on rough industry stereotypes, useful for documentation, + * demos, and as starting points consumers can fork. + * + * To use one in production, copy it and tune to your audience's actual + * survey/telemetry data. Do not assume these defaults represent your team. + */ + +/** + * Executive audience — high familiarity with bar/line/pie/gauge, + * limited tolerance for unfamiliar chart shapes. Most likely to encounter + * dashboards built by analysts; not building their own. + */ +export const executivePersona: AudienceProfile = { + name: "Executive", + familiarity: { + // Boardroom-comfortable + BarChart: 5, + LineChart: 5, + PieChart: 5, + DonutChart: 4, + GaugeChart: 5, + AreaChart: 4, + FunnelChart: 4, + ChoroplethMap: 4, + + // Recognizable but less common + Histogram: 3, + Heatmap: 3, + StackedBarChart: 3, + StackedAreaChart: 3, + Scatterplot: 3, + BubbleChart: 3, + GroupedBarChart: 3, + DotPlot: 3, + + // Specialist + BoxPlot: 2, + ViolinPlot: 1, + SwarmPlot: 1, + RidgelinePlot: 1, + MultiAxisLineChart: 2, + CandlestickChart: 2, + DifferenceChart: 2, + QuadrantChart: 3, + LikertChart: 3, + SwimlaneChart: 2, + MinimapChart: 2, + ConnectedScatterplot: 1, + + // Network/hierarchy + SankeyDiagram: 2, + TreeDiagram: 3, + Treemap: 3, + CirclePack: 2, + OrbitDiagram: 1, + ChordDiagram: 1, + ProcessSankey: 2, + ForceDirectedGraph: 1, + + // Geo specialist + ProportionalSymbolMap: 3, + FlowMap: 2, + DistanceCartogram: 1, + }, + targets: { + PieChart: { + direction: "decrease", + weight: 1, + reason: "shifting from share-by-angle toward share-by-length for accuracy", + }, + BarChart: { + direction: "increase", + weight: 1, + }, + }, + exposureLevel: 1, +} + +/** + * Analyst audience — broader chart vocabulary, comfortable with + * distribution-shape and matrix-shape charts. Building dashboards for + * others; can read most things on first encounter. + */ +export const analystPersona: AudienceProfile = { + name: "Analyst", + familiarity: { + BarChart: 5, + LineChart: 5, + PieChart: 4, + DonutChart: 4, + AreaChart: 5, + StackedAreaChart: 4, + StackedBarChart: 5, + GroupedBarChart: 5, + Histogram: 5, + Heatmap: 5, + Scatterplot: 5, + BubbleChart: 4, + BoxPlot: 4, + DotPlot: 4, + GaugeChart: 3, + FunnelChart: 4, + LikertChart: 4, + QuadrantChart: 4, + SwimlaneChart: 4, + MinimapChart: 4, + DifferenceChart: 3, + MultiAxisLineChart: 4, + CandlestickChart: 3, + ConnectedScatterplot: 3, + + // Less common in analyst workflows + ViolinPlot: 3, + SwarmPlot: 3, + RidgelinePlot: 2, + + // Network/hierarchy + TreeDiagram: 4, + Treemap: 4, + CirclePack: 3, + SankeyDiagram: 4, + ProcessSankey: 3, + ChordDiagram: 3, + OrbitDiagram: 2, + ForceDirectedGraph: 3, + + // Geo + ChoroplethMap: 4, + ProportionalSymbolMap: 4, + FlowMap: 3, + DistanceCartogram: 2, + }, + targets: { + PieChart: { direction: "decrease", weight: 1 }, + BoxPlot: { + direction: "increase", + weight: 1, + reason: "team is shifting from averages to distribution-aware comparisons", + }, + }, + exposureLevel: 1, +} + +/** + * Data scientist audience — comfortable with the full distribution-chart + * family, regression overlays, and density encodings. Will accept most + * exotic shapes if they're more honest about the data. + */ +export const dataScientistPersona: AudienceProfile = { + name: "Data scientist", + familiarity: { + BarChart: 5, + LineChart: 5, + PieChart: 3, + DonutChart: 3, + AreaChart: 5, + StackedAreaChart: 5, + StackedBarChart: 5, + GroupedBarChart: 5, + Histogram: 5, + Heatmap: 5, + Scatterplot: 5, + BubbleChart: 5, + BoxPlot: 5, + ViolinPlot: 5, + SwarmPlot: 4, + RidgelinePlot: 4, + DotPlot: 4, + QuadrantChart: 4, + LikertChart: 4, + DifferenceChart: 4, + MultiAxisLineChart: 4, + ConnectedScatterplot: 4, + GaugeChart: 2, + FunnelChart: 3, + SwimlaneChart: 3, + MinimapChart: 4, + CandlestickChart: 3, + + // Network/hierarchy + TreeDiagram: 4, + Treemap: 4, + CirclePack: 4, + SankeyDiagram: 4, + ProcessSankey: 3, + ChordDiagram: 3, + OrbitDiagram: 2, + ForceDirectedGraph: 4, + + // Geo + ChoroplethMap: 4, + ProportionalSymbolMap: 4, + FlowMap: 3, + DistanceCartogram: 3, + }, + targets: { + PieChart: { + direction: "decrease", + weight: 2, + reason: "preferring length-encoded comparisons for precision", + }, + BarChart: { + direction: "decrease", + weight: 1, + reason: "promoting distribution-aware charts over single-value bars when raw observations are available", + }, + BoxPlot: { direction: "increase", weight: 1 }, + ViolinPlot: { direction: "increase", weight: 1 }, + }, + exposureLevel: 2, +} + +/** + * Convenience map for consumers loading audience by name (e.g. from a config string). + */ +export const BUILT_IN_AUDIENCES: Record = { + executive: executivePersona, + analyst: analystPersona, + "data-scientist": dataScientistPersona, +} diff --git a/src/components/ai/chartCapabilities.ts b/src/components/ai/chartCapabilities.ts new file mode 100644 index 00000000..30427a58 --- /dev/null +++ b/src/components/ai/chartCapabilities.ts @@ -0,0 +1,193 @@ +import type { ChartCapability } from "./chartCapabilityTypes" + +// XY family +import { LineChartCapability } from "../charts/xy/LineChart.capability" +import { AreaChartCapability } from "../charts/xy/AreaChart.capability" +import { StackedAreaChartCapability } from "../charts/xy/StackedAreaChart.capability" +import { ScatterplotCapability } from "../charts/xy/Scatterplot.capability" +import { ConnectedScatterplotCapability } from "../charts/xy/ConnectedScatterplot.capability" +import { BubbleChartCapability } from "../charts/xy/BubbleChart.capability" +import { QuadrantChartCapability } from "../charts/xy/QuadrantChart.capability" +import { MultiAxisLineChartCapability } from "../charts/xy/MultiAxisLineChart.capability" +import { MinimapChartCapability } from "../charts/xy/MinimapChart.capability" +import { DifferenceChartCapability } from "../charts/xy/DifferenceChart.capability" +import { CandlestickChartCapability } from "../charts/xy/CandlestickChart.capability" +import { HeatmapCapability } from "../charts/xy/Heatmap.capability" + +// Ordinal family +import { BarChartCapability } from "../charts/ordinal/BarChart.capability" +import { GroupedBarChartCapability } from "../charts/ordinal/GroupedBarChart.capability" +import { StackedBarChartCapability } from "../charts/ordinal/StackedBarChart.capability" +import { DotPlotCapability } from "../charts/ordinal/DotPlot.capability" +import { PieChartCapability } from "../charts/ordinal/PieChart.capability" +import { DonutChartCapability } from "../charts/ordinal/DonutChart.capability" +import { FunnelChartCapability } from "../charts/ordinal/FunnelChart.capability" +import { GaugeChartCapability } from "../charts/ordinal/GaugeChart.capability" +import { LikertChartCapability } from "../charts/ordinal/LikertChart.capability" +import { SwimlaneChartCapability } from "../charts/ordinal/SwimlaneChart.capability" +import { HistogramCapability } from "../charts/ordinal/Histogram.capability" +import { BoxPlotCapability } from "../charts/ordinal/BoxPlot.capability" +import { SwarmPlotCapability } from "../charts/ordinal/SwarmPlot.capability" +import { ViolinPlotCapability } from "../charts/ordinal/ViolinPlot.capability" +import { RidgelinePlotCapability } from "../charts/ordinal/RidgelinePlot.capability" + +// Network family +import { ForceDirectedGraphCapability } from "../charts/network/ForceDirectedGraph.capability" +import { SankeyDiagramCapability } from "../charts/network/SankeyDiagram.capability" +import { ChordDiagramCapability } from "../charts/network/ChordDiagram.capability" +import { ProcessSankeyCapability } from "../charts/network/ProcessSankey.capability" +import { TreeDiagramCapability } from "../charts/network/TreeDiagram.capability" +import { TreemapCapability } from "../charts/network/Treemap.capability" +import { CirclePackCapability } from "../charts/network/CirclePack.capability" +import { OrbitDiagramCapability } from "../charts/network/OrbitDiagram.capability" + +// Geo family +import { ChoroplethMapCapability } from "../charts/geo/ChoroplethMap.capability" +import { ProportionalSymbolMapCapability } from "../charts/geo/ProportionalSymbolMap.capability" +import { FlowMapCapability } from "../charts/geo/FlowMap.capability" +import { DistanceCartogramCapability } from "../charts/geo/DistanceCartogram.capability" + +/** + * Built-in capability descriptors. Each chart owns its own descriptor in + * `Foo.capability.ts` next to `Foo.tsx`. To add a new chart, write the descriptor + * and append it here. + * + * Charts intentionally NOT in this registry: + * • Realtime variants (RealtimeLineChart, RealtimeHistogram, ...) — they're for + * streaming data, while `suggestCharts` operates on static datasets. + * • Custom-layout charts (XYCustomChart, OrdinalCustomChart, NetworkCustomChart) — + * they require a layout function and are escape-hatches by design. + * • LinkedCharts and ScatterplotMatrix — multi-chart compositions whose data + * shape is a tuple, not a single dataset. + * + * Consumers can still register these (or any custom chart) via `registerChartCapability`. + */ +const BUILT_IN_CAPABILITIES: ReadonlyArray = [ + // XY + LineChartCapability, + AreaChartCapability, + StackedAreaChartCapability, + ScatterplotCapability, + ConnectedScatterplotCapability, + BubbleChartCapability, + QuadrantChartCapability, + MultiAxisLineChartCapability, + MinimapChartCapability, + DifferenceChartCapability, + CandlestickChartCapability, + HeatmapCapability, + // Ordinal + BarChartCapability, + GroupedBarChartCapability, + StackedBarChartCapability, + DotPlotCapability, + PieChartCapability, + DonutChartCapability, + FunnelChartCapability, + GaugeChartCapability, + LikertChartCapability, + SwimlaneChartCapability, + // Distribution + HistogramCapability, + BoxPlotCapability, + SwarmPlotCapability, + ViolinPlotCapability, + RidgelinePlotCapability, + // Network + ForceDirectedGraphCapability, + SankeyDiagramCapability, + ChordDiagramCapability, + ProcessSankeyCapability, + // Hierarchy + TreeDiagramCapability, + TreemapCapability, + CirclePackCapability, + OrbitDiagramCapability, + // Geo + ChoroplethMapCapability, + ProportionalSymbolMapCapability, + FlowMapCapability, + DistanceCartogramCapability, +] + +const userCapabilities = new Map() + +/** + * Register a capability for a chart (built-in or third-party). Re-registering by + * component name replaces the previous descriptor — useful for overriding defaults. + */ +export function registerChartCapability(capability: ChartCapability): void { + userCapabilities.set(capability.component, capability) +} + +/** Remove a previously-registered capability. Does not affect built-ins. */ +export function unregisterChartCapability(component: string): void { + userCapabilities.delete(component) +} + +/** + * Current capability list — built-ins, then user-registered, with user-registered + * overriding built-ins by component name. + */ +export function getCapabilities(): ReadonlyArray { + if (userCapabilities.size === 0) return BUILT_IN_CAPABILITIES + const merged = new Map() + for (const c of BUILT_IN_CAPABILITIES) merged.set(c.component, c) + for (const [name, c] of userCapabilities) merged.set(name, c) + return Array.from(merged.values()) +} + +/** Look up a capability by component name. */ +export function getCapability(component: string): ChartCapability | undefined { + return getCapabilities().find((c) => c.component === component) +} + +// Re-export every built-in descriptor so consumers can import them individually +// without pulling in the registry. +export { + // XY + LineChartCapability, + AreaChartCapability, + StackedAreaChartCapability, + ScatterplotCapability, + ConnectedScatterplotCapability, + BubbleChartCapability, + QuadrantChartCapability, + MultiAxisLineChartCapability, + MinimapChartCapability, + DifferenceChartCapability, + CandlestickChartCapability, + HeatmapCapability, + // Ordinal + BarChartCapability, + GroupedBarChartCapability, + StackedBarChartCapability, + DotPlotCapability, + PieChartCapability, + DonutChartCapability, + FunnelChartCapability, + GaugeChartCapability, + LikertChartCapability, + SwimlaneChartCapability, + // Distribution + HistogramCapability, + BoxPlotCapability, + SwarmPlotCapability, + ViolinPlotCapability, + RidgelinePlotCapability, + // Network + ForceDirectedGraphCapability, + SankeyDiagramCapability, + ChordDiagramCapability, + ProcessSankeyCapability, + // Hierarchy + TreeDiagramCapability, + TreemapCapability, + CirclePackCapability, + OrbitDiagramCapability, + // Geo + ChoroplethMapCapability, + ProportionalSymbolMapCapability, + FlowMapCapability, + DistanceCartogramCapability, +} diff --git a/src/components/ai/chartCapabilityTypes.ts b/src/components/ai/chartCapabilityTypes.ts new file mode 100644 index 00000000..4f5cc421 --- /dev/null +++ b/src/components/ai/chartCapabilityTypes.ts @@ -0,0 +1,219 @@ +import type { Datum } from "../charts/shared/datumTypes" +import type { DataSummary } from "../data/DataSummarizer" +import type { IntentId } from "./intents" + +/** + * Chart family — high-level taxonomy used for filtering and intent matching. + */ +export type ChartFamily = + | "time-series" + | "categorical" + | "distribution" + | "relationship" + | "flow" + | "network" + | "hierarchy" + | "geo" + | "realtime" + | "custom" + +/** + * Where a chart is imported from. Used by generators to emit correct import paths. + */ +export type ChartImportPath = + | "semiotic/xy" + | "semiotic/ordinal" + | "semiotic/network" + | "semiotic/geo" + | "semiotic/realtime" + | "semiotic/ai" + | "semiotic" + +/** + * Familiarity/accuracy/precision rubric (1-5 each). + * Familiarity = how well-known the chart is to a general audience. + * Accuracy = how faithfully it represents the underlying data. + * Precision = how readable individual values are. + */ +export interface ChartRubric { + familiarity: number + accuracy: number + precision: number +} + +/** + * The kind of value a field holds, used for axis fitness. + */ +export type FieldKind = "numeric" | "categorical" | "date" | "boolean" | "unknown" + +/** + * A candidate field for a given role (x, y, series, etc.), with a quality score. + */ +export interface FieldCandidate { + field: string + kind: FieldKind + /** 0..1 — how good this field is for the role being considered. */ + quality: number + /** Field-level stats for downstream scorers. */ + distinctCount?: number + /** True if the field's values are strictly increasing in row order. */ + monotonic?: boolean +} + +/** + * Profile of a dataset for chart-fitness scoring. Extends DataSummary with + * shape inference (axis candidates, structure detection, primary roles). + */ +export interface ChartDataProfile extends DataSummary { + /** Original rows (read-only); used by capabilities to compute their own stats. */ + data: ReadonlyArray + /** Candidate fields per role, sorted best-first. */ + candidates: { + x: FieldCandidate[] + y: FieldCandidate[] + size: FieldCandidate[] + category: FieldCandidate[] + series: FieldCandidate[] + time: FieldCandidate[] + } + /** Best-guess primary assignment per role (the top candidate, if any). */ + primary: { + x?: string + y?: string + size?: string + category?: string + series?: string + time?: string + } + /** Distinct count of the primary category field, if any. */ + categoryCount?: number + /** Distinct count of the primary series field, if any. */ + seriesCount?: number + /** Distinct count of the primary x field, if any. */ + uniqueXCount?: number + /** True when some x value appears in more than one row (suggests aggregation). */ + hasRepeatedX: boolean + /** True when the primary x candidate is monotonic. */ + monotonicX: boolean + /** True when there is at least one date-typed candidate. */ + hasTimeAxis: boolean + /** + * How the primary x role was inferred. Capabilities can use this to detect + * the "scatter fallback" case (x picked only because there were 2+ numerics, + * not because the field is genuinely an x-axis) and decline to recommend + * themselves for trend-shaped intents. + * + * • "time" — explicit date/time field + * • "named" — numeric whose name matches an x-pattern (month, year, index, …) + * • "scatter"— filled in via the two-numeric scatter fallback; weak signal + * • "none" — no x role inferred + */ + xProvenance: "time" | "named" | "scatter" | "none" + /** Source dataset looks like a hierarchy (had a `children` array at root). */ + hasHierarchy: boolean + /** Source dataset looks like a node/edge graph. */ + hasNetwork: boolean + /** Source dataset looks like GeoJSON (FeatureCollection). */ + hasGeo: boolean + /** Extracted network payload when hasNetwork is true. */ + network?: { nodes: ReadonlyArray; edges: ReadonlyArray } + /** Extracted hierarchy root when hasHierarchy is true. */ + hierarchy?: Datum + /** Extracted GeoJSON FeatureCollection when hasGeo is true. */ + geo?: { features: ReadonlyArray; points?: ReadonlyArray; flows?: ReadonlyArray } +} + +/** + * An intent scorer is either a static 0..5 score or a function evaluated against the profile. + */ +export type IntentScorer = + | number + | ((profile: ChartDataProfile) => number) + +/** + * Variant — a configuration of the chart that meaningfully changes what it's good for. + * + * Variants compose into suggestions. The `intentDeltas` are additive against the + * base capability's intent scores (clamped to 0..5 by the engine). + */ +export interface ChartVariant { + key: string + label: string + description?: string + /** Props to merge into the base chart props. */ + props: Record + /** Style/role tags (used by consumers like vizmart for filtering). */ + tags?: ReadonlyArray + /** Per-intent additive score deltas (e.g. {"trend": +1, "outlier-detection": -2}). */ + intentDeltas?: Partial> + /** Rubric deltas — usually small, e.g. smoothing trades precision for familiarity. */ + rubricDeltas?: Partial + /** Caveats specific to this variant — surfaced in suggestion.caveats. */ + caveats?: ReadonlyArray +} + +/** + * Result of a capability's `fits()` gate. `null` means the chart fits. A string + * is the human-readable reason it doesn't, used for diagnostics and reasoning. + */ +export type FitResult = null | string + +/** + * The capability descriptor each chart ships alongside itself. + * + * Charts that declare a capability participate in `suggestCharts`, `useChartSuggestions`, + * and the `interrogateChart` MCP tool's recommendation surface. + */ +export interface ChartCapability { + component: string + family: ChartFamily + importPath: ChartImportPath + /** Base rubric, before variant/profile adjustments. */ + rubric: ChartRubric + /** + * Hard requirements gate. Return null if the chart can render this profile, + * or a human-readable string explaining why not (e.g. "no numeric y candidate"). + */ + fits: (profile: ChartDataProfile) => FitResult + /** + * Per-intent suitability score (0..5). Missing intents default to 0. + * Values may be functions for profile-aware scoring. + */ + intentScores: Partial> + /** + * Variants — different settings that change what the chart is useful for. + * Suggestion engine emits one suggestion per (capability × variant) pair. + * If empty, the engine still emits a base suggestion. + */ + variants?: ReadonlyArray + /** Caveats independent of variants (e.g. "log scale skipped for negative values"). */ + caveats?: (profile: ChartDataProfile) => ReadonlyArray + /** + * Build the props you'd pass to this chart for this dataset. Should produce + * a runnable config (accessor names, etc.) so consumers can ``. + */ + buildProps: (profile: ChartDataProfile, variant?: ChartVariant) => Record +} + +/** + * One suggestion produced by `suggestCharts`. Consumers render this as a card, + * pass it to an LLM for re-ranking, or hand the props straight to the chart. + */ +export interface Suggestion { + component: string + family: ChartFamily + importPath: ChartImportPath + variant?: ChartVariant + /** Composite score for the ranking intent(s), 0..5. */ + score: number + /** Per-intent scores after variant deltas. */ + intentScores: Partial> + /** Rubric after variant/profile adjustments. */ + rubric: ChartRubric + /** Narrative reasons this chart fits — suitable for tooltips or LLM context. */ + reasons: ReadonlyArray + /** Gotchas / things to be careful about. */ + caveats: ReadonlyArray + /** Ready-to-spread props. */ + props: Record +} diff --git a/src/components/ai/diffProfile.test.ts b/src/components/ai/diffProfile.test.ts new file mode 100644 index 00000000..4b01fec0 --- /dev/null +++ b/src/components/ai/diffProfile.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest" +import { profileData } from "./profileData" +import { diffProfile } from "./diffProfile" + +describe("diffProfile", () => { + it("reports unchanged when profiles are equivalent", () => { + const data = [{ a: 1, b: "x" }, { a: 2, b: "y" }] + const diff = diffProfile(profileData(data), profileData(data)) + expect(diff.unchanged).toBe(true) + expect(diff.added).toEqual([]) + expect(diff.removed).toEqual([]) + }) + + it("reports row count change", () => { + const a = profileData([{ x: 1 }, { x: 2 }]) + const b = profileData([{ x: 1 }, { x: 2 }, { x: 3 }]) + const diff = diffProfile(a, b) + expect(diff.rowCountChange).toBe(1) + }) + + it("reports added and removed fields", () => { + const a = profileData([{ a: 1, b: 2 }]) + const b = profileData([{ b: 2, c: 3 }]) + const diff = diffProfile(a, b) + expect(diff.added).toEqual(["c"]) + expect(diff.removed).toEqual(["a"]) + }) + + it("reports field type changes", () => { + const a = profileData([{ x: 1, score: 10 }, { x: 2, score: 20 }]) + const b = profileData([{ x: 1, score: "high" }, { x: 2, score: "low" }]) + const diff = diffProfile(a, b) + expect(diff.typeChanges.some((c) => c.field === "score" && c.from === "numeric" && c.to === "categorical")).toBe(true) + }) + + it("reports primary role re-assignments", () => { + const a = profileData([{ value: 10, region: "EU" }, { value: 20, region: "NA" }]) + // Adding a time field should move x's primary from numeric to time + const b = profileData([ + { value: 10, region: "EU", date: "2025-01-01" }, + { value: 20, region: "NA", date: "2025-02-01" }, + ]) + const diff = diffProfile(a, b) + const xChange = diff.primaryChanges.find((c) => c.role === "x") + const timeChange = diff.primaryChanges.find((c) => c.role === "time") + expect(xChange || timeChange).toBeDefined() + if (timeChange) { + expect(timeChange.from).toBeUndefined() + expect(timeChange.to).toBe("date") + } + }) + + it("reports charts that become fit/unfit", () => { + // Single row → 50 rows: histogram should become fit + const a = profileData([{ value: 10 }]) + const b = profileData(Array.from({ length: 50 }, (_, i) => ({ value: i + Math.random() * 5 }))) + const diff = diffProfile(a, b) + expect(diff.becameFit).toContain("Histogram") + }) + + it("becameUnfit and becameFit are disjoint", () => { + const a = profileData([{ x: 1, y: 2 }, { x: 3, y: 4 }, { x: 5, y: 6 }]) + const b = profileData([{ category: "A", value: 10 }, { category: "B", value: 20 }]) + const diff = diffProfile(a, b) + const overlap = diff.becameFit.filter((c) => diff.becameUnfit.includes(c)) + expect(overlap).toEqual([]) + }) +}) diff --git a/src/components/ai/diffProfile.ts b/src/components/ai/diffProfile.ts new file mode 100644 index 00000000..451fa447 --- /dev/null +++ b/src/components/ai/diffProfile.ts @@ -0,0 +1,131 @@ +import type { ChartDataProfile, FieldKind } from "./chartCapabilityTypes" +import { getCapabilities } from "./chartCapabilities" + +export type PrimaryRole = "x" | "y" | "size" | "category" | "series" | "time" + +export interface FieldTypeChange { + field: string + from: FieldKind | "unknown" + to: FieldKind | "unknown" +} + +export interface PrimaryRoleChange { + role: PrimaryRole + from: string | undefined + to: string | undefined +} + +export interface ProfileDiff { + /** Row count change (b.rowCount - a.rowCount). */ + rowCountChange: number + /** Fields present in b but not in a. */ + added: ReadonlyArray + /** Fields present in a but not in b. */ + removed: ReadonlyArray + /** Fields whose inferred type changed. */ + typeChanges: ReadonlyArray + /** Primary role re-assignments (e.g. x switched from "month" to "date"). */ + primaryChanges: ReadonlyArray + /** Suggestion components that fit a but not b. */ + becameUnfit: ReadonlyArray + /** Suggestion components that fit b but not a. */ + becameFit: ReadonlyArray + /** True when no observable change was detected. */ + unchanged: boolean +} + +const PRIMARY_ROLES: ReadonlyArray = ["x", "y", "size", "category", "series", "time"] + +function fieldKind(profile: ChartDataProfile, field: string): FieldKind | "unknown" { + const summary = profile.fields[field] + if (!summary) return "unknown" + if (summary.type === "numeric") return "numeric" + if (summary.type === "categorical") return "categorical" + if (summary.type === "date") return "date" + return "unknown" +} + +function fittingComponents(profile: ChartDataProfile): Set { + const set = new Set() + for (const capability of getCapabilities()) { + if (capability.fits(profile) === null) set.add(capability.component) + } + return set +} + +/** + * Compare two profiles and report what changed plus how the change affects + * chart suitability. Useful for: + * + * • "Why does my dashboard look different after the data refreshed?" + * • Editor warnings when a CSV upload would change the visible charts. + * • CI checks that flag when a fixture migration affects descriptor coverage. + * + * Doesn't compute *which suggestions ranked first* (that requires intent + + * full suggestCharts). Reports only structural deltas — added/removed fields, + * type changes, primary role re-assignments, fit set changes. + * + * @example + * const a = profileData(yesterdaysData) + * const b = profileData(todaysData) + * const diff = diffProfile(a, b) + * if (diff.becameUnfit.length) { + * console.warn(`These charts no longer fit: ${diff.becameUnfit.join(", ")}`) + * } + */ +export function diffProfile(a: ChartDataProfile, b: ChartDataProfile): ProfileDiff { + const aFields = new Set(Object.keys(a.fields)) + const bFields = new Set(Object.keys(b.fields)) + + const added: string[] = [] + const removed: string[] = [] + for (const field of bFields) { + if (!aFields.has(field)) added.push(field) + } + for (const field of aFields) { + if (!bFields.has(field)) removed.push(field) + } + added.sort() + removed.sort() + + const typeChanges: FieldTypeChange[] = [] + for (const field of bFields) { + if (!aFields.has(field)) continue + const aKind = fieldKind(a, field) + const bKind = fieldKind(b, field) + if (aKind !== bKind) typeChanges.push({ field, from: aKind, to: bKind }) + } + typeChanges.sort((x, y) => x.field.localeCompare(y.field)) + + const primaryChanges: PrimaryRoleChange[] = [] + for (const role of PRIMARY_ROLES) { + const aValue = a.primary[role] + const bValue = b.primary[role] + if (aValue !== bValue) primaryChanges.push({ role, from: aValue, to: bValue }) + } + + const aFit = fittingComponents(a) + const bFit = fittingComponents(b) + const becameUnfit = Array.from(aFit).filter((c) => !bFit.has(c)).sort() + const becameFit = Array.from(bFit).filter((c) => !aFit.has(c)).sort() + + const unchanged = + added.length === 0 && + removed.length === 0 && + typeChanges.length === 0 && + primaryChanges.length === 0 && + becameUnfit.length === 0 && + becameFit.length === 0 && + a.rowCount === b.rowCount + + return { + rowCountChange: b.rowCount - a.rowCount, + added, + removed, + typeChanges, + primaryChanges, + becameUnfit, + becameFit, + unchanged, + } +} diff --git a/src/components/ai/inferIntent.test.ts b/src/components/ai/inferIntent.test.ts new file mode 100644 index 00000000..8faa6e85 --- /dev/null +++ b/src/components/ai/inferIntent.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest" +import { inferIntent } from "./inferIntent" + +describe("inferIntent", () => { + const cases: Array<[string, string]> = [ + ["when did revenue peak?", "outlier-detection"], + ["show me the trend over time", "trend"], + ["which products are the top sellers?", "rank"], + ["what's the breakdown of revenue by region?", "part-to-whole"], + ["how is the distribution of test scores?", "distribution"], + ["is there a relationship between hours and grade?", "correlation"], + ["show conversion funnel from signup to purchase", "flow"], + ["display the org hierarchy", "hierarchy"], + ["what does this look like across countries?", "geo"], + ["how did the cohort composition change over time?", "composition-over-time"], + ["where did revenue suddenly shift?", "change-detection"], + ["compare regions side by side", "compare-series"], + ] + + it.each(cases)("maps %j → %s", (query, expected) => { + const result = inferIntent(query) + expect(result?.intent).toBe(expected) + }) + + it("returns null for empty or non-matching queries", () => { + expect(inferIntent("")).toBeNull() + expect(inferIntent(" ")).toBeNull() + expect(inferIntent("hello there")).toBeNull() + expect(inferIntent("what is this?")).toBeNull() + }) + + it("composition-over-time outranks plain trend when both apply", () => { + const result = inferIntent("show me the composition over time of revenue") + expect(result?.intent).toBe("composition-over-time") + }) + + it("returns alternates when multiple intents apply", () => { + const result = inferIntent("trend by category over time") + expect(result).not.toBeNull() + if (result) { + expect(result.confidence).toBeGreaterThan(0) + // alternates may be empty or populated depending on patterns matched + expect(Array.isArray(result.alternates)).toBe(true) + } + }) + + it("geo wins over other intents when geography is mentioned", () => { + const result = inferIntent("show me the trend across countries") + expect(result?.intent).toBe("geo") + }) +}) diff --git a/src/components/ai/inferIntent.ts b/src/components/ai/inferIntent.ts new file mode 100644 index 00000000..17f51342 --- /dev/null +++ b/src/components/ai/inferIntent.ts @@ -0,0 +1,180 @@ +import type { IntentId, BuiltInIntentId } from "./intents" + +/** + * Pure-heuristic mapping from a natural-language query to a canonical intent. + * + * Designed for chat-style interrogation surfaces (vizmart's Shopkeeper, any + * "ask the chart" UI) where the user types in their own words and the + * suggestion engine needs an intent to rank by. Built on regex patterns — + * fast, zero-dependency, offline. Returns the single best-matching intent + * or `null` if nothing clearly applies. + * + * Consumers who want a richer mapping (handling negation, multi-intent + * queries, domain jargon) should layer their own LLM call on top of this + * heuristic — it's a good cheap default, not a replacement. + */ + +interface IntentPattern { + intent: BuiltInIntentId + /** Patterns that should match the query (case-insensitive). Any match wins. */ + patterns: RegExp[] + /** Weight when multiple intents match — higher wins ties. */ + weight: number +} + +const PATTERNS: IntentPattern[] = [ + { + intent: "outlier-detection", + weight: 4, + patterns: [ + /\b(outlier|outliers|anomal|anomaly|anomalies|extreme|extremes|unusual|stands? out|sticks? out|odd one)\b/i, + /\b(peak|peaks|highest|lowest|biggest spike|spike|min|max|maximum|minimum)\b/i, + ], + }, + { + intent: "trend", + weight: 4, + patterns: [ + /\b(trend|trends|trending|trajectory|over time|across time|growth|decline|rising|falling|increasing|decreasing)\b/i, + /\b(history|historical|evolved|evolution|change over)\b/i, + ], + }, + { + intent: "change-detection", + weight: 3, + patterns: [ + /\b(when did|what changed|shift|shifted|breakpoint|inflection|turning point|sudden|abrupt)\b/i, + ], + }, + { + intent: "rank", + weight: 4, + patterns: [ + /\b(rank|ranking|ranked|biggest|smallest|largest|order by|sorted|best|worst|leaderboard)\b/i, + /\btop\s+(\d+|sellers?|performers?|picks?|results?|categories|items?)\b/i, + /\bbottom\s+(\d+|results?|items?)\b/i, + /\b(who has the most|which.*most|which.*highest|which.*lowest)\b/i, + ], + }, + { + intent: "part-to-whole", + weight: 4, + patterns: [ + /\b(share|shares|composition|portion|portions|fraction|percentage of|percent of|breakdown|make up|made up of|slice|slices)\b/i, + /\b(part of|part to whole|piece of the pie|how much of)\b/i, + ], + }, + { + intent: "composition-over-time", + weight: 5, // outranks plain "trend" + "part-to-whole" when both appear + patterns: [ + /\b(composition.*time|share.*over time|share.*across|how.*mix.*changed|stacked.*time)\b/i, + /\b(over time.*share|over time.*composition|over time.*breakdown)\b/i, + ], + }, + { + intent: "distribution", + weight: 4, + patterns: [ + /\b(distribution|distributions|spread|variance|variation|histogram|skew|skewed|range of|how.*spread|shape of|bell curve)\b/i, + /\b(typical value|typical range|where do most|mode|median)\b/i, + ], + }, + { + intent: "correlation", + weight: 4, + patterns: [ + /\b(correl|correlation|relationship|related to|connected to|associated|connection between|relate to)\b/i, + /\b(\w+ vs\.? \w+|\w+ versus \w+|\w+ against \w+|scatter)\b/i, + ], + }, + { + intent: "compare-series", + weight: 3, + patterns: [ + /\b(compare.*series|compare.*groups|compare.*cohorts|side by side|group.*vs|series.*vs)\b/i, + /\b(how do.*compare|each group|each series|each cohort)\b/i, + ], + }, + { + intent: "compare-categories", + weight: 3, + patterns: [ + /\b(compare.*categor|category.*compar|which is bigger|how does.*compare|differences? between)\b/i, + ], + }, + { + intent: "flow", + weight: 4, + patterns: [ + /\b(flow|flows|transition|transitions|movement|moved from|funnel|conversion|drop[- ]off|sankey|chord)\b/i, + /\b(from.*to|source.*target|path|journey|pipeline)\b/i, + ], + }, + { + intent: "hierarchy", + weight: 4, + patterns: [ + /\b(hierarchy|hierarchical|tree|nested|parent.*child|subcategory|sub-?categor|drill down|drilldown|breakdown by level)\b/i, + ], + }, + { + intent: "geo", + weight: 5, // geographic mentions are almost always intent-defining + patterns: [ + // Strong: explicitly geographic vocabulary that's unambiguous + /\b(geographic|geography|geospatial|map|maps|country|countries|cities|latitude|longitude|spatial|cartogr|choropleth)\b/i, + // Medium: "city" alone, "state" only when clearly a place + /\b(city|us state|each state|the states)\b/i, + // "across" + place noun is a strong geo signal (regions get caught here) + /\bacross\s+(countries|states|regions|cities|the world|the country)\b/i, + ], + }, +] + +export interface InferIntentResult { + intent: IntentId + /** 1..5 score for ranking ties. Higher = stronger match. */ + confidence: number + /** Other plausible intents, sorted by confidence. */ + alternates: ReadonlyArray<{ intent: IntentId; confidence: number }> +} + +/** + * Map a natural-language query to a built-in intent. Returns `null` when no + * pattern matches with meaningful confidence. + * + * @example + * inferIntent("when did revenue peak?") + * // → { intent: "outlier-detection", confidence: 4, alternates: [] } + * inferIntent("show me the trend over time") + * // → { intent: "trend", confidence: 4, alternates: [] } + * inferIntent("hello") + * // → null + */ +export function inferIntent(query: string): InferIntentResult | null { + if (typeof query !== "string" || query.trim().length === 0) return null + + const matches = new Map() + for (const pattern of PATTERNS) { + for (const re of pattern.patterns) { + if (re.test(query)) { + const existing = matches.get(pattern.intent) ?? 0 + // First match contributes full weight; subsequent matches of the + // same intent add diminishing weight (capped at 5). + const next = Math.min(5, existing === 0 ? pattern.weight : existing + 0.5) + matches.set(pattern.intent, next) + break // one match per intent is enough — multiple regex hits within an intent shouldn't dominate + } + } + } + + if (matches.size === 0) return null + + const sorted = Array.from(matches.entries()) + .map(([intent, confidence]) => ({ intent, confidence })) + .sort((a, b) => b.confidence - a.confidence) + + const [top, ...alternates] = sorted + return { intent: top.intent, confidence: top.confidence, alternates } +} diff --git a/src/components/ai/intents.ts b/src/components/ai/intents.ts new file mode 100644 index 00000000..f59f1175 --- /dev/null +++ b/src/components/ai/intents.ts @@ -0,0 +1,147 @@ +/** + * Canonical intent taxonomy for chart suggestion / interrogation. + * + * An "intent" is what the user is trying to *see* in the data. Charts declare how + * well they serve each intent in their capability descriptor. The suggestion engine + * filters and ranks by intent. + * + * The taxonomy is fixed but extensible: consumers can call `registerIntent` to add + * domain-specific intents at runtime. The IntentId type stays union-of-known so + * built-in code remains type-safe; registered intents are addressable as plain strings. + */ + +export type BuiltInIntentId = + | "trend" + | "compare-series" + | "compare-categories" + | "rank" + | "part-to-whole" + | "distribution" + | "correlation" + | "flow" + | "hierarchy" + | "geo" + | "outlier-detection" + | "composition-over-time" + | "change-detection" + +/** + * Any intent — built-in or user-registered. Custom intents are plain strings. + */ +export type IntentId = BuiltInIntentId | (string & {}) + +export interface IntentDescriptor { + id: IntentId + label: string + description: string + /** Soft hint of which chart family typically serves this intent. */ + familyHint?: "time-series" | "categorical" | "distribution" | "relationship" | "flow" | "network" | "hierarchy" | "geo" +} + +const BUILT_IN_INTENTS: IntentDescriptor[] = [ + { + id: "trend", + label: "Trend over time", + description: "How a single metric changes over an ordered sequence (typically time).", + familyHint: "time-series", + }, + { + id: "compare-series", + label: "Compare series", + description: "Compare multiple measured series across a shared x domain.", + familyHint: "time-series", + }, + { + id: "compare-categories", + label: "Compare categories", + description: "Compare a single measure across discrete categories.", + familyHint: "categorical", + }, + { + id: "rank", + label: "Rank", + description: "Show category ordering by a measure (largest to smallest).", + familyHint: "categorical", + }, + { + id: "part-to-whole", + label: "Part to whole", + description: "Show how individual categories share a total.", + familyHint: "categorical", + }, + { + id: "distribution", + label: "Distribution", + description: "Show the shape, spread, and central tendency of a numeric variable.", + familyHint: "distribution", + }, + { + id: "correlation", + label: "Correlation", + description: "Show the relationship between two (or more) numeric variables.", + familyHint: "relationship", + }, + { + id: "flow", + label: "Flow", + description: "Show movement, transitions, or transfers between states.", + familyHint: "flow", + }, + { + id: "hierarchy", + label: "Hierarchy", + description: "Show parent/child structure or nested totals.", + familyHint: "hierarchy", + }, + { + id: "geo", + label: "Geography", + description: "Show values bound to geographic locations or regions.", + familyHint: "geo", + }, + { + id: "outlier-detection", + label: "Outlier detection", + description: "Surface individual data points that diverge from the rest.", + familyHint: "distribution", + }, + { + id: "composition-over-time", + label: "Composition over time", + description: "Show how the share of categories changes across an ordered sequence.", + familyHint: "time-series", + }, + { + id: "change-detection", + label: "Change detection", + description: "Surface where or when a metric shifted meaningfully.", + familyHint: "time-series", + }, +] + +const intentRegistry = new Map( + BUILT_IN_INTENTS.map((intent) => [intent.id, intent]) +) + +/** Get an intent descriptor by id, or undefined if not registered. */ +export function getIntent(id: IntentId): IntentDescriptor | undefined { + return intentRegistry.get(id) +} + +/** All currently-registered intents (built-in + user-added). */ +export function listIntents(): IntentDescriptor[] { + return Array.from(intentRegistry.values()) +} + +/** + * Register a custom intent at runtime. Idempotent — re-registering with the same id + * replaces the descriptor. + */ +export function registerIntent(intent: IntentDescriptor): void { + intentRegistry.set(intent.id, intent) +} + +/** Sentinel set used by capability authors to opt out of an intent without misspelling. */ +export const BUILT_IN_INTENT_IDS: ReadonlySet = new Set( + BUILT_IN_INTENTS.map((intent) => intent.id) +) as ReadonlySet diff --git a/src/components/ai/profileData.test.ts b/src/components/ai/profileData.test.ts new file mode 100644 index 00000000..0c24d634 --- /dev/null +++ b/src/components/ai/profileData.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest" +import { profileData } from "./profileData" + +describe("profileData", () => { + it("identifies time/x/y/series candidates from a temporal dataset", () => { + const data = [ + { date: "2024-01-01", revenue: 1200, region: "EU" }, + { date: "2024-02-01", revenue: 1400, region: "EU" }, + { date: "2024-03-01", revenue: 1100, region: "EU" }, + { date: "2024-01-01", revenue: 900, region: "NA" }, + { date: "2024-02-01", revenue: 1100, region: "NA" }, + { date: "2024-03-01", revenue: 1500, region: "NA" }, + ] + const profile = profileData(data) + expect(profile.hasTimeAxis).toBe(true) + expect(profile.primary.time).toBe("date") + expect(profile.primary.x).toBe("date") + expect(profile.primary.y).toBe("revenue") + expect(profile.primary.series).toBe("region") + expect(profile.seriesCount).toBe(2) + expect(profile.hasRepeatedX).toBe(true) + }) + + it("handles a categorical dataset (bar-chart-shaped)", () => { + const data = [ + { product: "Widget", units: 30 }, + { product: "Gadget", units: 50 }, + { product: "Sprocket", units: 20 }, + ] + const profile = profileData(data) + expect(profile.primary.category).toBe("product") + expect(profile.primary.y).toBe("units") + expect(profile.categoryCount).toBe(3) + expect(profile.hasTimeAxis).toBe(false) + }) + + it("detects monotonic x", () => { + const data = Array.from({ length: 10 }, (_, i) => ({ x: i, y: Math.random() })) + const profile = profileData(data) + expect(profile.monotonicX).toBe(true) + }) + + it("detects hierarchy structure via rawInput", () => { + const profile = profileData([], { rawInput: { name: "root", children: [{ name: "a", value: 1 }] } }) + expect(profile.hasHierarchy).toBe(true) + expect(profile.hasNetwork).toBe(false) + }) + + it("detects network structure via rawInput", () => { + const profile = profileData([], { rawInput: { nodes: [{}], edges: [{}] } }) + expect(profile.hasNetwork).toBe(true) + }) + + it("detects geo structure via rawInput", () => { + const profile = profileData([], { rawInput: { type: "FeatureCollection", features: [] } }) + expect(profile.hasGeo).toBe(true) + }) +}) diff --git a/src/components/ai/profileData.ts b/src/components/ai/profileData.ts new file mode 100644 index 00000000..f42553a7 --- /dev/null +++ b/src/components/ai/profileData.ts @@ -0,0 +1,365 @@ +import type { Datum } from "../charts/shared/datumTypes" +import { summarizeData, type DataSummary, type FieldSummary } from "../data/DataSummarizer" +import type { ChartDataProfile, FieldCandidate, FieldKind } from "./chartCapabilityTypes" + +const X_FIELD_HINT = /^(x|index|rank|order|step|sequence|year|month|day|date|time|timestamp)$/i +const Y_FIELD_HINT = /^(y|value|amount|total|count|revenue|sales|price|score|rate|population|measure)$/i +const SIZE_FIELD_HINT = /(size|magnitude|volume|weight|count|amount)/i +const CATEGORY_FIELD_HINT = /^(category|label|name|type|group|region|segment|kind|class)$/i +const SERIES_FIELD_HINT = /^(series|group|type|category|segment|cohort|product)$/i + +const NUMERIC_LIKE_FOR_SIZE = new Set(["numeric"]) +const NUMERIC_OR_TIME_FOR_X = new Set(["numeric", "date"]) +const NUMERIC_FOR_Y = new Set(["numeric"]) +const DATE_FOR_TIME = new Set(["date"]) +const CATEGORICAL_LIKE = new Set(["categorical", "boolean"]) + +function fieldKindFromSummary(summary: FieldSummary): FieldKind { + if (summary.type === "numeric") return "numeric" + if (summary.type === "date") return "date" + if (summary.type === "categorical") return "categorical" + return "unknown" +} + +function nameBonus(field: string, hint: RegExp): number { + return hint.test(field) ? 0.2 : 0 +} + +function monotonic(data: ReadonlyArray, field: string): boolean { + let prev: number | null = null + for (let i = 0; i < data.length; i++) { + const v = data[i]?.[field] + if (v == null) continue + const n = v instanceof Date ? v.getTime() : Number(v) + if (!Number.isFinite(n)) return false + if (prev !== null && n < prev) return false + prev = n + } + return prev !== null +} + +function rankCandidates( + fields: Record, + data: ReadonlyArray, + allowed: Set, + hint: RegExp, + options: { computeMonotonic?: boolean } = {} +): FieldCandidate[] { + const out: FieldCandidate[] = [] + for (const [field, summary] of Object.entries(fields)) { + const kind = fieldKindFromSummary(summary) + if (!allowed.has(kind)) continue + let quality = 0.5 + quality += nameBonus(field, hint) + + let distinctCount: number | undefined + if (summary.type === "categorical") { + distinctCount = summary.distinctCount + // Categories with too few or too many values are less useful + if (distinctCount && distinctCount >= 2 && distinctCount <= 12) quality += 0.2 + if (distinctCount && distinctCount > 50) quality -= 0.2 + } + if (summary.type === "numeric") { + // Stable numerics with a real range score better + if (Number.isFinite(summary.min) && Number.isFinite(summary.max) && summary.max > summary.min) quality += 0.1 + } + + const candidate: FieldCandidate = { + field, + kind, + quality: Math.max(0, Math.min(1, quality)), + distinctCount, + } + if (options.computeMonotonic && (kind === "numeric" || kind === "date")) { + candidate.monotonic = monotonic(data, field) + if (candidate.monotonic) candidate.quality = Math.min(1, candidate.quality + 0.2) + } + out.push(candidate) + } + out.sort((a, b) => b.quality - a.quality) + return out +} + +function distinct(data: ReadonlyArray, field: string): number { + const seen = new Set() + for (let i = 0; i < data.length; i++) { + const v = data[i]?.[field] + if (v == null) continue + seen.add(String(v)) + } + return seen.size +} + +function hasRepeatedField(data: ReadonlyArray, field: string): boolean { + const seen = new Set() + for (let i = 0; i < data.length; i++) { + const v = data[i]?.[field] + if (v == null) continue + const key = String(v) + if (seen.has(key)) return true + seen.add(key) + } + return false +} + +interface InferStructure { + hasHierarchy: boolean + hasNetwork: boolean + hasGeo: boolean + network?: { nodes: ReadonlyArray; edges: ReadonlyArray } + hierarchy?: Datum + geo?: { features: ReadonlyArray; points?: ReadonlyArray; flows?: ReadonlyArray } +} + +function inferStructure(rawInput: unknown): InferStructure { + if (rawInput && typeof rawInput === "object" && !Array.isArray(rawInput)) { + const obj = rawInput as Record + if (obj.type === "FeatureCollection" && Array.isArray(obj.features)) { + return { + hasHierarchy: false, + hasNetwork: false, + hasGeo: true, + geo: { + features: obj.features as ReadonlyArray, + points: Array.isArray(obj.points) ? (obj.points as ReadonlyArray) : undefined, + flows: Array.isArray(obj.flows) ? (obj.flows as ReadonlyArray) : undefined, + }, + } + } + if (Array.isArray(obj.children)) { + return { hasHierarchy: true, hasNetwork: false, hasGeo: false, hierarchy: obj as Datum } + } + if (Array.isArray(obj.nodes) && (Array.isArray(obj.edges) || Array.isArray(obj.links))) { + const edges = (obj.edges ?? obj.links) as ReadonlyArray + return { + hasHierarchy: false, + hasNetwork: true, + hasGeo: false, + network: { nodes: obj.nodes as ReadonlyArray, edges }, + } + } + } + return { hasHierarchy: false, hasNetwork: false, hasGeo: false } +} + +// Field-name patterns for transition-event detection. A row like +// { stage: "Qualified", nextStage: "Discovery", startTime: "...", value: 14 } +// is conceptually an edge in a network — even though the rows themselves are +// a flat array, not a {nodes, edges} object. Recognising this pattern lets +// SankeyDiagram, ProcessSankey, ChordDiagram, and ForceDirectedGraph fit. +const SOURCE_FIELD_PATTERNS = /^(source|from|origin|stage|currentstage|sourcestage|fromstage)$/i +const TARGET_FIELD_PATTERNS = + /^(target|to|destination|nextstage|next|targetstage|tostage|destinationstage|status)$/i +const TRANSITION_START_PATTERNS = /^(starttime|startedat|enteredat|startdate|start|timestamp|date|time)$/i +const TRANSITION_END_PATTERNS = /^(endtime|endedat|exitedat|completedat|finishtime|enddate|end)$/i +const TRANSITION_VALUE_PATTERNS = /^(value|weight|amount|count|magnitude|volume)$/i + +function findField(fieldNames: ReadonlyArray, pattern: RegExp): string | undefined { + return fieldNames.find((f) => pattern.test(f)) +} + +/** + * Detect transition-event data — a flat array of rows where each row encodes + * an edge ({source, target, value?, startTime?}). When detected, derive an + * aggregated {nodes, edges} network so the network/flow chart family becomes + * viable. + * + * Returns null when the row shape doesn't look like transitions (e.g. when + * source and target aren't both present, or every row has source === target). + */ +function detectTransitionNetwork( + rows: ReadonlyArray, +): { nodes: ReadonlyArray; edges: ReadonlyArray } | null { + if (rows.length < 3) return null + const firstRow = rows[0] + if (!firstRow || typeof firstRow !== "object") return null + const fieldNames = Object.keys(firstRow) + + const sourceField = findField(fieldNames, SOURCE_FIELD_PATTERNS) + const targetField = findField(fieldNames, TARGET_FIELD_PATTERNS) + if (!sourceField || !targetField || sourceField === targetField) return null + + const startTimeField = findField(fieldNames, TRANSITION_START_PATTERNS) + const endTimeField = findField(fieldNames, TRANSITION_END_PATTERNS) + const valueField = findField(fieldNames, TRANSITION_VALUE_PATTERNS) + + // Validate: at least 3 rows must have both source and target with different, + // non-empty values. Guards against false positives on data where one of the + // matched fields happens to be present but isn't a transition signal. + const validRows: Datum[] = [] + for (const row of rows) { + if (!row) continue + const source = row[sourceField] + const target = row[targetField] + if (source == null || target == null) continue + const sourceStr = String(source).trim() + const targetStr = String(target).trim() + if (!sourceStr || !targetStr || sourceStr === targetStr) continue + validRows.push(row) + } + if (validRows.length < 3) return null + + // Build nodes (one per distinct source/target label) and edges (one per row, + // aggregating value across duplicates). + const nodes = new Map() + const edgeWeights = new Map() + const edgeMeta = new Map() + + for (const row of validRows) { + const sourceLabel = String(row[sourceField]).trim() + const targetLabel = String(row[targetField]).trim() + if (!nodes.has(sourceLabel)) nodes.set(sourceLabel, { id: sourceLabel, label: sourceLabel }) + if (!nodes.has(targetLabel)) nodes.set(targetLabel, { id: targetLabel, label: targetLabel }) + + const edgeKey = `${sourceLabel}->${targetLabel}` + const weight = valueField ? Number(row[valueField]) : 1 + const w = Number.isFinite(weight) ? weight : 1 + edgeWeights.set(edgeKey, (edgeWeights.get(edgeKey) ?? 0) + w) + + // Preserve the *first* row's timestamps for the edge — ProcessSankey reads + // startTime/endTime off each edge for its temporal layout. Aggregating + // weights across duplicates is correct; aggregating timestamps isn't. + if (!edgeMeta.has(edgeKey)) { + edgeMeta.set(edgeKey, { + source: sourceLabel, + target: targetLabel, + ...(startTimeField ? { startTime: row[startTimeField] } : {}), + ...(endTimeField ? { endTime: row[endTimeField] } : {}), + }) + } + } + + const edges: Datum[] = [] + for (const [key, meta] of edgeMeta) { + edges.push({ ...meta, value: edgeWeights.get(key) ?? 1 }) + } + + return { nodes: Array.from(nodes.values()), edges } +} + +export interface ProfileDataOptions { + /** If you have access to the raw input (which might be {nodes, edges} or GeoJSON), pass it for structure detection. */ + rawInput?: unknown + /** Override the field used as the primary series, useful when the heuristic guesses wrong. */ + seriesField?: string +} + +/** + * Build a ChartDataProfile from row data. Extends DataSummary with shape inference — + * candidate fields per role, distinct counts, monotonicity, and structure detection. + * + * Designed to be called once per dataset; the result is what `suggestCharts` and + * capability evaluators consume. + */ +export function profileData( + data: ReadonlyArray | null | undefined, + options: ProfileDataOptions = {} +): ChartDataProfile { + const summary = summarizeData(data ?? []) + const rows: ReadonlyArray = Array.isArray(data) ? data : [] + const structure = inferStructure(options.rawInput) + + // Transition-event detection: a flat array of rows with source/target fields + // is conceptually a network even though there's no {nodes, edges} payload. + // Derive one so flow charts (SankeyDiagram, ProcessSankey, ChordDiagram, + // ForceDirectedGraph) become viable on this data shape. Skip when rawInput + // already produced a structured network — that takes precedence. + if (!structure.hasNetwork && !structure.hasHierarchy && !structure.hasGeo) { + const transitionNet = detectTransitionNetwork(rows) + if (transitionNet) { + structure.hasNetwork = true + structure.network = transitionNet + } + } + + const xCandidates = rankCandidates(summary.fields, rows, NUMERIC_OR_TIME_FOR_X, X_FIELD_HINT, { computeMonotonic: true }) + const yCandidates = rankCandidates(summary.fields, rows, NUMERIC_FOR_Y, Y_FIELD_HINT) + const sizeCandidates = rankCandidates(summary.fields, rows, NUMERIC_LIKE_FOR_SIZE, SIZE_FIELD_HINT) + const categoryCandidates = rankCandidates(summary.fields, rows, CATEGORICAL_LIKE, CATEGORY_FIELD_HINT) + const seriesCandidates = rankCandidates(summary.fields, rows, CATEGORICAL_LIKE, SERIES_FIELD_HINT) + const timeCandidates = rankCandidates(summary.fields, rows, DATE_FOR_TIME, /(date|time|timestamp)/i, { computeMonotonic: true }) + + // x assignment proceeds in three tiers, each tagged so downstream logic + // can tell *how confident* we are that x is meaningful: + // • "time" — there's a date/time field; almost certainly the x axis + // • "named" — a numeric named like "month", "rank", "year"; high confidence + // • "scatter"— two+ numerics with no x-name signal; we pick one as a fallback + // The category/series disambiguation later uses this — when x is a scatter + // fallback, the lone categorical is more useful as `category` than `series`. + const time = timeCandidates[0]?.field + let x: string | undefined = time + let xProvenance: "time" | "named" | "scatter" | "none" = time ? "time" : "none" + if (!x) { + const xNamed = xCandidates.find((c) => X_FIELD_HINT.test(c.field) && c.kind === "numeric") + if (xNamed) { + x = xNamed.field + xProvenance = "named" + } + } + + // y: best numeric that isn't already x + let y: string | undefined = yCandidates.find((c) => c.field !== x)?.field + + // Scatter pattern: two+ numerics, no time-or-named x. + if (!x && y) { + const numericFields = Object.entries(summary.fields) + .filter(([_, s]) => s.type === "numeric") + .map(([k]) => k) + if (numericFields.length >= 2) { + x = numericFields.find((f) => f !== y) + if (x) xProvenance = "scatter" + } + } + + const size = sizeCandidates.find((c) => c.field !== x && c.field !== y)?.field + + // Category vs. series disambiguation. + // • Strong x (time/named): the lone categorical is the series (lineBy / stackBy). + // • Scatter-fallback x or no x: the lone categorical is the category — that's + // what enables BoxPlot/ViolinPlot/SwarmPlot on data like {id, value, cohort}. + const strongX = xProvenance === "time" || xProvenance === "named" + const categoricalList = categoryCandidates.map((c) => c.field) + let category: string | undefined + let series: string | undefined + if (strongX) { + series = options.seriesField ?? categoricalList[0] + category = categoricalList.find((f) => f !== series) + } else { + category = categoricalList[0] + series = options.seriesField ?? categoricalList.find((f) => f !== category) + } + + const categoryCount = category ? distinct(rows, category) : undefined + const seriesCount = series ? distinct(rows, series) : undefined + const uniqueXCount = x ? distinct(rows, x) : undefined + const hasRepeatedX = x ? hasRepeatedField(rows, x) : false + const monotonicX = xCandidates.find((c) => c.field === x)?.monotonic ?? false + const hasTimeAxis = timeCandidates.length > 0 + + return { + ...summary, + data: rows, + candidates: { + x: xCandidates, + y: yCandidates, + size: sizeCandidates, + category: categoryCandidates, + series: seriesCandidates, + time: timeCandidates, + }, + primary: { x, y, size, category, series, time }, + categoryCount, + seriesCount, + uniqueXCount, + hasRepeatedX, + monotonicX, + hasTimeAxis, + hasHierarchy: structure.hasHierarchy, + hasNetwork: structure.hasNetwork, + hasGeo: structure.hasGeo, + xProvenance, + network: structure.network, + hierarchy: structure.hierarchy, + geo: structure.geo, + } +} diff --git a/src/components/ai/qualityFixtures.ts b/src/components/ai/qualityFixtures.ts new file mode 100644 index 00000000..4515b369 --- /dev/null +++ b/src/components/ai/qualityFixtures.ts @@ -0,0 +1,395 @@ +import type { ScorecardFixture } from "./qualityScorecard" + +/** + * Canonical scorecard fixtures — the test set that descriptor tuning is + * measured against. Curated by hand. Each entry pairs a dataset with the + * intent the human expert would search by and the chart(s) the expert would + * pick. Stress-test fixtures (single-column, broken GeoJSON, etc.) set + * `expectsNoFit: true` to confirm the engine honestly rejects rather than + * forces a recommendation. + * + * To add a new fixture: keep it small (≤ ~50 rows), name it descriptively, + * pick the most-defensible expert answer. The scorecard tolerates the expert + * pick appearing anywhere in the top-3 — close-second behavior counts as + * agreement. + */ + +const monthlyRevenueMultiSeries = (() => { + const months = Array.from({ length: 12 }, (_, i) => i + 1) + const regions = ["EU", "NA", "APAC"] + return regions.flatMap((region, regionIdx) => + months.map((month) => ({ + month, + revenue: 800 + month * (200 + regionIdx * 40) + Math.sin(month) * 150, + region, + })), + ) +})() + +const monthlyRevenueOneSeries = Array.from({ length: 12 }, (_, i) => ({ + month: i + 1, + revenue: 1000 + i * 150 + Math.sin(i / 2) * 100, +})) + +const productSales = [ + { product: "Widget", units: 480 }, + { product: "Gadget", units: 620 }, + { product: "Sprocket", units: 290 }, + { product: "Whatsit", units: 740 }, + { product: "Doohickey", units: 410 }, +] + +const surveySatisfaction = Array.from({ length: 150 }, (_, i) => ({ + respondent_id: i + 1, + satisfaction: Math.max(1, Math.min(10, 6 + Math.sin(i / 7) * 2 + Math.random() * 3 - 1)), + cohort: ["Beta", "GA", "Enterprise"][i % 3], +})) + +const studyHoursVsGrade = Array.from({ length: 80 }, (_, i) => { + const hours = Math.max(0, Math.random() * 40) + return { + student_id: `s${i + 1}`, + hours, + grade: Math.min(100, hours * 1.8 + 30 + (Math.random() - 0.5) * 20), + } +}) + +const conversionFunnel = [ + { stage: "Visit", users: 10000 }, + { stage: "Signup", users: 2400 }, + { stage: "Trial", users: 1100 }, + { stage: "Paid", users: 380 }, +] + +const orgHierarchy = { + name: "Acme", + children: [ + { name: "Engineering", children: [ + { name: "Platform", value: 18 }, + { name: "Product", value: 22 }, + ]}, + { name: "Sales", children: [ + { name: "EMEA", value: 12 }, + { name: "AMER", value: 26 }, + ]}, + { name: "Ops", value: 9 }, + ], +} + +const transitionNetwork = { + nodes: [ + { id: "draft" }, { id: "review" }, { id: "approved" }, { id: "shipped" }, { id: "rejected" }, + ], + edges: [ + { source: "draft", target: "review", value: 100 }, + { source: "review", target: "approved", value: 60 }, + { source: "review", target: "rejected", value: 40 }, + { source: "approved", target: "shipped", value: 58 }, + ], +} + +const usGeoFeatures = { + type: "FeatureCollection", + features: [ + { type: "Feature", id: "CA", properties: { name: "California", value: 39 }, geometry: { type: "Polygon", coordinates: [[[-124,32],[-114,32],[-114,42],[-124,42],[-124,32]]] } }, + { type: "Feature", id: "TX", properties: { name: "Texas", value: 29 }, geometry: { type: "Polygon", coordinates: [[[-106,26],[-93,26],[-93,36],[-106,36],[-106,26]]] } }, + { type: "Feature", id: "NY", properties: { name: "New York", value: 19 }, geometry: { type: "Polygon", coordinates: [[[-79,40],[-72,40],[-72,45],[-79,45],[-79,40]]] } }, + ], +} + +const flatSingleColumn = Array.from({ length: 50 }, (_, i) => ({ + observation: 50 + Math.sin(i / 4) * 12 + Math.random() * 6, +})) + +// Three-numeric scatter — fixture for BubbleChart +const economiesByCountry = [ + { country: "USA", gdp_per_capita: 70, hours_worked: 1700, population_size: 330 }, + { country: "UK", gdp_per_capita: 48, hours_worked: 1500, population_size: 67 }, + { country: "Germany", gdp_per_capita: 53, hours_worked: 1330, population_size: 84 }, + { country: "Japan", gdp_per_capita: 40, hours_worked: 1600, population_size: 125 }, + { country: "France", gdp_per_capita: 45, hours_worked: 1480, population_size: 67 }, + { country: "Italy", gdp_per_capita: 38, hours_worked: 1700, population_size: 60 }, + { country: "Spain", gdp_per_capita: 32, hours_worked: 1640, population_size: 47 }, + { country: "Canada", gdp_per_capita: 52, hours_worked: 1690, population_size: 38 }, + { country: "Australia", gdp_per_capita: 56, hours_worked: 1700, population_size: 26 }, + { country: "South Korea", gdp_per_capita: 35, hours_worked: 1900, population_size: 52 }, +] + +// Multi-measure time series for MultiAxisLineChart +const websiteMetrics = Array.from({ length: 24 }, (_, i) => ({ + month: i + 1, + page_views: Math.round(50000 + i * 1200 + Math.sin(i / 3) * 8000), + conversion_rate: 2.5 + Math.sin(i / 4) * 0.8 + i * 0.05, + avg_session_seconds: Math.round(120 + i * 2 + Math.cos(i / 5) * 15), +})) + +// Categorical × series × value for GroupedBarChart / StackedBarChart +const salesByRegionAndProduct = [ + { product: "Widget", region: "EU", units: 480 }, + { product: "Widget", region: "NA", units: 620 }, + { product: "Widget", region: "APAC", units: 290 }, + { product: "Gadget", region: "EU", units: 320 }, + { product: "Gadget", region: "NA", units: 740 }, + { product: "Gadget", region: "APAC", units: 410 }, + { product: "Sprocket", region: "EU", units: 200 }, + { product: "Sprocket", region: "NA", units: 380 }, + { product: "Sprocket", region: "APAC", units: 150 }, + { product: "Whatsit", region: "EU", units: 290 }, + { product: "Whatsit", region: "NA", units: 550 }, + { product: "Whatsit", region: "APAC", units: 180 }, +] + +// Exactly-two-series temporal for DifferenceChart +const revenueVsExpenses = Array.from({ length: 24 }, (_, i) => ({ + month: i + 1, + amount: 100 + i * 8 + Math.sin(i / 3) * 25, + series: i % 2 === 0 ? "revenue" : "expenses", +})) +// Coerce to exactly-two-series shape by partitioning evenly +const revenueVsExpensesTwoSeries = [ + ...Array.from({ length: 24 }, (_, i) => ({ + month: i + 1, + amount: 100 + i * 8 + Math.sin(i / 3) * 25, + series: "revenue", + })), + ...Array.from({ length: 24 }, (_, i) => ({ + month: i + 1, + amount: 80 + i * 6 + Math.cos(i / 4) * 15, + series: "expenses", + })), +] + +// OHLC time series for CandlestickChart +const stockPrices = Array.from({ length: 30 }, (_, i) => { + const base = 100 + i * 1.2 + Math.sin(i / 4) * 8 + const open = base + (Math.random() - 0.5) * 4 + const close = base + (Math.random() - 0.5) * 4 + const high = Math.max(open, close) + Math.random() * 3 + const low = Math.min(open, close) - Math.random() * 3 + return { day: i + 1, open, high, low, close } +}) + +// Ordered-sequence scatter for ConnectedScatterplot +const usaUnemploymentVsInflation = Array.from({ length: 20 }, (_, i) => ({ + year: 2005 + i, + unemployment: 5 + Math.sin(i / 2) * 2 + (i > 4 && i < 10 ? 3 : 0), + inflation: 2 + Math.cos(i / 3) * 1.5, +})) + +const sparseThreeRow = [ + { name: "A", value: 12 }, + { name: "B", value: 34 }, + { name: "C", value: 8 }, +] + +// Flat array of transition events. The canonical input shape for SankeyDiagram / +// ProcessSankey / ChordDiagram / ForceDirectedGraph — should fit even though +// the data is rows, not a {nodes, edges} object. Exercises the +// detectTransitionNetwork path in profileData. +const transitionEvents = [ + { case: "deal-001", stage: "Inbound Lead", nextStage: "Qualified", startTime: "2024-04-01T09:00:00", value: 18 }, + { case: "deal-001", stage: "Qualified", nextStage: "Discovery", startTime: "2024-04-01T13:00:00", value: 16 }, + { case: "deal-001", stage: "Discovery", nextStage: "Proposal", startTime: "2024-04-02T11:00:00", value: 14 }, + { case: "deal-001", stage: "Proposal", nextStage: "Closed Won", startTime: "2024-04-04T09:00:00", value: 12 }, + { case: "deal-002", stage: "Inbound Lead", nextStage: "Qualified", startTime: "2024-04-01T10:00:00", value: 10 }, + { case: "deal-002", stage: "Qualified", nextStage: "Discovery", startTime: "2024-04-02T09:00:00", value: 9 }, + { case: "deal-002", stage: "Discovery", nextStage: "Proposal", startTime: "2024-04-03T09:00:00", value: 7 }, + { case: "deal-002", stage: "Proposal", nextStage: "Closed Lost", startTime: "2024-04-04T11:00:00", value: 5 }, + { case: "deal-003", stage: "Signup", nextStage: "Activated", startTime: "2024-04-01T08:30:00", value: 28 }, + { case: "deal-003", stage: "Activated", nextStage: "Trial", startTime: "2024-04-01T10:00:00", value: 24 }, + { case: "deal-003", stage: "Trial", nextStage: "Subscribed", startTime: "2024-04-02T10:00:00", value: 18 }, +] + +export const CANONICAL_FIXTURES: ReadonlyArray = [ + // Time-series family + { + name: "monthly revenue with regions, intent=trend", + shape: "12 months × 3 regions, numeric month, numeric revenue", + data: monthlyRevenueMultiSeries, + intent: "trend", + expected: ["LineChart", "AreaChart", "MinimapChart"], + }, + { + name: "monthly revenue with regions, intent=compare-series", + shape: "12 months × 3 regions", + data: monthlyRevenueMultiSeries, + intent: "compare-series", + expected: ["LineChart", "GroupedBarChart"], + }, + { + name: "monthly revenue with regions, intent=composition-over-time", + shape: "12 months × 3 regions, additive", + data: monthlyRevenueMultiSeries, + intent: "composition-over-time", + expected: ["StackedAreaChart", "StackedBarChart"], + }, + { + name: "monthly revenue single series, intent=trend", + shape: "12 months, no series", + data: monthlyRevenueOneSeries, + intent: "trend", + expected: ["LineChart", "AreaChart"], + }, + // Categorical family + { + name: "product sales, intent=rank", + shape: "5 products, single numeric measure", + data: productSales, + intent: "rank", + expected: ["BarChart", "DotPlot"], + }, + { + name: "product sales, intent=part-to-whole", + shape: "5 products, single numeric measure", + data: productSales, + intent: "part-to-whole", + expected: ["PieChart", "DonutChart", "BarChart"], + }, + // Distribution family + { + name: "satisfaction scores, intent=distribution", + shape: "150 numeric observations across 3 cohorts", + data: surveySatisfaction, + intent: "distribution", + expected: ["Histogram", "BoxPlot", "ViolinPlot"], + }, + { + name: "satisfaction scores, intent=compare-categories", + shape: "150 obs × 3 cohorts", + data: surveySatisfaction, + intent: "compare-categories", + expected: ["BoxPlot", "ViolinPlot", "SwarmPlot"], + }, + // Relationship family + { + name: "hours vs grade, intent=correlation", + shape: "80 students, hours + grade", + data: studyHoursVsGrade, + intent: "correlation", + expected: ["Scatterplot"], + }, + { + name: "hours vs grade, intent=outlier-detection", + shape: "80 students", + data: studyHoursVsGrade, + intent: "outlier-detection", + expected: ["Scatterplot"], + }, + // Flow family + { + name: "conversion funnel, intent=flow", + shape: "4 stages, descending values", + data: conversionFunnel, + intent: "flow", + expected: ["FunnelChart"], + }, + // Hierarchy family (rawInput payload) + { + name: "org chart, intent=hierarchy", + shape: "3-deep org tree", + data: [], + rawInput: orgHierarchy, + intent: "hierarchy", + expected: ["TreeDiagram", "Treemap", "CirclePack"], + }, + // Network family (rawInput payload) + { + name: "approval workflow transitions, intent=flow", + shape: "5 nodes / 4 weighted edges", + data: [], + rawInput: transitionNetwork, + intent: "flow", + expected: ["SankeyDiagram", "ChordDiagram"], + }, + // Geo family (rawInput payload) + { + name: "US states with values, intent=geo", + shape: "3 polygon features with numeric values", + data: [], + rawInput: usGeoFeatures, + intent: "geo", + expected: ["ChoroplethMap", "ProportionalSymbolMap"], + }, + + // Three-numeric scatter — exercises BubbleChart + { + name: "country economies, intent=correlation", + shape: "10 countries × 3 numeric measures (gdp, hours, population)", + data: economiesByCountry, + intent: "correlation", + expected: ["Scatterplot", "BubbleChart"], + }, + // Multi-measure time-series — exercises MultiAxisLineChart + { + name: "website metrics with 3 measures, intent=compare-series", + shape: "24 months × 3 numeric measures with different ranges", + data: websiteMetrics, + intent: "compare-series", + expected: ["MultiAxisLineChart", "LineChart"], + }, + // Category × series × value — exercises GroupedBarChart / StackedBarChart + { + name: "sales by region and product, intent=compare-series", + shape: "12 rows = 4 products × 3 regions", + data: salesByRegionAndProduct, + intent: "compare-series", + expected: ["GroupedBarChart", "StackedBarChart"], + }, + { + name: "sales by region and product, intent=part-to-whole", + shape: "12 rows = 4 products × 3 regions", + data: salesByRegionAndProduct, + intent: "part-to-whole", + expected: ["StackedBarChart", "PieChart"], + }, + // Exactly-two-series temporal — exercises DifferenceChart + { + name: "revenue vs expenses, intent=compare-series", + shape: "48 rows = 24 months × 2 series", + data: revenueVsExpensesTwoSeries, + intent: "compare-series", + expected: ["DifferenceChart", "LineChart", "GroupedBarChart"], + }, + // OHLC — exercises CandlestickChart + { + name: "stock OHLC prices, intent=change-detection", + shape: "30 days × open/high/low/close", + data: stockPrices, + intent: "change-detection", + expected: ["CandlestickChart", "LineChart"], + }, + // Ordered-sequence scatter — exercises ConnectedScatterplot + { + name: "unemployment vs inflation by year, intent=correlation", + shape: "20 years × 2 measures, ordered by year", + data: usaUnemploymentVsInflation, + intent: "correlation", + expected: ["ConnectedScatterplot", "Scatterplot"], + }, + + // Transition events — flat array of edges with stage/nextStage/startTime/value. + // Should be auto-derived into a network so flow charts fit. + { + name: "transition events, intent=flow", + shape: "11 stage transitions across 3 deals with startTime + value", + data: transitionEvents, + intent: "flow", + expected: ["SankeyDiagram", "ProcessSankey", "ChordDiagram"], + }, + + // Stress fixtures — expect no fitting chart for these. + { + name: "flat single column", + shape: "50 rows, one numeric column", + data: flatSingleColumn, + // intentionally no intent — we want the engine to refuse this whole class. + expected: ["Histogram"], // a histogram is genuinely the best (only) fit here + }, + { + name: "sparse 3-row data, intent=rank", + shape: "3 rows total", + data: sparseThreeRow, + intent: "rank", + expected: ["BarChart", "DotPlot"], + }, +] diff --git a/src/components/ai/qualityScorecard.test.ts b/src/components/ai/qualityScorecard.test.ts new file mode 100644 index 00000000..d90e3345 --- /dev/null +++ b/src/components/ai/qualityScorecard.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest" +import { runQualityScorecard } from "./qualityScorecard" +import { CANONICAL_FIXTURES } from "./qualityFixtures" + +describe("runQualityScorecard", () => { + it("returns a report covering every fixture", () => { + const report = runQualityScorecard(CANONICAL_FIXTURES) + expect(report.summary.fixtureCount).toBe(CANONICAL_FIXTURES.length) + expect(report.perFixture.length).toBe(CANONICAL_FIXTURES.length) + expect(report.perCapability.length).toBeGreaterThan(0) + }) + + it("expert agreement rate stays above 90% across the canonical set", () => { + // Phase 2.1 tuning landed expert agreement at 100% on 23 fixtures. + // Below 90% means a descriptor regressed; below 80% means urgent work. + // This gate is intentionally tight — the canonical set is curated and + // the engine should win on all of it. + const report = runQualityScorecard(CANONICAL_FIXTURES) + expect(report.summary.expertAgreementRate).toBeGreaterThanOrEqual(0.9) + }) + + it("emits per-capability tallies for every registered chart", () => { + const report = runQualityScorecard(CANONICAL_FIXTURES) + const names = new Set(report.perCapability.map((c) => c.component)) + expect(names.has("LineChart")).toBe(true) + expect(names.has("BarChart")).toBe(true) + expect(names.has("Histogram")).toBe(true) + }) + + it("ranks capabilities with zero expert agreement first", () => { + const report = runQualityScorecard(CANONICAL_FIXTURES) + // perCapability is sorted by expertAgreementCount ascending + const counts = report.perCapability.map((c) => c.expertAgreementCount) + for (let i = 1; i < counts.length; i++) { + expect(counts[i]).toBeGreaterThanOrEqual(counts[i - 1]) + } + }) + + it("doesn't crash on the sparse-data fixture", () => { + const sparse = CANONICAL_FIXTURES.find((f) => f.name.includes("sparse")) + expect(sparse).toBeDefined() + if (sparse) { + const report = runQualityScorecard([sparse]) + expect(report.perFixture[0]).toBeDefined() + } + }) +}) diff --git a/src/components/ai/qualityScorecard.ts b/src/components/ai/qualityScorecard.ts new file mode 100644 index 00000000..3bac79e8 --- /dev/null +++ b/src/components/ai/qualityScorecard.ts @@ -0,0 +1,228 @@ +import type { Datum } from "../charts/shared/datumTypes" +import { getCapabilities } from "./chartCapabilities" +import { profileData } from "./profileData" +import { explainCapabilityFit } from "./suggestCharts" +import type { ChartCapability, ChartDataProfile } from "./chartCapabilityTypes" +import type { IntentId } from "./intents" + +/** + * One canonical fixture in a scorecard run. Pair canonical data with the + * intents/components a human expert would expect to win on it. Use null + * `expected` when the fixture is a stress-test that should produce no + * fitting chart at all (e.g. flat single-column data, broken GeoJSON). + */ +export interface ScorecardFixture { + name: string + /** Free-text shape description, used in scorecard output for context. */ + shape?: string + data: ReadonlyArray + /** Optional non-tabular payload (network/hierarchy/GeoJSON). */ + rawInput?: unknown + /** Intent to rank by. If omitted, scored without intent (mean-of-all). */ + intent?: IntentId + /** Components the human expert would pick. Empty = "anything fits". */ + expected?: ReadonlyArray + /** True if the fixture should produce zero fitting suggestions. Mutually exclusive with `expected`. */ + expectsNoFit?: boolean +} + +export interface PerCapabilityScore { + component: string + family: ChartCapability["family"] + /** Number of fixtures where this capability fit. */ + fitsOn: number + /** Number of fixtures where this capability was rejected. */ + rejectedOn: number + /** Number of fixtures where this capability appeared in the top-3 ranked suggestions. */ + inTopThreeOn: number + /** Fixtures where the human expert picked this chart AND it was in top-3 ranking. */ + expertAgreementCount: number + /** Mean composite score across fixtures where it fit. */ + averageScore: number + /** Fraction of suggestions that included at least one caveat. */ + caveatCoverage: number + /** Fraction of suggestions that picked a non-base variant. */ + variantUtilization: number +} + +export interface PerFixtureScore { + fixture: string + shape?: string + intent?: IntentId + expected?: ReadonlyArray + topPick?: { component: string; variantKey?: string; score: number } + topThree: ReadonlyArray<{ component: string; variantKey?: string; score: number }> + fittingCount: number + rejectedCount: number + /** True if the top-3 ranking contained at least one expected component (when expected is provided). */ + expertAgreement: boolean | null + /** Did the engine honor `expectsNoFit`? */ + noFitHonored: boolean | null +} + +export interface ScorecardReport { + perCapability: PerCapabilityScore[] + perFixture: PerFixtureScore[] + summary: { + fixtureCount: number + capabilityCount: number + /** Fraction of expectation-bearing fixtures where the engine agreed with the expert. */ + expertAgreementRate: number + /** Average caveat coverage across all suggestions. */ + overallCaveatCoverage: number + /** Average variant utilization across all suggestions. */ + overallVariantUtilization: number + } +} + +/** + * Run the scorecard. Pure — does no I/O — so it can be called from CI scripts, + * vizmart UIs, or test suites. + */ +export function runQualityScorecard( + fixtures: ReadonlyArray, + capabilities: ReadonlyArray = getCapabilities(), +): ScorecardReport { + const perCapability = new Map() + for (const c of capabilities) { + perCapability.set(c.component, { + component: c.component, + family: c.family, + fitsOn: 0, + rejectedOn: 0, + inTopThreeOn: 0, + expertAgreementCount: 0, + averageScore: 0, + caveatCoverage: 0, + variantUtilization: 0, + }) + } + + // Running tallies for averaging + const scoreSums = new Map() + const suggestionCount = new Map() + const caveatCount = new Map() + const variantCount = new Map() + + const perFixture: PerFixtureScore[] = [] + + for (const fixture of fixtures) { + let profile: ChartDataProfile + let result: ReturnType + try { + profile = profileData(fixture.data, { rawInput: fixture.rawInput }) + result = explainCapabilityFit(fixture.data, { + profile, + intent: fixture.intent, + capabilities, + maxResults: 40, + }) + } catch (err) { + // A descriptor crashed on this fixture — flag it. + perFixture.push({ + fixture: fixture.name, + shape: fixture.shape, + intent: fixture.intent, + expected: fixture.expected, + topPick: undefined, + topThree: [], + fittingCount: 0, + rejectedCount: 0, + expertAgreement: false, + noFitHonored: null, + }) + continue + } + + const topThree = result.fitting.slice(0, 3).map((s) => ({ + component: s.component, + variantKey: s.variant?.key, + score: s.score, + })) + + const expertAgreement = fixture.expected && fixture.expected.length > 0 + ? topThree.some((t) => fixture.expected!.includes(t.component)) + : null + + const noFitHonored = fixture.expectsNoFit === true + ? result.fitting.length === 0 + : null + + perFixture.push({ + fixture: fixture.name, + shape: fixture.shape, + intent: fixture.intent, + expected: fixture.expected, + topPick: topThree[0], + topThree, + fittingCount: result.fitting.length, + rejectedCount: result.rejected.length, + expertAgreement, + noFitHonored, + }) + + // Tally per-capability stats + for (const s of result.fitting) { + const row = perCapability.get(s.component) + if (!row) continue + row.fitsOn += 1 + scoreSums.set(s.component, (scoreSums.get(s.component) ?? 0) + s.score) + suggestionCount.set(s.component, (suggestionCount.get(s.component) ?? 0) + 1) + if (s.caveats.length > 0) caveatCount.set(s.component, (caveatCount.get(s.component) ?? 0) + 1) + if (s.variant) variantCount.set(s.component, (variantCount.get(s.component) ?? 0) + 1) + } + for (const r of result.rejected) { + const row = perCapability.get(r.component) + if (row) row.rejectedOn += 1 + } + for (const t of topThree) { + const row = perCapability.get(t.component) + if (row) row.inTopThreeOn += 1 + } + if (fixture.expected && expertAgreement) { + for (const t of topThree) { + if (fixture.expected.includes(t.component)) { + const row = perCapability.get(t.component) + if (row) row.expertAgreementCount += 1 + } + } + } + } + + // Finalize averages + for (const row of perCapability.values()) { + const count = suggestionCount.get(row.component) ?? 0 + row.averageScore = count === 0 ? 0 : (scoreSums.get(row.component) ?? 0) / count + row.caveatCoverage = count === 0 ? 0 : (caveatCount.get(row.component) ?? 0) / count + row.variantUtilization = count === 0 ? 0 : (variantCount.get(row.component) ?? 0) / count + } + + // Sort: lowest expertAgreementCount first so weak descriptors surface first. + // Ties broken by fitsOn (higher = more chances to demonstrate value). + const perCapabilitySorted = Array.from(perCapability.values()).sort((a, b) => { + const expertDelta = a.expertAgreementCount - b.expertAgreementCount + if (expertDelta !== 0) return expertDelta + return b.fitsOn - a.fitsOn + }) + + const fixturesWithExpectations = perFixture.filter((f) => f.expertAgreement !== null) + const expertAgreementRate = fixturesWithExpectations.length === 0 + ? 0 + : fixturesWithExpectations.filter((f) => f.expertAgreement === true).length / fixturesWithExpectations.length + + const allSuggestionCount = Array.from(suggestionCount.values()).reduce((a, b) => a + b, 0) + const allCaveatCount = Array.from(caveatCount.values()).reduce((a, b) => a + b, 0) + const allVariantCount = Array.from(variantCount.values()).reduce((a, b) => a + b, 0) + + return { + perCapability: perCapabilitySorted, + perFixture, + summary: { + fixtureCount: fixtures.length, + capabilityCount: capabilities.length, + expertAgreementRate, + overallCaveatCoverage: allSuggestionCount === 0 ? 0 : allCaveatCount / allSuggestionCount, + overallVariantUtilization: allSuggestionCount === 0 ? 0 : allVariantCount / allSuggestionCount, + }, + } +} diff --git a/src/components/ai/repairChartConfig.test.ts b/src/components/ai/repairChartConfig.test.ts new file mode 100644 index 00000000..3216e1f8 --- /dev/null +++ b/src/components/ai/repairChartConfig.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest" +import { repairChartConfig } from "./repairChartConfig" + +const productSales = [ + { product: "Widget", units: 480 }, + { product: "Gadget", units: 620 }, + { product: "Sprocket", units: 290 }, + { product: "Whatsit", units: 740 }, + { product: "Doohickey", units: 410 }, + { product: "Gizmo", units: 200 }, + { product: "Thingamajig", units: 320 }, + { product: "Item-8", units: 110 }, + { product: "Item-9", units: 90 }, + { product: "Item-10", units: 75 }, +] + +const temporal = Array.from({ length: 12 }, (_, i) => ({ + month: i + 1, + revenue: 1000 + i * 120 + Math.sin(i) * 80, +})) + +describe("repairChartConfig", () => { + it("returns ok when the chart fits", () => { + const result = repairChartConfig("BarChart", productSales.slice(0, 5)) + expect(result.status).toBe("ok") + if (result.status === "ok") { + expect(result.component).toBe("BarChart") + } + }) + + it("proposes alternatives when the chart doesn't fit", () => { + // PieChart can't handle 10 categories + const result = repairChartConfig("PieChart", productSales, { intent: "rank" }) + expect(result.status).toBe("alternative") + if (result.status === "alternative") { + expect(result.reason).toMatch(/slices/) + expect(result.alternatives.length).toBeGreaterThan(0) + // BarChart or DotPlot should be the strongest replacement for rank + expect(["BarChart", "DotPlot"]).toContain(result.alternatives[0].component) + } + }) + + it("excludes the requested component from alternatives", () => { + const result = repairChartConfig("StackedBarChart", productSales) + expect(result.status).toBe("alternative") + if (result.status === "alternative") { + for (const alt of result.alternatives) { + expect(alt.component).not.toBe("StackedBarChart") + } + } + }) + + it("returns unknown for components without a registered capability", () => { + const result = repairChartConfig("NotARealChart", temporal, { intent: "trend" }) + expect(result.status).toBe("unknown") + if (result.status === "unknown") { + expect(result.alternatives.length).toBeGreaterThan(0) + // Top alt for trend on temporal should be LineChart + expect(result.alternatives[0].component).toBe("LineChart") + } + }) + + it("includes profile in every result for caller inspection", () => { + const result = repairChartConfig("PieChart", productSales) + expect(result.profile).toBeDefined() + expect(result.profile.rowCount).toBe(productSales.length) + }) + + it("alternatives carry runnable props", () => { + const result = repairChartConfig("PieChart", productSales, { intent: "rank" }) + if (result.status === "alternative") { + const top = result.alternatives[0] + expect(top.props).toBeDefined() + expect(top.props.data).toBeDefined() + } + }) +}) diff --git a/src/components/ai/repairChartConfig.ts b/src/components/ai/repairChartConfig.ts new file mode 100644 index 00000000..9948bb39 --- /dev/null +++ b/src/components/ai/repairChartConfig.ts @@ -0,0 +1,122 @@ +import type { Datum } from "../charts/shared/datumTypes" +import { getCapability } from "./chartCapabilities" +import { profileData } from "./profileData" +import { suggestCharts } from "./suggestCharts" +import type { ChartDataProfile, Suggestion } from "./chartCapabilityTypes" +import type { IntentId } from "./intents" + +/** + * Repair result when the chosen chart fits the data — nothing to fix. + */ +export interface RepairOkResult { + status: "ok" + component: string + /** The same data profile that was evaluated. */ + profile: ChartDataProfile +} + +/** + * Repair result when the chosen chart doesn't fit. Carries the diagnostic + * reason from the capability's `fits()` plus ranked alternatives that *do* + * fit, with their reasons surfaced for caller narration. + */ +export interface RepairAlternativeResult { + status: "alternative" + /** The component the caller asked about. */ + component: string + /** Why it doesn't fit. */ + reason: string + /** Whether the caller intended one of the alternatives anyway. */ + alternatives: Suggestion[] + profile: ChartDataProfile +} + +/** + * Repair result when no capability is registered for the asked component. + */ +export interface RepairUnknownResult { + status: "unknown" + component: string + /** Closest matches by family/intent — best effort. */ + alternatives: Suggestion[] + profile: ChartDataProfile +} + +export type RepairResult = RepairOkResult | RepairAlternativeResult | RepairUnknownResult + +export interface RepairOptions { + /** Caller's intent — informs ranking of alternatives when the chart doesn't fit. */ + intent?: IntentId | IntentId[] + /** Non-tabular payload (network/hierarchy/GeoJSON). Forwarded to profileData. */ + rawInput?: unknown + /** Limit number of alternatives returned (default 3). */ + maxAlternatives?: number + /** Pre-computed profile, avoids recomputation. */ + profile?: ChartDataProfile +} + +/** + * Validate that a chart component is a sensible choice for a dataset, and + * if not, propose alternatives that *do* fit — ranked by the caller's + * intent if provided. + * + * This is the "auto-fix" surface for `--doctor` and agent retry loops. + * Given a chart + data, returns either: + * + * - { status: "ok", component } — the chart fits, ship it + * - { status: "alternative", reason, alternatives } — the chart doesn't + * fit; here are charts that do, ranked by intent if specified + * - { status: "unknown", alternatives } — we don't have a + * capability for that component name; here are sensible defaults + * + * The contract: a caller can always render `alternatives[0]` and get + * something useful. The `reason` field is suitable for verbatim display + * to the user. + * + * @example + * repairChartConfig("PieChart", productData, { intent: "rank" }) + * // → { status: "alternative", + * // reason: "9 slices is too many for a pie chart", + * // alternatives: [BarChart, DotPlot, ...] } + */ +export function repairChartConfig( + component: string, + data: ReadonlyArray | null | undefined, + options: RepairOptions = {}, +): RepairResult { + const profile = options.profile ?? profileData(data ?? [], { rawInput: options.rawInput }) + const capability = getCapability(component) + const maxAlternatives = options.maxAlternatives ?? 3 + + if (!capability) { + // Unknown component — return top suggestions as best-effort fallbacks + const alternatives = suggestCharts(data, { + profile, + intent: options.intent, + maxResults: maxAlternatives, + includeVariants: false, + }) + return { status: "unknown", component, alternatives, profile } + } + + const fitReason = capability.fits(profile) + if (fitReason === null) { + return { status: "ok", component, profile } + } + + const alternatives = suggestCharts(data, { + profile, + intent: options.intent, + maxResults: maxAlternatives, + deny: [component], // don't recommend the one that already failed + includeVariants: false, + }) + + return { + status: "alternative", + component, + reason: fitReason, + alternatives, + profile, + } +} diff --git a/src/components/ai/streamingTypes.ts b/src/components/ai/streamingTypes.ts new file mode 100644 index 00000000..ce2e957f --- /dev/null +++ b/src/components/ai/streamingTypes.ts @@ -0,0 +1,73 @@ +import type { ChartRubric } from "./chartCapabilityTypes" +import type { IntentId } from "./intents" + +/** + * Streaming chart selection has a different shape than static. We don't have + * rows yet — we have a *schema*: which fields will arrive, what types, plus + * environment hints (throughput, retention). + * + * Rather than overloading `profileData` (which is row-statistics-centric) we + * model streams as a parallel API. The two share the intent vocabulary — + * "trend" still means trend — but the suitability logic is its own thing. + */ + +export type StreamFieldKind = "numeric" | "categorical" | "date" | "boolean" + +export interface StreamFieldSchema { + name: string + kind: StreamFieldKind + /** Optional role hint — overrides the engine's inference. */ + role?: "x" | "y" | "value" | "category" | "series" | "size" +} + +/** + * Schema describing what a stream emits. No data, just shape + environment hints. + */ +export interface StreamSchema { + fields: ReadonlyArray + /** + * Hint about expected event rate. Affects chart selection — heatmaps and + * waterfalls amortize high-throughput streams better than line charts do. + * • "low" — < 1 event/sec, line/area charts read well + * • "medium" — ~1-100 events/sec + * • "high" — > 100 events/sec, prefer aggregating visualizations + */ + throughput?: "low" | "medium" | "high" + /** + * Hint about how long events are kept in view. + * • "windowed" — only recent events visible (default) + * • "cumulative" — all events accumulate + */ + retention?: "windowed" | "cumulative" +} + +/** + * Stream capability descriptor — parallel to ChartCapability but operates on + * a schema. No `fits(profile)`; instead `fits(schema)` returns null/reason. + */ +export interface StreamChartCapability { + component: string + importPath: "semiotic/realtime" + rubric: ChartRubric + fits: (schema: StreamSchema) => null | string + intentScores: Partial> + caveats?: (schema: StreamSchema) => ReadonlyArray + buildProps: (schema: StreamSchema) => Record +} + +export type StreamIntentScorer = + | number + | ((schema: StreamSchema) => number) + +export interface StreamSuggestion { + component: string + family: "realtime" + importPath: "semiotic/realtime" + score: number + intentScores: Partial> + rubric: ChartRubric + reasons: ReadonlyArray + caveats: ReadonlyArray + /** Props ready to spread into the matching realtime chart. */ + props: Record +} diff --git a/src/components/ai/suggestCharts.test.ts b/src/components/ai/suggestCharts.test.ts new file mode 100644 index 00000000..cde017ca --- /dev/null +++ b/src/components/ai/suggestCharts.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from "vitest" +import { suggestCharts, scoreChart, explainCapabilityFit } from "./suggestCharts" +import { registerChartCapability, unregisterChartCapability } from "./chartCapabilities" +import type { ChartCapability } from "./chartCapabilityTypes" + +const temporalMultiSeries = [ + { month: 1, revenue: 1200, region: "EU" }, + { month: 2, revenue: 1400, region: "EU" }, + { month: 3, revenue: 1100, region: "EU" }, + { month: 4, revenue: 1700, region: "EU" }, + { month: 5, revenue: 1900, region: "EU" }, + { month: 1, revenue: 900, region: "NA" }, + { month: 2, revenue: 1100, region: "NA" }, + { month: 3, revenue: 1500, region: "NA" }, + { month: 4, revenue: 1300, region: "NA" }, + { month: 5, revenue: 1700, region: "NA" }, +] + +const categorical = [ + { product: "Widget", units: 30 }, + { product: "Gadget", units: 50 }, + { product: "Sprocket", units: 20 }, + { product: "Whatsit", units: 45 }, +] + +const distributionData = Array.from({ length: 100 }, (_, i) => ({ + observation: 50 + Math.sin(i / 7) * 20 + (i % 3 === 0 ? 30 : 0), +})) + +describe("suggestCharts", () => { + it("ranks LineChart highly for temporal multi-series with intent=trend", () => { + const suggestions = suggestCharts(temporalMultiSeries, { intent: "trend", includeVariants: false }) + expect(suggestions.length).toBeGreaterThan(0) + expect(suggestions[0].component).toBe("LineChart") + expect(suggestions[0].score).toBeGreaterThan(3) + }) + + it("ranks BarChart highly for categorical with intent=rank", () => { + const suggestions = suggestCharts(categorical, { intent: "rank", includeVariants: false }) + expect(suggestions[0].component).toBe("BarChart") + expect(suggestions[0].props.categoryAccessor).toBe("product") + expect(suggestions[0].props.valueAccessor).toBe("units") + }) + + it("ranks Histogram highly for distribution intent", () => { + const suggestions = suggestCharts(distributionData, { intent: "distribution", includeVariants: false }) + expect(suggestions[0].component).toBe("Histogram") + }) + + it("filters by allow list", () => { + const suggestions = suggestCharts(temporalMultiSeries, { allow: ["AreaChart"], includeVariants: false }) + expect(suggestions.every((s) => s.component === "AreaChart")).toBe(true) + }) + + it("emits variants by default", () => { + const suggestions = suggestCharts(temporalMultiSeries, { intent: "trend" }) + const lineVariants = suggestions.filter((s) => s.component === "LineChart" && s.variant) + expect(lineVariants.length).toBeGreaterThan(0) + }) + + it("smooth variant boosts trend score relative to base for LineChart", () => { + const suggestions = suggestCharts(temporalMultiSeries, { intent: "trend", allow: ["LineChart"] }) + const base = suggestions.find((s) => s.variant?.key === "linear") + const smooth = suggestions.find((s) => s.variant?.key === "smooth") + expect(base).toBeDefined() + expect(smooth).toBeDefined() + expect((smooth!.score)).toBeGreaterThanOrEqual(base!.score) + }) + + it("excludes PieChart when there are too many categories", () => { + const tooManyCategories = Array.from({ length: 15 }, (_, i) => ({ name: `Cat${i}`, count: i + 1 })) + const suggestions = suggestCharts(tooManyCategories) + expect(suggestions.find((s) => s.component === "PieChart")).toBeUndefined() + }) + + it("excludes StackedBarChart when there is no series field", () => { + const suggestions = suggestCharts(categorical) + expect(suggestions.find((s) => s.component === "StackedBarChart")).toBeUndefined() + }) + + it("buildProps returns runnable accessor configuration", () => { + const suggestions = suggestCharts(temporalMultiSeries, { intent: "trend", allow: ["LineChart"], includeVariants: false }) + const top = suggestions[0] + expect(top.props.xAccessor).toBe("month") + expect(top.props.yAccessor).toBe("revenue") + expect(top.props.lineBy).toBe("region") + expect(top.props.colorBy).toBe("region") + }) + + it("respects user-registered capabilities", () => { + const fake: ChartCapability = { + component: "MyCustomChart", + family: "custom", + importPath: "semiotic", + rubric: { familiarity: 1, accuracy: 5, precision: 5 }, + fits: () => null, + intentScores: { "trend": 5 }, + buildProps: () => ({ custom: true }), + } + registerChartCapability(fake) + try { + const suggestions = suggestCharts(temporalMultiSeries, { allow: ["MyCustomChart"] }) + expect(suggestions[0].component).toBe("MyCustomChart") + } finally { + unregisterChartCapability("MyCustomChart") + } + }) +}) + +describe("suggestCharts — structural shapes", () => { + it("recommends ForceDirectedGraph for {nodes, edges}", () => { + const network = { + nodes: [{ id: "a" }, { id: "b" }, { id: "c" }], + edges: [ + { source: "a", target: "b" }, + { source: "b", target: "c" }, + ], + } + const suggestions = suggestCharts([], { rawInput: network, allow: ["ForceDirectedGraph", "SankeyDiagram", "ChordDiagram"] }) + expect(suggestions.length).toBeGreaterThan(0) + expect(["network", "flow"]).toContain(suggestions[0].family) + expect((suggestions[0].props.nodes as unknown[]).length).toBe(3) + }) + + it("recommends Treemap/TreeDiagram for hierarchies", () => { + const hierarchy = { + name: "root", + children: [ + { name: "a", value: 10 }, + { name: "b", value: 20, children: [{ name: "b1", value: 5 }] }, + ], + } + const suggestions = suggestCharts([], { rawInput: hierarchy, intent: "hierarchy" }) + expect(suggestions.some((s) => s.family === "hierarchy")).toBe(true) + }) + + it("recommends ChoroplethMap for GeoJSON", () => { + const geo = { + type: "FeatureCollection", + features: [ + { type: "Feature", geometry: { type: "Polygon", coordinates: [] }, properties: { value: 5 } }, + { type: "Feature", geometry: { type: "Polygon", coordinates: [] }, properties: { value: 10 } }, + ], + } + const suggestions = suggestCharts([], { rawInput: geo, intent: "geo" }) + expect(suggestions.some((s) => s.component === "ChoroplethMap")).toBe(true) + }) +}) + +describe("explainCapabilityFit", () => { + it("returns both fitting and rejected capabilities", () => { + const { fitting, rejected, profile } = explainCapabilityFit(categorical) + expect(fitting.length).toBeGreaterThan(0) + expect(rejected.length).toBeGreaterThan(0) + // BarChart should fit categorical data; StackedBarChart should be rejected + expect(fitting.some((s) => s.component === "BarChart")).toBe(true) + expect(rejected.some((r) => r.component === "StackedBarChart")).toBe(true) + expect(profile.rowCount).toBe(categorical.length) + }) + + it("rejection reasons are human-readable strings", () => { + const { rejected } = explainCapabilityFit(categorical) + for (const r of rejected) { + expect(typeof r.reason).toBe("string") + expect(r.reason.length).toBeGreaterThan(0) + } + }) + + it("respects allow/deny lists", () => { + const { fitting, rejected } = explainCapabilityFit(categorical, { + allow: ["BarChart", "Histogram", "DotPlot"], + }) + for (const s of fitting) expect(["BarChart", "Histogram", "DotPlot"]).toContain(s.component) + for (const r of rejected) expect(["BarChart", "Histogram", "DotPlot"]).toContain(r.component) + }) + + it("rejection set + fitting set is disjoint", () => { + const { fitting, rejected } = explainCapabilityFit(temporalMultiSeries) + const fittingNames = new Set(fitting.map((s) => s.component)) + for (const r of rejected) { + expect(fittingNames.has(r.component)).toBe(false) + } + }) +}) + +describe("scoreChart", () => { + it("returns a suggestion for a fitting chart", () => { + const result = scoreChart("LineChart", temporalMultiSeries, { intent: "trend" }) + expect("score" in result).toBe(true) + if ("score" in result) { + expect(result.score).toBeGreaterThan(3) + expect(result.props.xAccessor).toBe("month") + } + }) + + it("returns a reason when the chart doesn't fit", () => { + const result = scoreChart("StackedBarChart", categorical) + expect("reason" in result).toBe(true) + }) + + it("returns a reason for unknown components", () => { + const result = scoreChart("DoesNotExist", categorical) + expect("reason" in result).toBe(true) + }) +}) diff --git a/src/components/ai/suggestCharts.ts b/src/components/ai/suggestCharts.ts new file mode 100644 index 00000000..e8dedd21 --- /dev/null +++ b/src/components/ai/suggestCharts.ts @@ -0,0 +1,312 @@ +import type { Datum } from "../charts/shared/datumTypes" +import { profileData, type ProfileDataOptions } from "./profileData" +import type { + ChartCapability, + ChartDataProfile, + ChartRubric, + ChartVariant, + IntentScorer, + Suggestion, +} from "./chartCapabilityTypes" +import type { IntentId } from "./intents" +import { getCapabilities } from "./chartCapabilities" +import { applyAudienceBias, type AudienceProfile } from "./audienceProfile" + +function score(scorer: IntentScorer | undefined, profile: ChartDataProfile): number { + if (scorer === undefined) return 0 + const raw = typeof scorer === "function" ? scorer(profile) : scorer + if (!Number.isFinite(raw)) return 0 + return Math.max(0, Math.min(5, raw)) +} + +function clampRubric(r: ChartRubric): ChartRubric { + const clamp = (n: number) => Math.max(1, Math.min(5, Math.round(n))) + return { familiarity: clamp(r.familiarity), accuracy: clamp(r.accuracy), precision: clamp(r.precision) } +} + +function applyVariantToScores( + baseScores: Partial>, + variant: ChartVariant | undefined +): Partial> { + if (!variant?.intentDeltas) return baseScores + const out: Partial> = { ...baseScores } + for (const [intent, delta] of Object.entries(variant.intentDeltas) as Array<[IntentId, number]>) { + const current = out[intent] ?? 0 + out[intent] = Math.max(0, Math.min(5, current + delta)) + } + return out +} + +function applyVariantToRubric(rubric: ChartRubric, variant: ChartVariant | undefined): ChartRubric { + if (!variant?.rubricDeltas) return rubric + return clampRubric({ + familiarity: rubric.familiarity + (variant.rubricDeltas.familiarity ?? 0), + accuracy: rubric.accuracy + (variant.rubricDeltas.accuracy ?? 0), + precision: rubric.precision + (variant.rubricDeltas.precision ?? 0), + }) +} + +function buildReasons( + capability: ChartCapability, + profile: ChartDataProfile, + intentScores: Partial>, + rankingIntents: IntentId[] +): string[] { + const reasons: string[] = [] + const top = rankingIntents + .map((intent) => ({ intent, score: intentScores[intent] ?? 0 })) + .filter((entry) => entry.score >= 3) + .sort((a, b) => b.score - a.score) + .slice(0, 2) + for (const { intent, score } of top) { + reasons.push(`Strong fit for ${intent} (${score}/5)`) + } + if (profile.primary.x && profile.primary.y) { + reasons.push(`x = ${profile.primary.x}, y = ${profile.primary.y}`) + } + if (profile.seriesCount && profile.seriesCount > 1) { + reasons.push(`${profile.seriesCount} series detected on field "${profile.primary.series ?? "series"}"`) + } + return reasons +} + +function compositeScore( + intentScores: Partial>, + rankingIntents: IntentId[] +): number { + if (rankingIntents.length === 0) { + // No intent specified — use mean of non-zero scores across all intents + const nonZero = Object.values(intentScores).filter((n): n is number => typeof n === "number" && n > 0) + if (nonZero.length === 0) return 0 + return nonZero.reduce((a, b) => a + b, 0) / nonZero.length + } + // Average the requested intents + let sum = 0 + for (const intent of rankingIntents) sum += intentScores[intent] ?? 0 + return sum / rankingIntents.length +} + +export interface SuggestChartsOptions extends ProfileDataOptions { + /** Ranking intent(s). When omitted, suggestions are ranked by mean intent score. */ + intent?: IntentId | IntentId[] + /** Restrict to these component names. */ + allow?: ReadonlyArray + /** Exclude these component names. */ + deny?: ReadonlyArray + /** Maximum suggestions to return (default 10). */ + maxResults?: number + /** Include variant-level suggestions (default true). */ + includeVariants?: boolean + /** Filter out suggestions with a composite score below this (default 0 — keep all). */ + minScore?: number + /** Provide a pre-built profile instead of re-deriving from data. */ + profile?: ChartDataProfile + /** Override the registry. Defaults to the global capability registry. */ + capabilities?: ReadonlyArray + /** + * Audience profile — overrides chart familiarity and applies adoption-target + * bias to the ranking. See `audienceProfile.ts`. + */ + audience?: AudienceProfile +} + +/** + * Suggest charts for a dataset, ranked by intent suitability. + * + * Heuristic-only — does not call an LLM. Designed to be cheap enough to run on every + * keystroke in a UI, and to feed structured context to an LLM when one is available. + */ +export function suggestCharts( + data: ReadonlyArray | null | undefined, + options: SuggestChartsOptions = {} +): Suggestion[] { + const profile = options.profile ?? profileData(data ?? [], { rawInput: options.rawInput, seriesField: options.seriesField }) + const capabilities = options.capabilities ?? getCapabilities() + const rankingIntents: IntentId[] = options.intent + ? Array.isArray(options.intent) ? options.intent : [options.intent] + : [] + const includeVariants = options.includeVariants !== false + const minScore = options.minScore ?? 0 + const maxResults = options.maxResults ?? 10 + + const allow = options.allow ? new Set(options.allow) : null + const deny = options.deny ? new Set(options.deny) : null + + const out: Suggestion[] = [] + + for (const capability of capabilities) { + if (allow && !allow.has(capability.component)) continue + if (deny && deny.has(capability.component)) continue + + const fitReason = capability.fits(profile) + if (fitReason !== null) continue + + // Base intent scores from the capability + const baseScores: Partial> = {} + for (const [intent, scorer] of Object.entries(capability.intentScores) as Array<[IntentId, IntentScorer]>) { + baseScores[intent] = score(scorer, profile) + } + + const baseCaveats = capability.caveats ? Array.from(capability.caveats(profile)) : [] + const variants: ReadonlyArray = + includeVariants && capability.variants && capability.variants.length > 0 + ? capability.variants + : [undefined] + + for (const variant of variants) { + const intentScores = applyVariantToScores(baseScores, variant) + const baseComposite = compositeScore(intentScores, rankingIntents) + const variantRubric = applyVariantToRubric(capability.rubric, variant) + + // Audience bias: overrides familiarity and shifts composite score + // by ±familiarity + ±target. Strong enough to reorder rankings, not + // strong enough to override fits-driven correctness. + const biased = applyAudienceBias( + baseComposite, + variantRubric, + capability.component, + options.audience, + ) + if (biased.score < minScore) continue + + const reasons = buildReasons(capability, profile, intentScores, rankingIntents) + if (biased.appliedReason) reasons.push(biased.appliedReason) + const caveats = [...baseCaveats, ...(variant?.caveats ?? [])] + const props = capability.buildProps(profile, variant) + + out.push({ + component: capability.component, + family: capability.family, + importPath: capability.importPath, + variant, + score: biased.score, + intentScores, + rubric: biased.rubric, + reasons, + caveats, + props, + }) + } + } + + // Sort: higher composite score first, then higher accuracy, then higher familiarity. + out.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score + if (b.rubric.accuracy !== a.rubric.accuracy) return b.rubric.accuracy - a.rubric.accuracy + return b.rubric.familiarity - a.rubric.familiarity + }) + + return out.slice(0, maxResults) +} + +/** + * One rejected capability: a chart whose `fits()` returned a reason. + * Surfaced by `explainCapabilityFit` for diagnostic panels and `--doctor` auto-fix. + */ +export interface RejectedCapability { + component: string + family: ChartCapability["family"] + importPath: ChartCapability["importPath"] + /** Human-readable reason this chart can't render this profile. */ + reason: string +} + +export interface ExplainCapabilityFitResult { + /** Capabilities that fit the profile — full ranked suggestion list. */ + fitting: Suggestion[] + /** Capabilities that did not fit, with their rejection reasons. */ + rejected: RejectedCapability[] + /** The profile that was evaluated against (provided or computed). */ + profile: ChartDataProfile +} + +/** + * Like `suggestCharts`, but also returns the capabilities that *didn't* fit + * along with their rejection reasons. The single best primitive for: + * • "Why isn't there a pie chart option?" UI surfaces (vizmart V.4) + * • `--doctor` auto-fix loops that need to enumerate alternatives + * • Descriptor authoring — quickly see whose `fits()` is too strict + * + * Mirrors `suggestCharts` for the fitting side. Rejection enumeration walks + * every registered capability whether it fits or not. + */ +export function explainCapabilityFit( + data: ReadonlyArray | null | undefined, + options: SuggestChartsOptions = {} +): ExplainCapabilityFitResult { + const profile = options.profile ?? profileData(data ?? [], { rawInput: options.rawInput, seriesField: options.seriesField }) + const capabilities = options.capabilities ?? getCapabilities() + + const allow = options.allow ? new Set(options.allow) : null + const deny = options.deny ? new Set(options.deny) : null + + const rejected: RejectedCapability[] = [] + for (const capability of capabilities) { + if (allow && !allow.has(capability.component)) continue + if (deny && deny.has(capability.component)) continue + const fitReason = capability.fits(profile) + if (fitReason !== null) { + rejected.push({ + component: capability.component, + family: capability.family, + importPath: capability.importPath, + reason: fitReason, + }) + } + } + + const fitting = suggestCharts(data, { ...options, profile }) + + return { fitting, rejected, profile } +} + +/** + * Score a specific (component, variant) pair against a dataset and (optionally) an intent. + * Useful for evaluating a chart a user already chose: "is this a good fit for what they want?" + */ +export function scoreChart( + component: string, + data: ReadonlyArray | null | undefined, + options: { intent?: IntentId | IntentId[]; variantKey?: string; profile?: ChartDataProfile } = {} +): Suggestion | { reason: string } { + const capabilities = getCapabilities() + const capability = capabilities.find((c) => c.component === component) + if (!capability) return { reason: `No capability registered for "${component}"` } + const profile = options.profile ?? profileData(data ?? []) + const fit = capability.fits(profile) + if (fit !== null) return { reason: fit } + + const variant = options.variantKey + ? capability.variants?.find((v) => v.key === options.variantKey) + : undefined + + const intents: IntentId[] = options.intent + ? Array.isArray(options.intent) ? options.intent : [options.intent] + : [] + + const baseScores: Partial> = {} + for (const [intent, scorer] of Object.entries(capability.intentScores) as Array<[IntentId, IntentScorer]>) { + baseScores[intent] = score(scorer, profile) + } + const intentScores = applyVariantToScores(baseScores, variant) + const composite = compositeScore(intentScores, intents) + const rubric = applyVariantToRubric(capability.rubric, variant) + const reasons = buildReasons(capability, profile, intentScores, intents) + const caveats = [ + ...(capability.caveats ? capability.caveats(profile) : []), + ...(variant?.caveats ?? []), + ] + + return { + component: capability.component, + family: capability.family, + importPath: capability.importPath, + variant, + score: composite, + intentScores, + rubric, + reasons, + caveats, + props: capability.buildProps(profile, variant), + } +} diff --git a/src/components/ai/suggestDashboard.test.ts b/src/components/ai/suggestDashboard.test.ts new file mode 100644 index 00000000..e85a9ad1 --- /dev/null +++ b/src/components/ai/suggestDashboard.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest" +import { suggestDashboard } from "./suggestDashboard" + +const temporalMultiSeries = Array.from({ length: 24 }, (_, i) => { + const region = ["EU", "NA", "APAC"][i % 3] + return { month: Math.floor(i / 3) + 1, revenue: 1000 + i * 80, region } +}) + +const productCatalog = [ + { product: "Widget", category: "tools", units: 480, region: "EU", price: 12 }, + { product: "Gadget", category: "tools", units: 620, region: "NA", price: 25 }, + { product: "Sprocket", category: "parts", units: 290, region: "EU", price: 8 }, + { product: "Whatsit", category: "parts", units: 740, region: "APAC", price: 15 }, + { product: "Gizmo", category: "tools", units: 410, region: "NA", price: 18 }, +] + +describe("suggestDashboard", () => { + it("returns multiple panels covering distinct intents", () => { + const dashboard = suggestDashboard(temporalMultiSeries) + expect(dashboard.panels.length).toBeGreaterThan(1) + // No two panels share the same intent + const intents = dashboard.panels.map((p) => p.intent) + expect(new Set(intents).size).toBe(intents.length) + }) + + it("diversifies by chart family by default", () => { + const dashboard = suggestDashboard(temporalMultiSeries) + const families = dashboard.panels.map((p) => p.suggestion.family) + // Ideally every family appears at most once; allow occasional repeat if + // diversification's fallback path kicked in. + const uniqueFamilies = new Set(families) + expect(uniqueFamilies.size).toBeGreaterThanOrEqual(Math.min(2, families.length)) + }) + + it("emits a dashboard sized to maxPanels", () => { + const dashboard = suggestDashboard(temporalMultiSeries, { maxPanels: 3 }) + expect(dashboard.panels.length).toBeLessThanOrEqual(3) + }) + + it("respects an explicit intent list when provided", () => { + const dashboard = suggestDashboard(temporalMultiSeries, { + intents: ["trend", "compare-series", "compare-categories"], + }) + expect(dashboard.panels.map((p) => p.intent)).toEqual([ + "trend", + "compare-series", + "compare-categories", + ]) + }) + + it("reports intents the data couldn't cover", () => { + // Categorical product data can't cover trend/hierarchy/geo + const dashboard = suggestDashboard(productCatalog, { + intents: ["rank", "trend", "hierarchy", "geo"], + }) + expect(dashboard.intentsMissing).toContain("trend") + expect(dashboard.intentsMissing).toContain("hierarchy") + expect(dashboard.intentsMissing).toContain("geo") + expect(dashboard.intentsCovered).toContain("rank") + }) + + it("includes runnable props on every panel", () => { + const dashboard = suggestDashboard(temporalMultiSeries) + for (const panel of dashboard.panels) { + expect(panel.suggestion.props).toBeDefined() + expect(panel.suggestion.props.data).toBeDefined() + } + }) + + it("does not repeat the same chart twice", () => { + const dashboard = suggestDashboard(temporalMultiSeries) + const keys = dashboard.panels.map( + (p) => `${p.suggestion.component}/${p.suggestion.variant?.key ?? "base"}`, + ) + expect(new Set(keys).size).toBe(keys.length) + }) + + it("returns empty panels gracefully for empty data", () => { + const dashboard = suggestDashboard([]) + expect(dashboard.panels).toEqual([]) + expect(dashboard.intentsCovered).toEqual([]) + }) + + it("default intents skip families the data doesn't support", () => { + // productCatalog has no time axis and no hierarchy; default intents shouldn't include trend/hierarchy + const dashboard = suggestDashboard(productCatalog) + const intents = [...dashboard.intentsCovered, ...dashboard.intentsMissing] + expect(intents).not.toContain("trend") + expect(intents).not.toContain("hierarchy") + expect(intents).not.toContain("geo") + }) +}) diff --git a/src/components/ai/suggestDashboard.ts b/src/components/ai/suggestDashboard.ts new file mode 100644 index 00000000..d041311a --- /dev/null +++ b/src/components/ai/suggestDashboard.ts @@ -0,0 +1,223 @@ +import type { Datum } from "../charts/shared/datumTypes" +import { profileData } from "./profileData" +import { suggestCharts } from "./suggestCharts" +import { suggestStretchCharts, type StretchSuggestion } from "./suggestStretchCharts" +import type { ChartDataProfile, Suggestion } from "./chartCapabilityTypes" +import type { IntentId } from "./intents" +import type { AudienceProfile } from "./audienceProfile" + +/** + * One panel in a generated dashboard. Pairs a chart suggestion with the + * intent that motivated it — consumers render the suggestion and label it + * with the intent so readers know *why* that panel exists. + */ +export interface DashboardPanel { + /** The intent this panel covers. */ + intent: IntentId + /** The chart picked for that intent. */ + suggestion: Suggestion +} + +export interface DashboardSuggestion { + /** Ordered panels, each covering a distinct intent. */ + panels: DashboardPanel[] + /** Intents the engine actually filled. */ + intentsCovered: IntentId[] + /** Intents the engine couldn't fill from this data. */ + intentsMissing: IntentId[] + /** + * Stretch panels — unfamiliar-but-fitting charts the audience could grow + * into. Empty when no `audience` is provided or `exposureLevel` is 0. + * Render alongside the main panels in a distinct surface so users see + * them as opt-in literacy growth, not silent defaults. + */ + stretchPanels: StretchSuggestion[] + /** The shape profile (computed once, reused for every panel). */ + profile: ChartDataProfile +} + +export interface SuggestDashboardOptions { + /** + * Intents to attempt. When omitted, the engine picks a sensible default set + * based on the data shape (e.g. if `hasTimeAxis`, include "trend"; if + * `categoryCount`, include "rank" and "part-to-whole"). + */ + intents?: ReadonlyArray + /** Maximum number of panels. Default 6. */ + maxPanels?: number + /** + * When true (default), prefer not to repeat the same chart family across + * panels — produces a more varied dashboard. Set false to allow duplicates. + */ + diversifyByFamily?: boolean + /** Allow only these component names. */ + allow?: ReadonlyArray + /** Exclude these component names. */ + deny?: ReadonlyArray + /** Optional pre-built profile (avoids recomputation). */ + profile?: ChartDataProfile + /** Non-tabular payload — forwarded to profileData. */ + rawInput?: unknown + /** + * Audience profile — applies familiarity overrides and adoption-target bias + * to every panel's ranking. When set with `exposureLevel >= 1`, the dashboard + * additionally returns `stretchPanels` showing unfamiliar-but-fitting charts. + */ + audience?: AudienceProfile + /** Max stretch panels (default min(maxPanels, 3)). */ + maxStretchPanels?: number +} + +/** + * Choose a default intent set based on data shape. The intuition: a good + * dashboard answers "what's here?" through several lenses, but those lenses + * only make sense if the data actually supports them. + */ +function defaultIntents(profile: ChartDataProfile): IntentId[] { + const intents: IntentId[] = [] + + if (profile.hasTimeAxis) { + intents.push("trend") + if (profile.seriesCount && profile.seriesCount >= 2) { + intents.push("compare-series", "composition-over-time") + } + intents.push("change-detection") + } + + if (profile.categoryCount) { + intents.push("rank", "compare-categories", "part-to-whole") + } + + // Distribution applies whenever we have a primary numeric y and enough rows. + if (profile.primary.y && profile.rowCount >= 10) { + intents.push("distribution") + } + + // Correlation if there are 2+ numerics + const numericFieldCount = Object.values(profile.fields).filter( + (f) => f.type === "numeric", + ).length + if (numericFieldCount >= 2) { + intents.push("correlation", "outlier-detection") + } + + if (profile.hasHierarchy) intents.push("hierarchy") + if (profile.hasNetwork) intents.push("flow") + if (profile.hasGeo) intents.push("geo") + + // Dedup while preserving order + return Array.from(new Set(intents)) +} + +/** + * Generate a dashboard: a set of complementary chart panels, each + * answering a distinct analytical intent on the same dataset. + * + * The contract: every panel has a stated `intent` and a suggestion that + * fits that intent. The engine diversifies by chart family by default to + * avoid "every panel is a bar chart" outcomes. Intents that can't be + * filled from the data (e.g. "geo" on row data with no lat/lon) are + * reported in `intentsMissing` so consumers can show "no fit for geo + * here" rather than silently dropping them. + * + * Heuristic only — no LLM call. The result is suitable for direct + * rendering (each panel's `suggestion.props` is spreadable into the + * matching chart) or for piping to an LLM as composition context. + * + * @example + * const { panels } = suggestDashboard(data) + * return ( + * + * {panels.map(({ intent, suggestion }) => ( + * + * + * + * ))} + * + * ) + */ +export function suggestDashboard( + data: ReadonlyArray | null | undefined, + options: SuggestDashboardOptions = {}, +): DashboardSuggestion { + const profile = options.profile ?? profileData(data ?? [], { rawInput: options.rawInput }) + const maxPanels = options.maxPanels ?? 6 + const diversify = options.diversifyByFamily !== false + const intents = options.intents ?? defaultIntents(profile) + + const panels: DashboardPanel[] = [] + const intentsCovered: IntentId[] = [] + const intentsMissing: IntentId[] = [] + const usedFamilies = new Set() + // Track (component, variantKey) so the same chart never appears twice + const usedKeys = new Set() + + for (const intent of intents) { + if (panels.length >= maxPanels) { + intentsMissing.push(intent) + continue + } + + // Get a fresh ranked list for this intent. We re-rank rather than + // cherry-picking from a single suggestion set because intent-specific + // ranking is the whole point. The minScore floor ensures we don't + // recommend "the technically least-bad fit" when *nothing* actually + // serves the intent (e.g. "geo" on row data with no lat/lon). + const candidates = suggestCharts(data, { + profile, + intent, + allow: options.allow, + deny: options.deny, + maxResults: 20, + includeVariants: true, + minScore: 1.5, + audience: options.audience, + }) + + // Find the highest-ranked candidate not already used (component+variant), + // and (when diversifying) whose family isn't already in the dashboard. + let pick: Suggestion | undefined + for (const candidate of candidates) { + const key = `${candidate.component}/${candidate.variant?.key ?? "base"}` + if (usedKeys.has(key)) continue + if (diversify && usedFamilies.has(candidate.family)) continue + pick = candidate + break + } + + // Fallback: if diversification eliminated all candidates, accept a + // family repeat rather than skipping the intent. + if (!pick && diversify) { + for (const candidate of candidates) { + const key = `${candidate.component}/${candidate.variant?.key ?? "base"}` + if (usedKeys.has(key)) continue + pick = candidate + break + } + } + + if (pick) { + panels.push({ intent, suggestion: pick }) + intentsCovered.push(intent) + usedFamilies.add(pick.family) + usedKeys.add(`${pick.component}/${pick.variant?.key ?? "base"}`) + } else { + intentsMissing.push(intent) + } + } + + // Stretch panels are populated when an audience is provided and exposure is enabled. + // Excludes anything already in the main dashboard so the stretch rail genuinely + // shows growth opportunities, not duplicates of the familiar picks. + const stretchPanels: StretchSuggestion[] = + options.audience && (options.audience.exposureLevel ?? 1) > 0 + ? suggestStretchCharts(data, { + profile, + audience: options.audience, + deny: Array.from(usedKeys).map((k) => k.split("/")[0]), + maxResults: options.maxStretchPanels ?? Math.min(3, maxPanels), + }) + : [] + + return { panels, intentsCovered, intentsMissing, stretchPanels, profile } +} diff --git a/src/components/ai/suggestStreamCharts.test.ts b/src/components/ai/suggestStreamCharts.test.ts new file mode 100644 index 00000000..baeec66c --- /dev/null +++ b/src/components/ai/suggestStreamCharts.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest" +import { suggestStreamCharts, registerStreamChartCapability, unregisterStreamChartCapability } from "./suggestStreamCharts" +import type { StreamSchema, StreamChartCapability } from "./streamingTypes" + +const latencyStream: StreamSchema = { + fields: [ + { name: "ts", kind: "date" }, + { name: "latency_ms", kind: "numeric" }, + { name: "endpoint", kind: "categorical" }, + ], + throughput: "medium", + retention: "windowed", +} + +const highVolumeStream: StreamSchema = { + fields: [ + { name: "ts", kind: "date" }, + { name: "value", kind: "numeric" }, + ], + throughput: "high", + retention: "windowed", +} + +const pureValueStream: StreamSchema = { + fields: [ + { name: "value", kind: "numeric" }, + { name: "cohort", kind: "categorical" }, + ], +} + +describe("suggestStreamCharts", () => { + it("recommends RealtimeLineChart for medium-throughput trend", () => { + const suggestions = suggestStreamCharts(latencyStream, { intent: "trend" }) + expect(suggestions[0].component).toBe("RealtimeLineChart") + }) + + it("recommends RealtimeHeatmap / Waterfall for high throughput trend", () => { + const suggestions = suggestStreamCharts(highVolumeStream, { intent: "trend" }) + expect(suggestions[0].component).not.toBe("RealtimeLineChart") + expect(["RealtimeHeatmap", "RealtimeWaterfallChart"]).toContain(suggestions[0].component) + }) + + it("rejects RealtimeLineChart at high throughput", () => { + const suggestions = suggestStreamCharts(highVolumeStream) + expect(suggestions.find((s) => s.component === "RealtimeLineChart")).toBeUndefined() + }) + + it("recommends RealtimeHistogram for distribution", () => { + const suggestions = suggestStreamCharts(latencyStream, { intent: "distribution" }) + expect(suggestions[0].component).toBe("RealtimeHistogram") + }) + + it("recommends RealtimeSwarmChart for outlier detection with categories", () => { + const suggestions = suggestStreamCharts(pureValueStream, { intent: "outlier-detection" }) + expect(suggestions[0].component).toBe("RealtimeSwarmChart") + }) + + it("includes ready-to-use props", () => { + const suggestions = suggestStreamCharts(latencyStream, { intent: "trend" }) + expect(suggestions[0].props.xAccessor).toBe("ts") + expect(suggestions[0].props.yAccessor).toBe("latency_ms") + }) + + it("surfaces cumulative-retention caveat for line chart", () => { + const cumulativeStream: StreamSchema = { + fields: [ + { name: "ts", kind: "date" }, + { name: "value", kind: "numeric" }, + ], + throughput: "low", + retention: "cumulative", + } + const suggestions = suggestStreamCharts(cumulativeStream, { intent: "trend" }) + const line = suggestions.find((s) => s.component === "RealtimeLineChart") + expect(line?.caveats.some((c) => c.includes("buffer") || c.includes("windowSize"))).toBe(true) + }) + + it("respects user-registered capabilities", () => { + const custom: StreamChartCapability = { + component: "MyStreamChart", + importPath: "semiotic/realtime", + rubric: { familiarity: 1, accuracy: 5, precision: 5 }, + fits: () => null, + intentScores: { "trend": 5 }, + buildProps: () => ({}), + } + registerStreamChartCapability(custom) + try { + const suggestions = suggestStreamCharts(latencyStream, { allow: ["MyStreamChart"] }) + expect(suggestions[0].component).toBe("MyStreamChart") + } finally { + unregisterStreamChartCapability("MyStreamChart") + } + }) +}) diff --git a/src/components/ai/suggestStreamCharts.ts b/src/components/ai/suggestStreamCharts.ts new file mode 100644 index 00000000..c3df4bf6 --- /dev/null +++ b/src/components/ai/suggestStreamCharts.ts @@ -0,0 +1,167 @@ +import type { + StreamChartCapability, + StreamIntentScorer, + StreamSchema, + StreamSuggestion, +} from "./streamingTypes" +import type { ChartRubric } from "./chartCapabilityTypes" +import type { IntentId } from "./intents" +import { RealtimeLineChartCapability } from "../charts/realtime/RealtimeLineChart.capability" +import { RealtimeHistogramCapability } from "../charts/realtime/RealtimeHistogram.capability" +import { RealtimeSwarmChartCapability } from "../charts/realtime/RealtimeSwarmChart.capability" +import { RealtimeWaterfallChartCapability } from "../charts/realtime/RealtimeWaterfallChart.capability" +import { RealtimeHeatmapCapability } from "../charts/realtime/RealtimeHeatmap.capability" +import { TemporalHistogramCapability } from "../charts/realtime/TemporalHistogram.capability" + +const BUILT_IN_STREAM_CAPABILITIES: ReadonlyArray = [ + RealtimeLineChartCapability, + RealtimeHistogramCapability, + RealtimeSwarmChartCapability, + RealtimeWaterfallChartCapability, + RealtimeHeatmapCapability, + TemporalHistogramCapability, +] + +const userStreamCapabilities = new Map() + +export function registerStreamChartCapability(capability: StreamChartCapability): void { + userStreamCapabilities.set(capability.component, capability) +} + +export function unregisterStreamChartCapability(component: string): void { + userStreamCapabilities.delete(component) +} + +export function getStreamCapabilities(): ReadonlyArray { + if (userStreamCapabilities.size === 0) return BUILT_IN_STREAM_CAPABILITIES + const merged = new Map() + for (const c of BUILT_IN_STREAM_CAPABILITIES) merged.set(c.component, c) + for (const [name, c] of userStreamCapabilities) merged.set(name, c) + return Array.from(merged.values()) +} + +function scoreValue(scorer: StreamIntentScorer | undefined, schema: StreamSchema): number { + if (scorer === undefined) return 0 + const raw = typeof scorer === "function" ? (scorer as (s: StreamSchema) => number)(schema) : scorer + if (!Number.isFinite(raw)) return 0 + return Math.max(0, Math.min(5, raw)) +} + +function compositeScore( + intentScores: Partial>, + rankingIntents: IntentId[], +): number { + if (rankingIntents.length === 0) { + const nonZero = Object.values(intentScores).filter((n): n is number => typeof n === "number" && n > 0) + if (nonZero.length === 0) return 0 + return nonZero.reduce((a, b) => a + b, 0) / nonZero.length + } + let sum = 0 + for (const intent of rankingIntents) sum += intentScores[intent] ?? 0 + return sum / rankingIntents.length +} + +function buildReasons( + schema: StreamSchema, + intentScores: Partial>, + rankingIntents: IntentId[], +): string[] { + const reasons: string[] = [] + const top = rankingIntents + .map((intent) => ({ intent, score: intentScores[intent] ?? 0 })) + .filter((entry) => entry.score >= 3) + .sort((a, b) => b.score - a.score) + .slice(0, 2) + for (const { intent, score } of top) { + reasons.push(`Strong fit for ${intent} (${score}/5)`) + } + if (schema.throughput) reasons.push(`tuned for ${schema.throughput} throughput`) + return reasons +} + +export interface SuggestStreamChartsOptions { + intent?: IntentId | IntentId[] + allow?: ReadonlyArray + deny?: ReadonlyArray + maxResults?: number + minScore?: number + capabilities?: ReadonlyArray +} + +/** + * Suggest realtime charts for a schema, ranked by intent. + * + * Parallel to `suggestCharts` but operates on a `StreamSchema` (fields + + * throughput/retention hints) rather than row data. Use for live dashboards, + * monitoring views, anywhere events arrive over time rather than as a bounded + * table. + * + * @example + * const suggestions = suggestStreamCharts({ + * fields: [ + * { name: "ts", kind: "date" }, + * { name: "latency_ms", kind: "numeric" }, + * { name: "endpoint", kind: "categorical" }, + * ], + * throughput: "high", + * retention: "windowed", + * }, { intent: "trend" }) + * // → [{ component: "RealtimeHeatmap", ... }, { component: "RealtimeWaterfallChart", ... }] + */ +export function suggestStreamCharts( + schema: StreamSchema, + options: SuggestStreamChartsOptions = {}, +): StreamSuggestion[] { + const capabilities = options.capabilities ?? getStreamCapabilities() + const rankingIntents: IntentId[] = options.intent + ? Array.isArray(options.intent) ? options.intent : [options.intent] + : [] + const minScore = options.minScore ?? 0 + const maxResults = options.maxResults ?? 10 + + const allow = options.allow ? new Set(options.allow) : null + const deny = options.deny ? new Set(options.deny) : null + + const out: StreamSuggestion[] = [] + + for (const capability of capabilities) { + if (allow && !allow.has(capability.component)) continue + if (deny && deny.has(capability.component)) continue + + const fitReason = capability.fits(schema) + if (fitReason !== null) continue + + const intentScores: Partial> = {} + for (const [intent, scorer] of Object.entries(capability.intentScores) as Array<[IntentId, StreamIntentScorer]>) { + intentScores[intent] = scoreValue(scorer, schema) + } + + const composite = compositeScore(intentScores, rankingIntents) + if (composite < minScore) continue + + const rubric: ChartRubric = { ...capability.rubric } + const caveats = capability.caveats ? Array.from(capability.caveats(schema)) : [] + const reasons = buildReasons(schema, intentScores, rankingIntents) + const props = capability.buildProps(schema) + + out.push({ + component: capability.component, + family: "realtime", + importPath: capability.importPath, + score: composite, + intentScores, + rubric, + reasons, + caveats, + props, + }) + } + + out.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score + if (b.rubric.accuracy !== a.rubric.accuracy) return b.rubric.accuracy - a.rubric.accuracy + return b.rubric.familiarity - a.rubric.familiarity + }) + + return out.slice(0, maxResults) +} diff --git a/src/components/ai/suggestStretchCharts.test.ts b/src/components/ai/suggestStretchCharts.test.ts new file mode 100644 index 00000000..d53a52ec --- /dev/null +++ b/src/components/ai/suggestStretchCharts.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest" +import { suggestStretchCharts } from "./suggestStretchCharts" +import { suggestDashboard } from "./suggestDashboard" +import type { AudienceProfile } from "./audienceProfile" + +const satisfactionByCohort = Array.from({ length: 150 }, (_, i) => ({ + respondent: i + 1, + satisfaction: Math.max(1, Math.min(10, 6 + Math.sin(i / 7) * 2 + Math.random() * 3 - 1)), + cohort: ["Beta", "GA", "Enterprise"][i % 3], +})) + +const productSales = [ + { product: "A", units: 30 }, + { product: "B", units: 50 }, + { product: "C", units: 20 }, + { product: "D", units: 45 }, +] + +const executiveAudience: AudienceProfile = { + name: "Exec", + familiarity: { BarChart: 5, LineChart: 5, PieChart: 5, BoxPlot: 2, ViolinPlot: 1, SwarmPlot: 1 }, + targets: { + BoxPlot: { direction: "increase", weight: 2, reason: "growing distribution literacy" }, + }, + exposureLevel: 1, +} + +describe("suggestStretchCharts", () => { + it("returns empty array when no audience is supplied", () => { + const result = suggestStretchCharts(satisfactionByCohort) + expect(result).toEqual([]) + }) + + it("surfaces audience-targeted increase charts as stretches", () => { + const result = suggestStretchCharts(satisfactionByCohort, { + audience: executiveAudience, + intent: "compare-categories", + }) + expect(result.some((s) => s.suggestion.component === "BoxPlot")).toBe(true) + }) + + it("each stretch carries a non-empty rationale", () => { + const result = suggestStretchCharts(satisfactionByCohort, { + audience: executiveAudience, + intent: "compare-categories", + }) + for (const s of result) { + expect(s.rationale.length).toBeGreaterThan(0) + } + }) + + it("uses target reason verbatim when one is provided", () => { + const result = suggestStretchCharts(satisfactionByCohort, { + audience: executiveAudience, + intent: "compare-categories", + }) + const boxStretch = result.find((s) => s.suggestion.component === "BoxPlot") + expect(boxStretch?.rationale).toContain("growing distribution literacy") + }) + + it("respects the familiarity ceiling — never recommends a chart the audience already knows", () => { + const result = suggestStretchCharts(productSales, { + audience: executiveAudience, + intent: "rank", + }) + // BarChart is familiarity 5; should never appear as a stretch + expect(result.some((s) => s.suggestion.component === "BarChart")).toBe(false) + }) + + it("does not return charts that fail the fits gate", () => { + // 4-row product data can't fit ViolinPlot/RidgelinePlot + const result = suggestStretchCharts(productSales, { + audience: executiveAudience, + intent: "rank", + }) + expect(result.some((s) => s.suggestion.component === "RidgelinePlot")).toBe(false) + }) + + it("widens the ceiling at exposureLevel 2", () => { + // bump exposure level — Scatterplot is familiarity 3 (executive default) + const audience: AudienceProfile = { + ...executiveAudience, + familiarity: { ...executiveAudience.familiarity, Scatterplot: 3 }, + exposureLevel: 2, + } + const dataWith2Numerics = Array.from({ length: 30 }, () => ({ + x: Math.random() * 100, + y: Math.random() * 100, + })) + const result = suggestStretchCharts(dataWith2Numerics, { + audience, + intent: "correlation", + }) + expect(result.some((s) => s.suggestion.component === "Scatterplot")).toBe(true) + }) +}) + +describe("suggestDashboard × stretchPanels", () => { + it("includes stretchPanels when audience has exposureLevel >= 1", () => { + const dashboard = suggestDashboard(satisfactionByCohort, { + audience: executiveAudience, + }) + expect(dashboard.stretchPanels.length).toBeGreaterThan(0) + }) + + it("returns no stretchPanels when exposureLevel is 0", () => { + const audience = { ...executiveAudience, exposureLevel: 0 as const } + const dashboard = suggestDashboard(satisfactionByCohort, { audience }) + expect(dashboard.stretchPanels).toEqual([]) + }) + + it("returns no stretchPanels when no audience is supplied", () => { + const dashboard = suggestDashboard(satisfactionByCohort) + expect(dashboard.stretchPanels).toEqual([]) + }) + + it("stretchPanels do not duplicate main panels", () => { + const dashboard = suggestDashboard(satisfactionByCohort, { + audience: executiveAudience, + }) + const panelComponents = new Set(dashboard.panels.map((p) => p.suggestion.component)) + for (const stretch of dashboard.stretchPanels) { + expect(panelComponents.has(stretch.suggestion.component)).toBe(false) + } + }) +}) diff --git a/src/components/ai/suggestStretchCharts.ts b/src/components/ai/suggestStretchCharts.ts new file mode 100644 index 00000000..7c9361ff --- /dev/null +++ b/src/components/ai/suggestStretchCharts.ts @@ -0,0 +1,158 @@ +import type { Datum } from "../charts/shared/datumTypes" +import { profileData } from "./profileData" +import { suggestCharts } from "./suggestCharts" +import { getCapabilities } from "./chartCapabilities" +import type { ChartDataProfile, Suggestion } from "./chartCapabilityTypes" +import type { IntentId } from "./intents" +import { effectiveFamiliarity, stretchFamiliarityCeiling, type AudienceProfile } from "./audienceProfile" + +/** + * A "stretch pick" — an unfamiliar-but-fitting chart paired with the + * familiar chart it could substitute for. Pairing makes the literacy + * suggestion concrete: "instead of BarChart, try BoxPlot here, because…" + */ +export interface StretchSuggestion { + /** The unfamiliar chart we're suggesting as growth. */ + suggestion: Suggestion + /** + * The familiar chart this stretch could replace for the same intent. + * Undefined when the stretch is recommended on its own merits (e.g. a + * direct "increase" target with no obvious familiar counterpart). + */ + replacing?: string + /** Human-readable rationale, suitable for verbatim display. */ + rationale: string + /** Audience familiarity for this chart — the number that made it qualify as a stretch. */ + familiarity: number +} + +export interface SuggestStretchChartsOptions { + /** Intent(s) to rank by. When omitted, charts are picked by data fit alone. */ + intent?: IntentId | IntentId[] + /** Required — without an audience profile, the concept of "stretch" doesn't apply. */ + audience?: AudienceProfile + /** Restrict to these component names. */ + allow?: ReadonlyArray + /** Exclude these component names. */ + deny?: ReadonlyArray + /** Max stretch picks to return (default 5). */ + maxResults?: number + /** Pre-built profile. */ + profile?: ChartDataProfile + /** Non-tabular payload — forwarded to profileData. */ + rawInput?: unknown + /** + * Only return stretches within this score distance of the top familiar pick + * (default 1.5). Tighter values keep the suggestions plausible; wider values + * expose more variety. + */ + scoreTolerance?: number +} + +interface PairCandidate { + stretch: Suggestion + familiar?: Suggestion +} + +/** + * Find pairs (familiar, stretch) where the stretch chart fits the data, + * has audience familiarity at or below the stretch ceiling, and either: + * • is an `increase` target for this audience, OR + * • scores within `scoreTolerance` of a familiar alternative for the + * same intent. + * + * Each pair is returned as a StretchSuggestion with `replacing` (the + * familiar chart it could substitute for) and a rationale string. + * + * Heuristic only. Use `audience` with care — without target signals, every + * audience-unfamiliar chart becomes a candidate, which can drown the + * surface in dubious recommendations. + */ +export function suggestStretchCharts( + data: ReadonlyArray | null | undefined, + options: SuggestStretchChartsOptions = {}, +): StretchSuggestion[] { + const audience = options.audience + if (!audience) return [] + + const profile = options.profile ?? profileData(data ?? [], { rawInput: options.rawInput }) + const ceiling = stretchFamiliarityCeiling(audience) + const scoreTolerance = options.scoreTolerance ?? 1.5 + const maxResults = options.maxResults ?? 5 + + // Build a map of effective familiarity per registered component + const capabilities = getCapabilities() + const familiarityByComponent = new Map() + for (const c of capabilities) { + familiarityByComponent.set(c.component, effectiveFamiliarity(c.component, c.rubric.familiarity, audience)) + } + + // Run a familiar-only pass (no audience bias) so we have a baseline ranking + // to compare stretches against — otherwise we'd compare biased scores to + // biased scores and the comparison is degenerate. + const baseline = suggestCharts(data, { + profile, + intent: options.intent, + maxResults: 30, + includeVariants: true, + minScore: 1.0, + allow: options.allow, + deny: options.deny, + }) + + // Bucket baseline by intent so we can find a familiar counterpart per stretch. + // For multi-intent or no-intent cases, just take the top-scoring familiar pick. + const familiarPicks = baseline.filter( + (s) => (familiarityByComponent.get(s.component) ?? s.rubric.familiarity) >= 4, + ) + const topFamiliar = familiarPicks[0] + const topFamiliarByComponent = new Map() + for (const s of familiarPicks) { + if (!topFamiliarByComponent.has(s.component)) topFamiliarByComponent.set(s.component, s) + } + + // Identify stretches: charts that fit, with audience familiarity ≤ ceiling. + const stretchCandidates: PairCandidate[] = [] + for (const candidate of baseline) { + const familiarity = familiarityByComponent.get(candidate.component) ?? candidate.rubric.familiarity + if (familiarity > ceiling) continue + + const isIncreaseTarget = audience.targets?.[candidate.component]?.direction === "increase" + const withinTolerance = topFamiliar + ? topFamiliar.score - candidate.score <= scoreTolerance + : true + + if (!isIncreaseTarget && !withinTolerance) continue + + stretchCandidates.push({ stretch: candidate, familiar: topFamiliar }) + } + + // Dedupe by component+variant + const seen = new Set() + const out: StretchSuggestion[] = [] + for (const { stretch, familiar } of stretchCandidates) { + const key = `${stretch.component}/${stretch.variant?.key ?? "base"}` + if (seen.has(key)) continue + seen.add(key) + + const familiarity = familiarityByComponent.get(stretch.component) ?? stretch.rubric.familiarity + const target = audience.targets?.[stretch.component] + const rationale = + target?.reason ?? + (target?.direction === "increase" + ? `${audience.name ?? "your audience"} is growing adoption of ${stretch.component}` + : familiar + ? `${stretch.component} is on the data, and within reach of ${familiar.component} which you're already familiar with` + : `${stretch.component} fits this data and would expand your team's vocabulary`) + + out.push({ + suggestion: stretch, + replacing: familiar?.component, + rationale, + familiarity, + }) + if (out.length >= maxResults) break + } + + return out +} diff --git a/src/components/ai/useChartSuggestions.ts b/src/components/ai/useChartSuggestions.ts new file mode 100644 index 00000000..3d24f13e --- /dev/null +++ b/src/components/ai/useChartSuggestions.ts @@ -0,0 +1,53 @@ +"use client" +import { useMemo } from "react" +import type { Datum } from "../charts/shared/datumTypes" +import { profileData, type ProfileDataOptions } from "./profileData" +import { suggestCharts, type SuggestChartsOptions } from "./suggestCharts" +import type { ChartDataProfile, Suggestion } from "./chartCapabilityTypes" + +export interface UseChartSuggestionsOptions extends SuggestChartsOptions, ProfileDataOptions {} + +export interface UseChartSuggestionsResult { + suggestions: ReadonlyArray + profile: ChartDataProfile +} + +/** + * Memoized chart suggestion hook. + * + * Heuristic-only: this hook never calls an LLM. Pair with `useChartInterrogation` + * to let an LLM re-rank or narrate the heuristic suggestions. + * + * @example + * const { suggestions } = useChartSuggestions(data, { intent: "trend" }) + * const top = suggestions[0] + * return + */ +export function useChartSuggestions( + data: ReadonlyArray | null | undefined, + options: UseChartSuggestionsOptions = {} +): UseChartSuggestionsResult { + const { intent, allow, deny, maxResults, includeVariants, minScore, rawInput, seriesField, capabilities, profile: providedProfile } = options + + const profile = useMemo( + () => providedProfile ?? profileData(data ?? [], { rawInput, seriesField }), + [providedProfile, data, rawInput, seriesField] + ) + + const suggestions = useMemo( + () => + suggestCharts(data, { + intent, + allow, + deny, + maxResults, + includeVariants, + minScore, + capabilities, + profile, + }), + [data, intent, allow, deny, maxResults, includeVariants, minScore, capabilities, profile] + ) + + return { suggestions, profile } +} diff --git a/src/components/charts/geo/ChoroplethMap.capability.ts b/src/components/charts/geo/ChoroplethMap.capability.ts new file mode 100644 index 00000000..61a52ea9 --- /dev/null +++ b/src/components/charts/geo/ChoroplethMap.capability.ts @@ -0,0 +1,23 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const ChoroplethMapCapability: ChartCapability = { + component: "ChoroplethMap", + family: "geo", + importPath: "semiotic/geo", + rubric: { familiarity: 4, accuracy: 3, precision: 2 }, + + fits: (profile) => { + if (!profile.hasGeo || !profile.geo) return "needs a GeoJSON FeatureCollection via rawInput" + if (profile.geo.features.length < 1) return "needs at least 1 area feature" + return null + }, + + intentScores: { "geo": 5, "compare-categories": 3 }, + + caveats: () => ["large areas dominate visual weight regardless of measurement"], + + buildProps: (profile) => ({ + areas: profile.geo?.features ?? [], + valueAccessor: profile.primary.y ?? "value", + }), +} diff --git a/src/components/charts/geo/DistanceCartogram.capability.ts b/src/components/charts/geo/DistanceCartogram.capability.ts new file mode 100644 index 00000000..f7d8a521 --- /dev/null +++ b/src/components/charts/geo/DistanceCartogram.capability.ts @@ -0,0 +1,23 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const DistanceCartogramCapability: ChartCapability = { + component: "DistanceCartogram", + family: "geo", + importPath: "semiotic/geo", + rubric: { familiarity: 1, accuracy: 3, precision: 3 }, + + fits: (profile) => { + if (!profile.hasGeo || !profile.geo) return "needs a geo dataset" + if (!(profile.geo.points?.length)) return "needs point nodes with lat/lon and a cost field" + return null + }, + + intentScores: { "geo": 3, "rank": 3, "compare-categories": 2 }, + + caveats: () => ["non-standard projection — requires explanation for most readers"], + + buildProps: (profile) => ({ + points: profile.geo?.points ?? [], + costAccessor: "cost", + }), +} diff --git a/src/components/charts/geo/FlowMap.capability.ts b/src/components/charts/geo/FlowMap.capability.ts new file mode 100644 index 00000000..750b8070 --- /dev/null +++ b/src/components/charts/geo/FlowMap.capability.ts @@ -0,0 +1,23 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const FlowMapCapability: ChartCapability = { + component: "FlowMap", + family: "geo", + importPath: "semiotic/geo", + rubric: { familiarity: 2, accuracy: 3, precision: 2 }, + + fits: (profile) => { + if (!profile.hasGeo || !profile.geo) return "needs a geo dataset" + if (!(profile.geo.flows?.length)) return "needs flow records (source/target/value)" + if (!(profile.geo.points?.length)) return "needs point nodes with lat/lon" + return null + }, + + intentScores: { "geo": 4, "flow": 5 }, + + buildProps: (profile) => ({ + flows: profile.geo?.flows ?? [], + nodes: profile.geo?.points ?? [], + valueAccessor: "value", + }), +} diff --git a/src/components/charts/geo/ProportionalSymbolMap.capability.ts b/src/components/charts/geo/ProportionalSymbolMap.capability.ts new file mode 100644 index 00000000..ccc58c69 --- /dev/null +++ b/src/components/charts/geo/ProportionalSymbolMap.capability.ts @@ -0,0 +1,25 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const ProportionalSymbolMapCapability: ChartCapability = { + component: "ProportionalSymbolMap", + family: "geo", + importPath: "semiotic/geo", + rubric: { familiarity: 3, accuracy: 3, precision: 3 }, + + fits: (profile) => { + if (!profile.hasGeo || !profile.geo) return "needs a GeoJSON FeatureCollection (with points or area centroids)" + const havePoints = (profile.geo.points?.length ?? 0) > 0 + if (!havePoints && (profile.geo.features.length ?? 0) === 0) return "needs points or area features" + return null + }, + + intentScores: { "geo": 4, "rank": 3, "compare-categories": 3 }, + + buildProps: (profile) => ({ + points: profile.geo?.points ?? [], + areas: profile.geo?.features ?? undefined, + xAccessor: "lon", + yAccessor: "lat", + sizeBy: profile.primary.size ?? "value", + }), +} diff --git a/src/components/charts/network/ChordDiagram.capability.ts b/src/components/charts/network/ChordDiagram.capability.ts new file mode 100644 index 00000000..63a148b7 --- /dev/null +++ b/src/components/charts/network/ChordDiagram.capability.ts @@ -0,0 +1,27 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const ChordDiagramCapability: ChartCapability = { + component: "ChordDiagram", + family: "flow", + importPath: "semiotic/network", + rubric: { familiarity: 2, accuracy: 3, precision: 2 }, + + fits: (profile) => { + if (!profile.hasNetwork || !profile.network) return "needs a {nodes, edges} network" + if (profile.network.nodes.length < 3) return "needs 3+ nodes" + if (profile.network.edges.length < 3) return "needs 3+ edges" + return null + }, + + intentScores: { + "flow": 4, + }, + + caveats: () => ["chord diagrams trade accuracy for symmetry; use Sankey if direction matters"], + + buildProps: (profile) => ({ + nodes: profile.network?.nodes ?? [], + edges: profile.network?.edges ?? [], + valueAccessor: "value", + }), +} diff --git a/src/components/charts/network/CirclePack.capability.ts b/src/components/charts/network/CirclePack.capability.ts new file mode 100644 index 00000000..33ecd93d --- /dev/null +++ b/src/components/charts/network/CirclePack.capability.ts @@ -0,0 +1,25 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const CirclePackCapability: ChartCapability = { + component: "CirclePack", + family: "hierarchy", + importPath: "semiotic/network", + rubric: { familiarity: 3, accuracy: 3, precision: 2 }, + + fits: (profile) => { + if (!profile.hasHierarchy || !profile.hierarchy) return "needs a hierarchical root with values" + return null + }, + + intentScores: { + "hierarchy": 4, + "part-to-whole": 3, + }, + + caveats: () => ["circle area is harder to compare than rectangle area"], + + buildProps: (profile) => ({ + data: profile.hierarchy ?? { name: "root", children: [] }, + valueAccessor: "value", + }), +} diff --git a/src/components/charts/network/ForceDirectedGraph.capability.ts b/src/components/charts/network/ForceDirectedGraph.capability.ts new file mode 100644 index 00000000..eca34201 --- /dev/null +++ b/src/components/charts/network/ForceDirectedGraph.capability.ts @@ -0,0 +1,33 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const ForceDirectedGraphCapability: ChartCapability = { + component: "ForceDirectedGraph", + family: "network", + importPath: "semiotic/network", + rubric: { familiarity: 3, accuracy: 3, precision: 3 }, + + fits: (profile) => { + if (!profile.hasNetwork || !profile.network) return "needs a {nodes, edges} network passed via rawInput" + if (profile.network.nodes.length < 2) return "needs at least 2 nodes" + if (profile.network.edges.length < 1) return "needs at least 1 edge" + return null + }, + + intentScores: { + "flow": 3, + "correlation": 2, + }, + + caveats: (p) => { + const n = p.network?.nodes.length ?? 0 + return n > 500 ? ["large graphs become hairballs — consider filtering or aggregating"] : [] + }, + + buildProps: (profile) => ({ + nodes: profile.network?.nodes ?? [], + edges: profile.network?.edges ?? [], + nodeIDAccessor: "id", + sourceAccessor: "source", + targetAccessor: "target", + }), +} diff --git a/src/components/charts/network/OrbitDiagram.capability.ts b/src/components/charts/network/OrbitDiagram.capability.ts new file mode 100644 index 00000000..7e96c3a1 --- /dev/null +++ b/src/components/charts/network/OrbitDiagram.capability.ts @@ -0,0 +1,22 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const OrbitDiagramCapability: ChartCapability = { + component: "OrbitDiagram", + family: "hierarchy", + importPath: "semiotic/network", + rubric: { familiarity: 1, accuracy: 2, precision: 2 }, + + fits: (profile) => { + if (!profile.hasHierarchy || !profile.hierarchy) return "needs a hierarchical root" + return null + }, + + intentScores: { "hierarchy": 3 }, + + caveats: () => ["decorative — readers without context will not infer hierarchy easily"], + + buildProps: (profile) => ({ + data: profile.hierarchy ?? { name: "root", children: [] }, + orbitMode: "solar", + }), +} diff --git a/src/components/charts/network/ProcessSankey.capability.ts b/src/components/charts/network/ProcessSankey.capability.ts new file mode 100644 index 00000000..df460cf0 --- /dev/null +++ b/src/components/charts/network/ProcessSankey.capability.ts @@ -0,0 +1,31 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const ProcessSankeyCapability: ChartCapability = { + component: "ProcessSankey", + family: "flow", + importPath: "semiotic/network", + rubric: { familiarity: 2, accuracy: 4, precision: 3 }, + + fits: (profile) => { + if (!profile.hasNetwork || !profile.network) return "needs a {nodes, edges} network" + // Edges need startTime/endTime fields for a process sankey to make sense. + const first = profile.network.edges[0] + if (!first || (first.startTime === undefined && first.start === undefined)) { + return "edges need startTime/endTime for a temporal sankey" + } + return null + }, + + intentScores: { + "flow": 5, + "composition-over-time": 4, + "change-detection": 3, + }, + + buildProps: (profile) => ({ + nodes: profile.network?.nodes ?? [], + edges: profile.network?.edges ?? [], + pairing: "temporal", + laneOrder: "crossing-min", + }), +} diff --git a/src/components/charts/network/SankeyDiagram.capability.ts b/src/components/charts/network/SankeyDiagram.capability.ts new file mode 100644 index 00000000..2048936c --- /dev/null +++ b/src/components/charts/network/SankeyDiagram.capability.ts @@ -0,0 +1,28 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const SankeyDiagramCapability: ChartCapability = { + component: "SankeyDiagram", + family: "flow", + importPath: "semiotic/network", + rubric: { familiarity: 3, accuracy: 4, precision: 3 }, + + fits: (profile) => { + if (!profile.hasNetwork || !profile.network) return "needs a {nodes, edges} network with edge weights" + if (profile.network.edges.length < 2) return "needs 2+ weighted edges" + return null + }, + + intentScores: { + "flow": 5, + "part-to-whole": 3, + }, + + buildProps: (profile) => ({ + nodes: profile.network?.nodes ?? [], + edges: profile.network?.edges ?? [], + sourceAccessor: "source", + targetAccessor: "target", + valueAccessor: "value", + nodeIdAccessor: "id", + }), +} diff --git a/src/components/charts/network/TreeDiagram.capability.ts b/src/components/charts/network/TreeDiagram.capability.ts new file mode 100644 index 00000000..370ea7d0 --- /dev/null +++ b/src/components/charts/network/TreeDiagram.capability.ts @@ -0,0 +1,25 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const TreeDiagramCapability: ChartCapability = { + component: "TreeDiagram", + family: "hierarchy", + importPath: "semiotic/network", + rubric: { familiarity: 4, accuracy: 4, precision: 3 }, + + fits: (profile) => { + if (!profile.hasHierarchy || !profile.hierarchy) return "needs a hierarchical root (object with children) via rawInput" + return null + }, + + intentScores: { "hierarchy": 5 }, + + variants: [ + { key: "vertical-tree", label: "Vertical tree", props: { layout: "tree", orientation: "vertical" }, tags: ["vertical"] }, + { key: "horizontal-cluster", label: "Horizontal cluster", props: { layout: "cluster", orientation: "horizontal" }, tags: ["horizontal"] }, + ], + + buildProps: (profile, variant) => ({ + data: profile.hierarchy ?? { name: "root", children: [] }, + ...(variant?.props ?? {}), + }), +} diff --git a/src/components/charts/network/Treemap.capability.ts b/src/components/charts/network/Treemap.capability.ts new file mode 100644 index 00000000..94ec0b93 --- /dev/null +++ b/src/components/charts/network/Treemap.capability.ts @@ -0,0 +1,26 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const TreemapCapability: ChartCapability = { + component: "Treemap", + family: "hierarchy", + importPath: "semiotic/network", + rubric: { familiarity: 4, accuracy: 3, precision: 3 }, + + fits: (profile) => { + if (!profile.hasHierarchy || !profile.hierarchy) return "needs a hierarchical root with values" + return null + }, + + intentScores: { + "hierarchy": 4, + "part-to-whole": 4, + "compare-categories": 3, + }, + + caveats: () => ["rectangle area comparisons are less precise than length — prefer a bar chart for ranking"], + + buildProps: (profile) => ({ + data: profile.hierarchy ?? { name: "root", children: [] }, + valueAccessor: "value", + }), +} diff --git a/src/components/charts/ordinal/BarChart.capability.ts b/src/components/charts/ordinal/BarChart.capability.ts new file mode 100644 index 00000000..38d4fd86 --- /dev/null +++ b/src/components/charts/ordinal/BarChart.capability.ts @@ -0,0 +1,63 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const BarChartCapability: ChartCapability = { + component: "BarChart", + family: "categorical", + importPath: "semiotic/ordinal", + rubric: { familiarity: 5, accuracy: 5, precision: 4 }, + + fits: (profile) => { + if (!profile.primary.category) return "needs a category field" + if (!profile.primary.y) return "needs a numeric value field" + if ((profile.categoryCount ?? 0) < 1) return "needs at least 1 category" + if ((profile.categoryCount ?? 0) > 50) return "too many categories — consider aggregating or use a different chart" + return null + }, + + intentScores: { + // BarChart compares pre-aggregated category totals. When each category has + // many raw observations, a BoxPlot / ViolinPlot / SwarmPlot is more honest — + // BarChart's implicit aggregation hides the within-category distribution. + "compare-categories": (p) => { + if (!p.categoryCount) return 0 + const obsPerCategory = p.rowCount / p.categoryCount + if (obsPerCategory >= 10) return 3 // distribution-shaped — yield to distribution charts + return 5 + }, + "rank": 5, + "part-to-whole": (p) => ((p.categoryCount ?? 0) <= 8 ? 3 : 2), + "distribution": 1, + }, + + variants: [ + { + key: "sorted-desc", + label: "Ranked", + props: { sort: "desc" }, + tags: ["sorted", "ranked"], + intentDeltas: { "rank": +0, "compare-categories": +0 }, + }, + { + key: "source-order", + label: "Source order", + props: { sort: false }, + tags: ["source-order"], + intentDeltas: { "rank": -2 }, + }, + { + key: "horizontal", + label: "Horizontal bars", + props: { orientation: "horizontal", sort: "desc" }, + tags: ["horizontal", "ranked"], + intentDeltas: { "rank": +1 }, + rubricDeltas: { precision: +1 }, + }, + ], + + buildProps: (profile, variant) => ({ + data: profile.data, + categoryAccessor: profile.primary.category, + valueAccessor: profile.primary.y, + ...(variant?.props ?? {}), + }), +} diff --git a/src/components/charts/ordinal/BoxPlot.capability.ts b/src/components/charts/ordinal/BoxPlot.capability.ts new file mode 100644 index 00000000..8a20a692 --- /dev/null +++ b/src/components/charts/ordinal/BoxPlot.capability.ts @@ -0,0 +1,30 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const BoxPlotCapability: ChartCapability = { + component: "BoxPlot", + family: "distribution", + importPath: "semiotic/ordinal", + rubric: { familiarity: 4, accuracy: 4, precision: 3 }, + + fits: (profile) => { + if (!profile.primary.y) return "needs a numeric field" + if (!profile.primary.category) return "needs a category to split distributions" + // We need repeated rows per category — otherwise there's no distribution per box. + if (profile.rowCount / Math.max(profile.categoryCount ?? 1, 1) < 3) { + return "needs 3+ observations per category" + } + return null + }, + + intentScores: { + "distribution": 5, + "compare-categories": 4, + "outlier-detection": 4, + }, + + buildProps: (profile) => ({ + data: profile.data, + categoryAccessor: profile.primary.category, + valueAccessor: profile.primary.y, + }), +} diff --git a/src/components/charts/ordinal/DonutChart.capability.ts b/src/components/charts/ordinal/DonutChart.capability.ts new file mode 100644 index 00000000..c9124007 --- /dev/null +++ b/src/components/charts/ordinal/DonutChart.capability.ts @@ -0,0 +1,29 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const DonutChartCapability: ChartCapability = { + component: "DonutChart", + family: "categorical", + importPath: "semiotic/ordinal", + rubric: { familiarity: 4, accuracy: 3, precision: 2 }, + + fits: (profile) => { + if (!profile.primary.category) return "needs a category field" + if (!profile.primary.y) return "needs a numeric value field" + const count = profile.categoryCount ?? 0 + if (count < 2) return "needs 2+ categories" + if (count > 8) return `${count} slices is too many for a donut` + return null + }, + + intentScores: { + "part-to-whole": 4, + "compare-categories": 2, + }, + + buildProps: (profile) => ({ + data: profile.data, + categoryAccessor: profile.primary.category, + valueAccessor: profile.primary.y, + innerRadius: 60, + }), +} diff --git a/src/components/charts/ordinal/DotPlot.capability.ts b/src/components/charts/ordinal/DotPlot.capability.ts new file mode 100644 index 00000000..2629db7f --- /dev/null +++ b/src/components/charts/ordinal/DotPlot.capability.ts @@ -0,0 +1,34 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const DotPlotCapability: ChartCapability = { + component: "DotPlot", + family: "categorical", + importPath: "semiotic/ordinal", + rubric: { familiarity: 3, accuracy: 5, precision: 5 }, + + fits: (profile) => { + if (!profile.primary.category) return "needs a category field" + if (!profile.primary.y) return "needs a numeric value field" + if ((profile.categoryCount ?? 0) > 30) return "too many categories for a dot plot" + return null + }, + + intentScores: { + // Like BarChart, DotPlot implicitly aggregates — yield to distribution + // charts when each category has many observations. + "compare-categories": (p) => { + if (!p.categoryCount) return 0 + const obsPerCategory = p.rowCount / p.categoryCount + if (obsPerCategory >= 10) return 3 + return 5 + }, + "rank": 5, + "outlier-detection": 3, + }, + + buildProps: (profile) => ({ + data: profile.data, + categoryAccessor: profile.primary.category, + valueAccessor: profile.primary.y, + }), +} diff --git a/src/components/charts/ordinal/FunnelChart.capability.ts b/src/components/charts/ordinal/FunnelChart.capability.ts new file mode 100644 index 00000000..259ef6a0 --- /dev/null +++ b/src/components/charts/ordinal/FunnelChart.capability.ts @@ -0,0 +1,35 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +const STAGE_HINT = /(stage|step|funnel|status|outcome|phase)/i + +export const FunnelChartCapability: ChartCapability = { + component: "FunnelChart", + family: "flow", + importPath: "semiotic/ordinal", + rubric: { familiarity: 4, accuracy: 3, precision: 3 }, + + fits: (profile) => { + if (!profile.primary.y) return "needs a numeric value field" + const stepField = Object.keys(profile.fields).find((f) => STAGE_HINT.test(f)) + if (!stepField) return "needs a stage/step/funnel-named field" + return null + }, + + intentScores: { + "flow": 4, + "rank": 3, + "part-to-whole": 2, + }, + + caveats: () => ["readers infer conversion drop-off — make sure rows actually represent sequential stages"], + + buildProps: (profile) => { + const stepField = Object.keys(profile.fields).find((f) => STAGE_HINT.test(f)) + return { + data: profile.data, + stepAccessor: stepField, + valueAccessor: profile.primary.y, + ...(profile.primary.category && profile.primary.category !== stepField ? { categoryAccessor: profile.primary.category } : {}), + } + }, +} diff --git a/src/components/charts/ordinal/GaugeChart.capability.ts b/src/components/charts/ordinal/GaugeChart.capability.ts new file mode 100644 index 00000000..35437b25 --- /dev/null +++ b/src/components/charts/ordinal/GaugeChart.capability.ts @@ -0,0 +1,34 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const GaugeChartCapability: ChartCapability = { + component: "GaugeChart", + family: "categorical", + importPath: "semiotic/ordinal", + rubric: { familiarity: 4, accuracy: 2, precision: 2 }, + + fits: (profile) => { + if (profile.rowCount > 1) return "GaugeChart shows a single value — provide a 1-row dataset or use BarChart" + if (!profile.primary.y) return "needs a numeric value" + return null + }, + + intentScores: { + "compare-categories": 1, + "rank": 1, + }, + + caveats: () => ["gauges only show a single value; consider a stat card or bar instead for comparison"], + + buildProps: (profile) => { + const yField = profile.primary.y! + const firstRow = profile.data[0] + const value = firstRow ? Number(firstRow[yField]) : 0 + const summary = profile.fields[yField] + const max = summary?.type === "numeric" ? summary.max : 100 + return { + value: Number.isFinite(value) ? value : 0, + min: 0, + max, + } + }, +} diff --git a/src/components/charts/ordinal/GroupedBarChart.capability.ts b/src/components/charts/ordinal/GroupedBarChart.capability.ts new file mode 100644 index 00000000..037917b7 --- /dev/null +++ b/src/components/charts/ordinal/GroupedBarChart.capability.ts @@ -0,0 +1,32 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const GroupedBarChartCapability: ChartCapability = { + component: "GroupedBarChart", + family: "categorical", + importPath: "semiotic/ordinal", + rubric: { familiarity: 4, accuracy: 5, precision: 4 }, + + fits: (profile) => { + if (!profile.primary.category) return "needs a category field" + if (!profile.primary.y) return "needs a numeric value field" + if (!profile.primary.series) return "needs a series field to group by" + if ((profile.seriesCount ?? 0) < 2) return "needs 2+ groups" + if ((profile.seriesCount ?? 0) > 6) return `${profile.seriesCount} groups is too many for grouped bars` + if ((profile.categoryCount ?? 0) > 25) return "too many categories for grouped bars" + return null + }, + + intentScores: { + "compare-categories": 5, + "compare-series": 4, + "rank": 3, + }, + + buildProps: (profile) => ({ + data: profile.data, + categoryAccessor: profile.primary.category, + valueAccessor: profile.primary.y, + groupBy: profile.primary.series, + colorBy: profile.primary.series, + }), +} diff --git a/src/components/charts/ordinal/Histogram.capability.ts b/src/components/charts/ordinal/Histogram.capability.ts new file mode 100644 index 00000000..2a3e3051 --- /dev/null +++ b/src/components/charts/ordinal/Histogram.capability.ts @@ -0,0 +1,48 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const HistogramCapability: ChartCapability = { + component: "Histogram", + family: "distribution", + importPath: "semiotic/ordinal", + rubric: { familiarity: 4, accuracy: 4, precision: 3 }, + + fits: (profile) => { + if (profile.rowCount < 10) return "histograms need at least ~10 observations" + if (!profile.primary.y) return "needs a numeric field to bin" + // Distinct values must be > a handful — otherwise a bar chart of counts is better + const yField = profile.primary.y + const yCandidate = profile.candidates.y.find((c) => c.field === yField) + if (yCandidate?.distinctCount !== undefined && yCandidate.distinctCount < 6) { + return "too few distinct numeric values; a bar chart of counts is a better fit" + } + return null + }, + + intentScores: { + "distribution": 5, + "outlier-detection": 3, + "compare-categories": 1, + }, + + variants: [ + { + key: "count-bins", + label: "Count bins", + props: { bins: 10, relative: false }, + tags: ["count"], + }, + { + key: "share-bins", + label: "Share bins (relative)", + props: { bins: 10, relative: true }, + tags: ["share"], + intentDeltas: { "distribution": +0 }, + }, + ], + + buildProps: (profile, variant) => ({ + data: profile.data, + valueAccessor: profile.primary.y, + ...(variant?.props ?? {}), + }), +} diff --git a/src/components/charts/ordinal/Histogram.test.tsx b/src/components/charts/ordinal/Histogram.test.tsx index 8c577f87..9c55a948 100644 --- a/src/components/charts/ordinal/Histogram.test.tsx +++ b/src/components/charts/ordinal/Histogram.test.tsx @@ -44,6 +44,23 @@ describe("Histogram", () => { expect(lastOrdinalFrameProps.data).toBe(sampleData) }) + it("renders raw-observation data with no category field (single bucket)", () => { + // Regression: prior default categoryAccessor="category" failed validation + // on rows like { value: 12 } because "category" wasn't in the data. + // The default now synthesizes an "All" bucket for these cases so + // suggestCharts can route raw-observation data to Histogram cleanly. + const observations = Array.from({ length: 30 }, (_, i) => ({ value: i * 2 + Math.random() * 5 })) + const { container } = render( + + + + ) + const frame = container.querySelector(".stream-ordinal-frame") + expect(frame).toBeTruthy() + // No ChartError rendered — the validator path passed. + expect(container.querySelector(".semiotic-chart-error")).toBeNull() + }) + it("handles empty data gracefully (no frame rendered)", () => { const { container } = render( diff --git a/src/components/charts/ordinal/Histogram.tsx b/src/components/charts/ordinal/Histogram.tsx index 7d548a18..b4377373 100644 --- a/src/components/charts/ordinal/Histogram.tsx +++ b/src/components/charts/ordinal/Histogram.tsx @@ -37,8 +37,10 @@ export interface HistogramProps extends BaseChartP data?: TDatum[] /** * Field name or function returning the bin label (used when data is - * already binned). Ignored when binning raw values. - * @default "category" + * already binned). For raw-observation data with no category dimension, + * the default treats all rows as a single "All" bucket — no need to set + * this explicitly. + * @default (d) => d.category ?? "All" */ categoryAccessor?: ChartAccessor /** @@ -169,7 +171,13 @@ export const Histogram = forwardRef(function Histogram (d?.category as string | undefined) ?? "All") as ChartAccessor, + valueAccessor = "value", bins = 25, relative = false, valueFormat, colorBy, colorScheme, categoryPadding = 20, diff --git a/src/components/charts/ordinal/LikertChart.capability.ts b/src/components/charts/ordinal/LikertChart.capability.ts new file mode 100644 index 00000000..df5aeb77 --- /dev/null +++ b/src/components/charts/ordinal/LikertChart.capability.ts @@ -0,0 +1,34 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +const RATING_HINT = /(rating|score|likert|satisfaction|nps|agree|sentiment|level)/i + +export const LikertChartCapability: ChartCapability = { + component: "LikertChart", + family: "categorical", + importPath: "semiotic/ordinal", + rubric: { familiarity: 3, accuracy: 4, precision: 3 }, + + fits: (profile) => { + if (!profile.primary.category) return "needs a category (question) field" + if (!profile.primary.y) return "needs a numeric rating/count field" + const ratingField = Object.keys(profile.fields).find((f) => RATING_HINT.test(f)) + if (!ratingField) return "needs an ordinal rating/level field (rating, score, level...)" + return null + }, + + intentScores: { + "compare-categories": 4, + "distribution": 3, + "part-to-whole": 3, + }, + + buildProps: (profile) => { + const ratingField = Object.keys(profile.fields).find((f) => RATING_HINT.test(f))! + return { + data: profile.data, + categoryAccessor: profile.primary.category, + valueAccessor: profile.primary.y, + levelAccessor: ratingField, + } + }, +} diff --git a/src/components/charts/ordinal/PieChart.capability.ts b/src/components/charts/ordinal/PieChart.capability.ts new file mode 100644 index 00000000..194dfdd6 --- /dev/null +++ b/src/components/charts/ordinal/PieChart.capability.ts @@ -0,0 +1,50 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const PieChartCapability: ChartCapability = { + component: "PieChart", + family: "categorical", + importPath: "semiotic/ordinal", + rubric: { familiarity: 5, accuracy: 3, precision: 2 }, + + fits: (profile) => { + if (!profile.primary.category) return "needs a category field" + if (!profile.primary.y) return "needs a numeric value field" + const count = profile.categoryCount ?? 0 + if (count < 2) return "needs 2+ categories" + if (count > 8) return `${count} slices is too many for a pie chart` + return null + }, + + intentScores: { + "part-to-whole": 4, + "compare-categories": 2, + "rank": 1, + }, + + caveats: () => [ + "angle comparisons are less accurate than length — prefer a bar chart unless part-to-whole is the explicit message", + ], + + variants: [ + { + key: "pie", + label: "Pie", + props: {}, + tags: ["pie"], + }, + { + key: "donut", + label: "Donut", + description: "Hollow center — easier to fit a label or KPI inside.", + props: { innerRadius: 60 }, + tags: ["donut"], + }, + ], + + buildProps: (profile, variant) => ({ + data: profile.data, + categoryAccessor: profile.primary.category, + valueAccessor: profile.primary.y, + ...(variant?.props ?? {}), + }), +} diff --git a/src/components/charts/ordinal/RidgelinePlot.capability.ts b/src/components/charts/ordinal/RidgelinePlot.capability.ts new file mode 100644 index 00000000..b683e547 --- /dev/null +++ b/src/components/charts/ordinal/RidgelinePlot.capability.ts @@ -0,0 +1,30 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const RidgelinePlotCapability: ChartCapability = { + component: "RidgelinePlot", + family: "distribution", + importPath: "semiotic/ordinal", + rubric: { familiarity: 2, accuracy: 3, precision: 3 }, + + fits: (profile) => { + if (!profile.primary.y) return "needs a numeric field" + if (!profile.primary.category) return "needs a category dimension to stack distributions" + if ((profile.categoryCount ?? 0) < 3) return "needs 3+ categories to make a ridgeline meaningful" + if (profile.rowCount / Math.max(profile.categoryCount ?? 1, 1) < 6) return "needs 6+ observations per category" + return null + }, + + intentScores: { + "distribution": 4, + "compare-categories": 3, + "composition-over-time": 2, + }, + + caveats: () => ["readers can confuse overlapping ridges — limit categories or use small multiples"], + + buildProps: (profile) => ({ + data: profile.data, + categoryAccessor: profile.primary.category, + valueAccessor: profile.primary.y, + }), +} diff --git a/src/components/charts/ordinal/StackedBarChart.capability.ts b/src/components/charts/ordinal/StackedBarChart.capability.ts new file mode 100644 index 00000000..a2bb51cb --- /dev/null +++ b/src/components/charts/ordinal/StackedBarChart.capability.ts @@ -0,0 +1,53 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const StackedBarChartCapability: ChartCapability = { + component: "StackedBarChart", + family: "categorical", + importPath: "semiotic/ordinal", + rubric: { familiarity: 4, accuracy: 4, precision: 3 }, + + fits: (profile) => { + if (!profile.primary.category) return "needs a category field" + if (!profile.primary.y) return "needs a numeric value field" + if (!profile.primary.series) return "needs a series field to stack by" + if ((profile.seriesCount ?? 0) < 2) return "needs 2+ stack groups" + if ((profile.seriesCount ?? 0) > 8) return `${profile.seriesCount} stacked groups is too many` + return null + }, + + intentScores: { + "part-to-whole": 4, + "compare-categories": 4, + "composition-over-time": (p) => (p.hasTimeAxis ? 3 : 1), + "compare-series": 2, + }, + + caveats: () => ["only the bottom segment shares a baseline; others are harder to compare across categories"], + + variants: [ + { + key: "absolute", + label: "Absolute stacks", + props: { normalize: false }, + tags: ["absolute"], + }, + { + key: "normalized", + label: "100% stacked", + description: "Each bar normalized to 1 — emphasizes composition, hides totals.", + props: { normalize: true }, + tags: ["normalized", "part-to-whole"], + intentDeltas: { "part-to-whole": +1, "compare-categories": -1 }, + caveats: ["absolute magnitudes are no longer comparable across bars"], + }, + ], + + buildProps: (profile, variant) => ({ + data: profile.data, + categoryAccessor: profile.primary.category, + valueAccessor: profile.primary.y, + stackBy: profile.primary.series, + colorBy: profile.primary.series, + ...(variant?.props ?? {}), + }), +} diff --git a/src/components/charts/ordinal/SwarmPlot.capability.ts b/src/components/charts/ordinal/SwarmPlot.capability.ts new file mode 100644 index 00000000..fc8d0ee1 --- /dev/null +++ b/src/components/charts/ordinal/SwarmPlot.capability.ts @@ -0,0 +1,29 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const SwarmPlotCapability: ChartCapability = { + component: "SwarmPlot", + family: "distribution", + importPath: "semiotic/ordinal", + rubric: { familiarity: 3, accuracy: 4, precision: 4 }, + + fits: (profile) => { + if (!profile.primary.y) return "needs a numeric field" + if (!profile.primary.category) return "needs a category" + if (profile.rowCount / Math.max(profile.categoryCount ?? 1, 1) < 4) return "needs 4+ observations per category" + if (profile.rowCount > 2000) return "too many points for a swarm — consider a violin or box" + return null + }, + + intentScores: { + "distribution": 4, + "outlier-detection": 5, + "compare-categories": 3, + }, + + buildProps: (profile) => ({ + data: profile.data, + categoryAccessor: profile.primary.category, + valueAccessor: profile.primary.y, + ...(profile.primary.series && profile.primary.series !== profile.primary.category ? { colorBy: profile.primary.series } : {}), + }), +} diff --git a/src/components/charts/ordinal/SwimlaneChart.capability.ts b/src/components/charts/ordinal/SwimlaneChart.capability.ts new file mode 100644 index 00000000..37ec89e4 --- /dev/null +++ b/src/components/charts/ordinal/SwimlaneChart.capability.ts @@ -0,0 +1,30 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const SwimlaneChartCapability: ChartCapability = { + component: "SwimlaneChart", + family: "categorical", + importPath: "semiotic/ordinal", + rubric: { familiarity: 3, accuracy: 4, precision: 4 }, + + fits: (profile) => { + if (!profile.primary.category) return "needs a category field" + if (!profile.primary.series) return "needs a sub-category (lane) field" + if (!profile.primary.y) return "needs a numeric value field" + if ((profile.categoryCount ?? 0) < 2) return "needs 2+ categories" + return null + }, + + intentScores: { + "compare-categories": 4, + "composition-over-time": (p) => (p.hasTimeAxis ? 3 : 1), + "compare-series": 3, + }, + + buildProps: (profile) => ({ + data: profile.data, + categoryAccessor: profile.primary.category, + subcategoryAccessor: profile.primary.series, + valueAccessor: profile.primary.y, + colorBy: profile.primary.series, + }), +} diff --git a/src/components/charts/ordinal/ViolinPlot.capability.ts b/src/components/charts/ordinal/ViolinPlot.capability.ts new file mode 100644 index 00000000..6e23c8e0 --- /dev/null +++ b/src/components/charts/ordinal/ViolinPlot.capability.ts @@ -0,0 +1,39 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const ViolinPlotCapability: ChartCapability = { + component: "ViolinPlot", + family: "distribution", + importPath: "semiotic/ordinal", + rubric: { familiarity: 3, accuracy: 4, precision: 3 }, + + fits: (profile) => { + if (!profile.primary.y) return "needs a numeric field" + if (!profile.primary.category) return "needs a category to split distributions" + if (profile.rowCount / Math.max(profile.categoryCount ?? 1, 1) < 6) return "needs 6+ observations per category" + return null + }, + + intentScores: { + "distribution": 5, + "compare-categories": 4, + }, + + variants: [ + { key: "density", label: "Density only", props: { showIQR: false }, tags: ["density"] }, + { + key: "density-iqr", + label: "Density with IQR", + props: { showIQR: true }, + tags: ["density", "iqr"], + intentDeltas: { "distribution": +0 }, + rubricDeltas: { precision: +1 }, + }, + ], + + buildProps: (profile, variant) => ({ + data: profile.data, + categoryAccessor: profile.primary.category, + valueAccessor: profile.primary.y, + ...(variant?.props ?? {}), + }), +} diff --git a/src/components/charts/realtime/RealtimeHeatmap.capability.ts b/src/components/charts/realtime/RealtimeHeatmap.capability.ts new file mode 100644 index 00000000..662ce72f --- /dev/null +++ b/src/components/charts/realtime/RealtimeHeatmap.capability.ts @@ -0,0 +1,35 @@ +import type { StreamChartCapability } from "../../ai/streamingTypes" + +export const RealtimeHeatmapCapability: StreamChartCapability = { + component: "RealtimeHeatmap", + importPath: "semiotic/realtime", + rubric: { familiarity: 2, accuracy: 3, precision: 2 }, + + fits: (schema) => { + if (!schema.fields.some((f) => f.kind === "date" || f.role === "x")) { + return "needs a time field for the x axis" + } + if (!schema.fields.some((f) => f.kind === "numeric" || f.role === "value")) { + return "needs a numeric value field" + } + // Heatmaps shine at higher throughputs where line charts get cluttered + return null + }, + + intentScores: { + // Particularly strong for high-throughput streams where lines would saturate + "trend": (schema) => (schema.throughput === "high" ? 4 : 2), + "distribution": 3, + "change-detection": 3, + "compare-series": (schema) => { + const seriesField = schema.fields.find((f) => f.role === "series" || (f.kind === "categorical" && f.role !== "category")) + return seriesField ? 4 : 1 + }, + }, + + buildProps: (schema) => { + const xField = schema.fields.find((f) => f.role === "x" || f.kind === "date")?.name + const yField = schema.fields.find((f) => f.role === "y" || f.role === "value" || f.kind === "numeric")?.name + return { xAccessor: xField, yAccessor: yField } + }, +} diff --git a/src/components/charts/realtime/RealtimeHistogram.capability.ts b/src/components/charts/realtime/RealtimeHistogram.capability.ts new file mode 100644 index 00000000..90781711 --- /dev/null +++ b/src/components/charts/realtime/RealtimeHistogram.capability.ts @@ -0,0 +1,27 @@ +import type { StreamChartCapability } from "../../ai/streamingTypes" + +export const RealtimeHistogramCapability: StreamChartCapability = { + component: "RealtimeHistogram", + importPath: "semiotic/realtime", + rubric: { familiarity: 3, accuracy: 4, precision: 3 }, + + fits: (schema) => { + if (!schema.fields.some((f) => f.kind === "numeric" || f.role === "value")) { + return "needs a numeric field to bin" + } + return null + }, + + intentScores: { + "distribution": 5, + "outlier-detection": 4, + "change-detection": 2, + }, + + buildProps: (schema) => { + const valueField = schema.fields.find((f) => f.role === "value" || f.kind === "numeric")?.name + return { + valueAccessor: valueField, + } + }, +} diff --git a/src/components/charts/realtime/RealtimeLineChart.capability.ts b/src/components/charts/realtime/RealtimeLineChart.capability.ts new file mode 100644 index 00000000..7647d3d2 --- /dev/null +++ b/src/components/charts/realtime/RealtimeLineChart.capability.ts @@ -0,0 +1,46 @@ +import type { StreamChartCapability } from "../../ai/streamingTypes" + +export const RealtimeLineChartCapability: StreamChartCapability = { + component: "RealtimeLineChart", + importPath: "semiotic/realtime", + rubric: { familiarity: 4, accuracy: 4, precision: 3 }, + + fits: (schema) => { + if (!schema.fields.some((f) => f.kind === "date" || f.role === "x")) { + return "needs a date/time field for the x axis" + } + if (!schema.fields.some((f) => f.kind === "numeric" || f.role === "y" || f.role === "value")) { + return "needs a numeric value field" + } + if (schema.throughput === "high") { + return "for high-throughput streams, prefer RealtimeHeatmap or RealtimeWaterfallChart" + } + return null + }, + + intentScores: { + "trend": 5, + "change-detection": 4, + "compare-series": 3, + "outlier-detection": 2, + }, + + caveats: (schema) => { + const out: string[] = [] + if (schema.retention === "cumulative") { + out.push("cumulative retention will eventually exhaust the buffer — set a windowSize or downsample") + } + return out + }, + + buildProps: (schema) => { + const xField = schema.fields.find((f) => f.role === "x" || f.kind === "date")?.name + const yField = schema.fields.find((f) => f.role === "y" || f.role === "value" || f.kind === "numeric")?.name + const seriesField = schema.fields.find((f) => f.role === "series" || (f.kind === "categorical" && f.role !== "category"))?.name + return { + xAccessor: xField, + yAccessor: yField, + ...(seriesField ? { lineBy: seriesField, colorBy: seriesField } : {}), + } + }, +} diff --git a/src/components/charts/realtime/RealtimeSwarmChart.capability.ts b/src/components/charts/realtime/RealtimeSwarmChart.capability.ts new file mode 100644 index 00000000..807e06b7 --- /dev/null +++ b/src/components/charts/realtime/RealtimeSwarmChart.capability.ts @@ -0,0 +1,31 @@ +import type { StreamChartCapability } from "../../ai/streamingTypes" + +export const RealtimeSwarmChartCapability: StreamChartCapability = { + component: "RealtimeSwarmChart", + importPath: "semiotic/realtime", + rubric: { familiarity: 2, accuracy: 4, precision: 4 }, + + fits: (schema) => { + if (!schema.fields.some((f) => f.kind === "numeric" || f.role === "value")) { + return "needs a numeric field" + } + if (!schema.fields.some((f) => f.kind === "categorical" || f.role === "category")) { + return "needs a category to swarm by" + } + return null + }, + + intentScores: { + "outlier-detection": 5, + "distribution": 4, + "compare-categories": 3, + }, + + caveats: (schema) => (schema.throughput === "high" ? ["high-throughput swarms get crowded — consider RealtimeHistogram"] : []), + + buildProps: (schema) => { + const valueField = schema.fields.find((f) => f.role === "value" || f.kind === "numeric")?.name + const categoryField = schema.fields.find((f) => f.role === "category" || f.kind === "categorical")?.name + return { valueAccessor: valueField, categoryAccessor: categoryField } + }, +} diff --git a/src/components/charts/realtime/RealtimeWaterfallChart.capability.ts b/src/components/charts/realtime/RealtimeWaterfallChart.capability.ts new file mode 100644 index 00000000..919e2dc6 --- /dev/null +++ b/src/components/charts/realtime/RealtimeWaterfallChart.capability.ts @@ -0,0 +1,31 @@ +import type { StreamChartCapability } from "../../ai/streamingTypes" + +export const RealtimeWaterfallChartCapability: StreamChartCapability = { + component: "RealtimeWaterfallChart", + importPath: "semiotic/realtime", + rubric: { familiarity: 2, accuracy: 4, precision: 3 }, + + fits: (schema) => { + if (!schema.fields.some((f) => f.kind === "date" || f.role === "x")) { + return "needs a time field" + } + if (!schema.fields.some((f) => f.kind === "numeric" || f.role === "value")) { + return "needs a numeric value field" + } + return null + }, + + intentScores: { + "change-detection": 5, + "trend": 3, + "outlier-detection": 4, + // Waterfalls work especially well at high throughput + "distribution": (schema) => (schema.throughput === "high" ? 4 : 2), + }, + + buildProps: (schema) => { + const xField = schema.fields.find((f) => f.role === "x" || f.kind === "date")?.name + const valueField = schema.fields.find((f) => f.role === "value" || f.kind === "numeric")?.name + return { xAccessor: xField, yAccessor: valueField } + }, +} diff --git a/src/components/charts/realtime/TemporalHistogram.capability.ts b/src/components/charts/realtime/TemporalHistogram.capability.ts new file mode 100644 index 00000000..c8b49093 --- /dev/null +++ b/src/components/charts/realtime/TemporalHistogram.capability.ts @@ -0,0 +1,37 @@ +import type { StreamChartCapability } from "../../ai/streamingTypes" + +/** + * TemporalHistogram is the bounded sibling of RealtimeHistogram — same chart + * but for static data with a fixed window. For stream selection it competes + * with RealtimeHistogram; the choice depends on retention. + */ +export const TemporalHistogramCapability: StreamChartCapability = { + component: "TemporalHistogram", + importPath: "semiotic/realtime", + rubric: { familiarity: 3, accuracy: 4, precision: 3 }, + + fits: (schema) => { + if (!schema.fields.some((f) => f.kind === "date" || f.role === "x")) { + return "needs a time field" + } + if (!schema.fields.some((f) => f.kind === "numeric" || f.role === "value")) { + return "needs a numeric value field" + } + if (schema.retention === "windowed") { + return "windowed retention is RealtimeHistogram's job; TemporalHistogram serves bounded/cumulative data" + } + return null + }, + + intentScores: { + "distribution": 5, + "change-detection": 3, + "trend": 2, + }, + + buildProps: (schema) => { + const valueField = schema.fields.find((f) => f.role === "value" || f.kind === "numeric")?.name + const xField = schema.fields.find((f) => f.role === "x" || f.kind === "date")?.name + return { valueAccessor: valueField, xAccessor: xField } + }, +} diff --git a/src/components/charts/xy/AreaChart.capability.ts b/src/components/charts/xy/AreaChart.capability.ts new file mode 100644 index 00000000..46648ef0 --- /dev/null +++ b/src/components/charts/xy/AreaChart.capability.ts @@ -0,0 +1,61 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const AreaChartCapability: ChartCapability = { + component: "AreaChart", + family: "time-series", + importPath: "semiotic/xy", + rubric: { familiarity: 4, accuracy: 3, precision: 3 }, + + fits: (profile) => { + if (profile.rowCount < 3) return "needs at least 3 rows" + if (!profile.primary.x) return "needs a numeric or time x field" + if (!profile.primary.y) return "needs a numeric y field" + if (profile.xProvenance === "scatter" && !profile.monotonicX) { + return "needs an ordered/temporal x — given x looks like a scatter pattern, not a sequence" + } + return null + }, + + intentScores: { + "trend": (p) => (p.xProvenance === "scatter" && !p.monotonicX ? 1 : 4), + "composition-over-time": (p) => (p.seriesCount && p.seriesCount >= 2 ? 3 : 1), + "change-detection": (p) => (p.xProvenance === "scatter" && !p.monotonicX ? 1 : 3), + "compare-series": 2, + }, + + variants: [ + { + key: "smooth-gradient", + label: "Smooth gradient area", + props: { curve: "monotoneX", areaOpacity: 0.55, gradientFill: true }, + tags: ["smooth", "gradient", "narrative"], + intentDeltas: { "trend": +1 }, + rubricDeltas: { precision: -1 }, + }, + { + key: "linear", + label: "Linear area", + props: { curve: "linear", areaOpacity: 0.5 }, + tags: ["linear"], + }, + { + key: "stepped", + label: "Stepped area", + props: { curve: "stepAfter", areaOpacity: 0.55 }, + tags: ["step"], + intentDeltas: { "change-detection": +1 }, + }, + ], + + buildProps: (profile, variant) => { + const base: Record = { + data: profile.data, + xAccessor: profile.primary.x, + yAccessor: profile.primary.y, + } + if (profile.hasTimeAxis && profile.primary.x === profile.primary.time) { + base.xScaleType = "time" + } + return { ...base, ...(variant?.props ?? {}) } + }, +} diff --git a/src/components/charts/xy/BubbleChart.capability.ts b/src/components/charts/xy/BubbleChart.capability.ts new file mode 100644 index 00000000..1d2fc325 --- /dev/null +++ b/src/components/charts/xy/BubbleChart.capability.ts @@ -0,0 +1,32 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const BubbleChartCapability: ChartCapability = { + component: "BubbleChart", + family: "relationship", + importPath: "semiotic/xy", + rubric: { familiarity: 3, accuracy: 4, precision: 3 }, + + fits: (profile) => { + if (profile.rowCount < 4) return "needs at least 4 points" + if (!profile.primary.x) return "needs a numeric x field" + if (!profile.primary.y) return "needs a numeric y field" + if (!profile.primary.size) return "needs a third numeric measure for bubble size" + return null + }, + + intentScores: { + "correlation": 4, + "compare-categories": 3, + "outlier-detection": 4, + }, + + caveats: () => ["bubble area is harder to compare than length — large dynamic ranges distort"], + + buildProps: (profile) => ({ + data: profile.data, + xAccessor: profile.primary.x, + yAccessor: profile.primary.y, + sizeBy: profile.primary.size, + ...(profile.primary.series && (profile.seriesCount ?? 0) <= 6 ? { colorBy: profile.primary.series } : {}), + }), +} diff --git a/src/components/charts/xy/CandlestickChart.capability.ts b/src/components/charts/xy/CandlestickChart.capability.ts new file mode 100644 index 00000000..57ed3593 --- /dev/null +++ b/src/components/charts/xy/CandlestickChart.capability.ts @@ -0,0 +1,39 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +const OHLC = ["open", "high", "low", "close"] + +export const CandlestickChartCapability: ChartCapability = { + component: "CandlestickChart", + family: "time-series", + importPath: "semiotic/xy", + rubric: { familiarity: 3, accuracy: 4, precision: 4 }, + + fits: (profile) => { + if (profile.rowCount < 4) return "needs at least 4 rows" + if (!profile.primary.x) return "needs an x field (typically date)" + const fieldNames = new Set(Object.keys(profile.fields).map((f) => f.toLowerCase())) + const haveHigh = fieldNames.has("high") + const haveLow = fieldNames.has("low") + if (!haveHigh || !haveLow) return "needs at minimum high/low fields (open/close optional)" + return null + }, + + intentScores: { + "change-detection": 4, + "trend": 3, + "outlier-detection": 3, + }, + + buildProps: (profile) => { + const fields = Object.keys(profile.fields) + const find = (target: string) => fields.find((f) => f.toLowerCase() === target) + return { + data: profile.data, + xAccessor: profile.primary.x, + highAccessor: find("high"), + lowAccessor: find("low"), + openAccessor: find("open"), + closeAccessor: find("close"), + } + }, +} diff --git a/src/components/charts/xy/ConnectedScatterplot.capability.ts b/src/components/charts/xy/ConnectedScatterplot.capability.ts new file mode 100644 index 00000000..87c6d816 --- /dev/null +++ b/src/components/charts/xy/ConnectedScatterplot.capability.ts @@ -0,0 +1,32 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const ConnectedScatterplotCapability: ChartCapability = { + component: "ConnectedScatterplot", + family: "relationship", + importPath: "semiotic/xy", + rubric: { familiarity: 3, accuracy: 4, precision: 4 }, + + fits: (profile) => { + if (profile.rowCount < 4) return "needs at least 4 ordered points" + if (!profile.primary.x) return "needs an x field" + if (!profile.primary.y) return "needs a y field" + if (!profile.monotonicX && !profile.hasTimeAxis) return "needs an ordered x sequence" + return null + }, + + intentScores: { + "trend": 3, + "correlation": 4, + "change-detection": 3, + }, + + caveats: () => ["readers can confuse path direction without explicit start/end markers"], + + buildProps: (profile) => ({ + data: profile.data, + xAccessor: profile.primary.x, + yAccessor: profile.primary.y, + orderAccessor: profile.primary.time ?? profile.primary.x, + ...(profile.primary.series && (profile.seriesCount ?? 0) <= 6 ? { colorBy: profile.primary.series } : {}), + }), +} diff --git a/src/components/charts/xy/DifferenceChart.capability.ts b/src/components/charts/xy/DifferenceChart.capability.ts new file mode 100644 index 00000000..7ba34c7f --- /dev/null +++ b/src/components/charts/xy/DifferenceChart.capability.ts @@ -0,0 +1,38 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +/** + * DifferenceChart needs exactly two series. Without enough series data we can't fit. + */ +export const DifferenceChartCapability: ChartCapability = { + component: "DifferenceChart", + family: "time-series", + importPath: "semiotic/xy", + rubric: { familiarity: 3, accuracy: 4, precision: 4 }, + + fits: (profile) => { + if (profile.rowCount < 4) return "needs at least 4 rows" + if (!profile.primary.x) return "needs an x field (numeric or time)" + if (!profile.primary.series) return "needs a series field with exactly two groups" + if (profile.seriesCount !== 2) return `needs exactly 2 series (got ${profile.seriesCount ?? 0})` + if (!profile.primary.y) return "needs a numeric y field" + return null + }, + + intentScores: { + "compare-series": 5, + "change-detection": 4, + "trend": 3, + }, + + buildProps: (profile) => { + // DifferenceChart expects two-axis-per-row, so this is a "show A vs B" pre-aggregated form. + // We approximate by passing the raw data plus the accessor; consumers who want true A/B + // shape will pre-pivot. The capability stays generic. + return { + data: profile.data, + xAccessor: profile.primary.x, + seriesAAccessor: profile.primary.y, + seriesBAccessor: profile.primary.y, + } + }, +} diff --git a/src/components/charts/xy/Heatmap.capability.ts b/src/components/charts/xy/Heatmap.capability.ts new file mode 100644 index 00000000..9fad8ea9 --- /dev/null +++ b/src/components/charts/xy/Heatmap.capability.ts @@ -0,0 +1,86 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +/** + * Heatmap is a matrix: categorical × categorical (or temporal × categorical), + * with a numeric encoded as color. Without two genuine discrete dimensions + * for the axes, a heatmap of raw rows is sparse and unreadable. Tuned in + * Phase 2.1 after the scorecard surfaced Heatmap winning unsuitable + * compare-categories rankings. + */ +export const HeatmapCapability: ChartCapability = { + component: "Heatmap", + family: "relationship", + importPath: "semiotic/xy", + rubric: { familiarity: 3, accuracy: 4, precision: 3 }, + + fits: (profile) => { + if (profile.rowCount < 4) return "needs at least 4 cells" + if (!profile.primary.y) return "needs a numeric value to encode in cell color" + // Heatmap needs two discrete axes. Acceptable shapes: + // • 2+ distinct categorical fields (category × category) + // • 1 categorical + 1 time field (category × time) + // • 1 categorical + low-cardinality numeric (≤ 30 distinct values) + const categoricalCount = profile.candidates.category.length + const hasTime = profile.hasTimeAxis + if (categoricalCount < 2 && !(categoricalCount >= 1 && hasTime)) { + return "needs two categorical-or-time dimensions for the axes" + } + const xUnique = profile.uniqueXCount ?? 0 + if (xUnique > 50) return "too many x cells for a legible heatmap" + return null + }, + + intentScores: { + "correlation": 3, + "distribution": 2, + // compare-categories only works when we have a *matrix*, not a 1D categorical comparison + "compare-categories": (p) => { + const catCount = p.candidates.category.length + return catCount >= 2 ? 4 : 1 + }, + "composition-over-time": (p) => (p.hasTimeAxis && p.candidates.category.length >= 1 ? 4 : 1), + }, + + caveats: (p) => { + const out: string[] = [] + if ((p.uniqueXCount ?? 0) > 30) out.push("many x values — cells will be narrow") + return out + }, + + variants: [ + { + key: "default", + label: "Sequential color", + props: {}, + tags: ["sequential"], + }, + { + key: "show-values", + label: "With cell labels", + props: { showValues: true }, + tags: ["labeled"], + intentDeltas: { "compare-categories": +1 }, + rubricDeltas: { precision: +1 }, + caveats: ["cell labels crowd dense matrices"], + }, + ], + + buildProps: (profile, variant) => { + // Prefer category × category if available, else category × time. + const categoricalFields = profile.candidates.category.map((c) => c.field) + const xField = profile.primary.time ?? categoricalFields[0] + const yField = + categoricalFields.find((f) => f !== xField) ?? + categoricalFields[0] ?? + profile.primary.series + const valueField = profile.primary.y + + return { + data: profile.data, + xAccessor: xField, + yAccessor: yField, + valueAccessor: valueField, + ...(variant?.props ?? {}), + } + }, +} diff --git a/src/components/charts/xy/LineChart.capability.ts b/src/components/charts/xy/LineChart.capability.ts new file mode 100644 index 00000000..613ff8be --- /dev/null +++ b/src/components/charts/xy/LineChart.capability.ts @@ -0,0 +1,103 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +/** + * LineChart capability — declares what data shapes LineChart serves well, + * what intents it answers, and what variants change those answers. + * + * Read alongside `LineChart.tsx`; this file is what makes the chart + * "self-aware" for suggestion and interrogation flows. + */ +export const LineChartCapability: ChartCapability = { + component: "LineChart", + family: "time-series", + importPath: "semiotic/xy", + rubric: { familiarity: 5, accuracy: 4, precision: 4 }, + + fits: (profile) => { + if (profile.rowCount < 2) return "needs at least 2 rows" + if (!profile.primary.x) return "needs a numeric or time x field" + if (!profile.primary.y) return "needs a numeric y field" + const xKind = profile.candidates.x.find((c) => c.field === profile.primary.x)?.kind + if (xKind && xKind !== "numeric" && xKind !== "date") return `x field "${profile.primary.x}" is ${xKind}, LineChart needs numeric or time` + // A line chart needs an *ordered* x — connecting points across an arbitrary + // numeric (scatter-fallback x with no monotonicity) is misleading. + if (profile.xProvenance === "scatter" && !profile.monotonicX) { + return "needs an ordered/temporal x — given x looks like a scatter pattern, not a sequence" + } + return null + }, + + intentScores: { + "trend": (p) => { + // A trend needs an *ordered* x — time field, monotonic numeric, or + // an x-named numeric. Scatter-fallback x (just "the other numeric" + // when there are two) doesn't qualify as a trend axis. + if (p.xProvenance === "scatter" && !p.monotonicX) return 1 + return p.uniqueXCount && p.uniqueXCount >= 4 ? 5 : 3 + }, + "compare-series": (p) => { + if (p.xProvenance === "scatter" && !p.monotonicX) return 1 + if (!p.seriesCount || p.seriesCount < 2) return 1 + if (p.seriesCount > 8) return 2 + return 4 + }, + "change-detection": (p) => (p.xProvenance === "scatter" && !p.monotonicX ? 1 : 4), + "outlier-detection": 2, + "correlation": 2, + }, + + caveats: (p) => { + const out: string[] = [] + if (p.hasRepeatedX && (!p.seriesCount || p.seriesCount < 2)) { + out.push("x values repeat — consider aggregating or adding a series field") + } + if (p.seriesCount && p.seriesCount > 8) { + out.push(`${p.seriesCount} series may produce a spaghetti chart`) + } + return out + }, + + variants: [ + { + key: "linear", + label: "Linear trend", + props: { curve: "linear", showPoints: false }, + tags: ["linear"], + }, + { + key: "smooth", + label: "Smooth trend", + description: "Monotone smoothing — emphasizes the shape over individual points.", + props: { curve: "monotoneX", showPoints: false }, + tags: ["smooth", "narrative"], + intentDeltas: { "trend": +1, "outlier-detection": -2 }, + rubricDeltas: { precision: -1 }, + caveats: ["smoothing hides individual outliers"], + }, + { + key: "stepped-with-points", + label: "Discrete steps", + description: "Step curve plus visible points — for state changes or discrete events.", + props: { curve: "step", showPoints: true, pointRadius: 3 }, + tags: ["step", "discrete"], + intentDeltas: { "change-detection": +1, "trend": -1 }, + rubricDeltas: { precision: +1 }, + }, + ], + + buildProps: (profile, variant) => { + const base: Record = { + data: profile.data, + xAccessor: profile.primary.x, + yAccessor: profile.primary.y, + } + if (profile.seriesCount && profile.seriesCount >= 2 && profile.primary.series) { + base.lineBy = profile.primary.series + base.colorBy = profile.primary.series + } + if (profile.hasTimeAxis && profile.primary.x === profile.primary.time) { + base.xScaleType = "time" + } + return { ...base, ...(variant?.props ?? {}) } + }, +} diff --git a/src/components/charts/xy/MinimapChart.capability.ts b/src/components/charts/xy/MinimapChart.capability.ts new file mode 100644 index 00000000..a6fadc6d --- /dev/null +++ b/src/components/charts/xy/MinimapChart.capability.ts @@ -0,0 +1,31 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const MinimapChartCapability: ChartCapability = { + component: "MinimapChart", + family: "time-series", + importPath: "semiotic/xy", + rubric: { familiarity: 4, accuracy: 4, precision: 4 }, + + fits: (profile) => { + if (profile.rowCount < 30) return "minimap pays off only on long sequences (30+ rows)" + if (!profile.primary.x) return "needs an ordered x field" + if (!profile.primary.y) return "needs a numeric y field" + if (profile.xProvenance === "scatter" && !profile.monotonicX) { + return "needs an ordered/temporal x — minimap previews a sequence" + } + return null + }, + + intentScores: { + "trend": 4, + "change-detection": 4, + "outlier-detection": 3, + }, + + buildProps: (profile) => ({ + data: profile.data, + xAccessor: profile.primary.x, + yAccessor: profile.primary.y, + ...(profile.hasTimeAxis && profile.primary.x === profile.primary.time ? { xScaleType: "time" } : {}), + }), +} diff --git a/src/components/charts/xy/MultiAxisLineChart.capability.ts b/src/components/charts/xy/MultiAxisLineChart.capability.ts new file mode 100644 index 00000000..969ff25c --- /dev/null +++ b/src/components/charts/xy/MultiAxisLineChart.capability.ts @@ -0,0 +1,42 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const MultiAxisLineChartCapability: ChartCapability = { + component: "MultiAxisLineChart", + family: "time-series", + importPath: "semiotic/xy", + rubric: { familiarity: 3, accuracy: 3, precision: 3 }, + + fits: (profile) => { + if (profile.rowCount < 4) return "needs at least 4 rows" + if (!profile.primary.x) return "needs an x field" + // Needs 2+ numeric measures with different ranges + const numericFields = Object.entries(profile.fields) + .filter(([f, s]) => s.type === "numeric" && f !== profile.primary.x) + .map(([f]) => f) + if (numericFields.length < 2) return "needs at least 2 numeric measures" + if (profile.xProvenance === "scatter" && !profile.monotonicX) { + return "needs an ordered/temporal x — multi-axis lines need a shared sequence" + } + return null + }, + + intentScores: { + "compare-series": 4, + "trend": 3, + "correlation": 3, + }, + + caveats: () => ["dual axes can mislead — only use when measures share interpretation"], + + buildProps: (profile) => { + const numericFields = Object.entries(profile.fields) + .filter(([f, s]) => s.type === "numeric" && f !== profile.primary.x) + .slice(0, 2) + .map(([f]) => ({ yAccessor: f, label: f })) + return { + data: profile.data, + xAccessor: profile.primary.x, + series: numericFields, + } + }, +} diff --git a/src/components/charts/xy/QuadrantChart.capability.ts b/src/components/charts/xy/QuadrantChart.capability.ts new file mode 100644 index 00000000..d385203c --- /dev/null +++ b/src/components/charts/xy/QuadrantChart.capability.ts @@ -0,0 +1,42 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const QuadrantChartCapability: ChartCapability = { + component: "QuadrantChart", + family: "relationship", + importPath: "semiotic/xy", + rubric: { familiarity: 3, accuracy: 4, precision: 4 }, + + fits: (profile) => { + if (profile.rowCount < 4) return "needs at least 4 points" + if (!profile.primary.x) return "needs a numeric x field" + if (!profile.primary.y) return "needs a numeric y field" + return null + }, + + intentScores: { + // QuadrantChart partitions a 2D plane by thresholds — useful for + // strategy-matrix views (BCG, Eisenhower), not for raw category comparison. + // The two axes should both be meaningful continuous measures. + "compare-categories": 2, + "correlation": 3, + "outlier-detection": 3, + }, + + buildProps: (profile) => { + // Use the median x and y as default split points. + const xField = profile.primary.x! + const yField = profile.primary.y! + const xSummary = profile.fields[xField] + const ySummary = profile.fields[yField] + const xCenter = xSummary?.type === "numeric" ? xSummary.median : undefined + const yCenter = ySummary?.type === "numeric" ? ySummary.median : undefined + return { + data: profile.data, + xAccessor: xField, + yAccessor: yField, + ...(xCenter !== undefined ? { xCenter } : {}), + ...(yCenter !== undefined ? { yCenter } : {}), + ...(profile.primary.series && (profile.seriesCount ?? 0) <= 6 ? { colorBy: profile.primary.series } : {}), + } + }, +} diff --git a/src/components/charts/xy/Scatterplot.capability.ts b/src/components/charts/xy/Scatterplot.capability.ts new file mode 100644 index 00000000..16167ba6 --- /dev/null +++ b/src/components/charts/xy/Scatterplot.capability.ts @@ -0,0 +1,62 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const ScatterplotCapability: ChartCapability = { + component: "Scatterplot", + family: "relationship", + importPath: "semiotic/xy", + rubric: { familiarity: 4, accuracy: 5, precision: 5 }, + + fits: (profile) => { + if (profile.rowCount < 3) return "needs at least 3 rows" + if (!profile.primary.x) return "needs a numeric x field" + if (!profile.primary.y) return "needs a numeric y field" + const xKind = profile.candidates.x.find((c) => c.field === profile.primary.x)?.kind + if (xKind === "date") { + // Time-axis scatter is technically valid but usually a worse choice than a line/area + return null + } + if (xKind && xKind !== "numeric") return `x field "${profile.primary.x}" is ${xKind}, Scatterplot needs numeric` + return null + }, + + intentScores: { + "correlation": 5, + "outlier-detection": 5, + "distribution": 3, + "compare-series": (p) => (p.seriesCount && p.seriesCount >= 2 && p.seriesCount <= 6 ? 3 : 1), + "rank": 1, + }, + + variants: [ + { + key: "points", + label: "Points only", + props: {}, + tags: ["points"], + }, + { + key: "with-trend", + label: "Points with regression line", + props: { regression: "linear" }, + tags: ["regression", "trend"], + // A regression line illuminates the correlation but doesn't make + // Scatterplot a "trend over time" chart — keep delta modest. + intentDeltas: { "correlation": +0, "trend": +1 }, + }, + ], + + buildProps: (profile, variant) => { + const base: Record = { + data: profile.data, + xAccessor: profile.primary.x, + yAccessor: profile.primary.y, + } + if (profile.primary.series && profile.seriesCount && profile.seriesCount <= 6) { + base.colorBy = profile.primary.series + } + if (profile.primary.size) { + base.sizeBy = profile.primary.size + } + return { ...base, ...(variant?.props ?? {}) } + }, +} diff --git a/src/components/charts/xy/StackedAreaChart.capability.ts b/src/components/charts/xy/StackedAreaChart.capability.ts new file mode 100644 index 00000000..8681ccd6 --- /dev/null +++ b/src/components/charts/xy/StackedAreaChart.capability.ts @@ -0,0 +1,69 @@ +import type { ChartCapability } from "../../ai/chartCapabilityTypes" + +export const StackedAreaChartCapability: ChartCapability = { + component: "StackedAreaChart", + family: "time-series", + importPath: "semiotic/xy", + rubric: { familiarity: 4, accuracy: 3, precision: 3 }, + + fits: (profile) => { + if (profile.rowCount < 4) return "needs at least 4 rows" + if (!profile.primary.x) return "needs an ordered x field" + if (!profile.primary.y) return "needs a numeric y field" + if (!profile.seriesCount || profile.seriesCount < 2) return "needs 2+ stack groups (series field)" + if (profile.seriesCount > 10) return `${profile.seriesCount} series is too many to stack legibly` + if (profile.xProvenance === "scatter" && !profile.monotonicX) { + return "needs an ordered/temporal x — stacking only makes sense across a sequence" + } + return null + }, + + intentScores: { + "composition-over-time": 5, + "part-to-whole": (p) => (p.hasTimeAxis ? 4 : 3), + "trend": 3, + "compare-series": 2, + }, + + caveats: () => ["readability of individual layers degrades below the baseline"], + + variants: [ + { + key: "baseline-zero", + label: "Zero baseline", + props: { baseline: "zero", stackOrder: "key" }, + tags: ["zero-baseline"], + }, + { + key: "streamgraph", + label: "Streamgraph", + description: "Wiggle baseline + inside-out ordering — emphasizes momentum over precise totals.", + props: { baseline: "wiggle", stackOrder: "insideOut", showLine: false }, + tags: ["streamgraph", "narrative"], + intentDeltas: { "composition-over-time": +0, "trend": +1, "part-to-whole": -2 }, + rubricDeltas: { accuracy: -1, precision: -1 }, + caveats: ["streamgraph hides absolute totals; precise reads not possible"], + }, + { + key: "centered", + label: "Centered baseline", + props: { baseline: "silhouette", stackOrder: "insideOut" }, + tags: ["silhouette"], + intentDeltas: { "part-to-whole": -1 }, + }, + ], + + buildProps: (profile, variant) => { + const base: Record = { + data: profile.data, + xAccessor: profile.primary.x, + yAccessor: profile.primary.y, + areaBy: profile.primary.series, + colorBy: profile.primary.series, + } + if (profile.hasTimeAxis && profile.primary.x === profile.primary.time) { + base.xScaleType = "time" + } + return { ...base, ...(variant?.props ?? {}) } + }, +} diff --git a/src/components/data/DataSummarizer.test.ts b/src/components/data/DataSummarizer.test.ts new file mode 100644 index 00000000..138c83e1 --- /dev/null +++ b/src/components/data/DataSummarizer.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest" +import { summarizeData } from "./DataSummarizer" + +describe("summarizeData", () => { + it("summarizes numeric fields with min/max/mean/median", () => { + const data = [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + { x: 3, y: 30 }, + { x: 4, y: 40 }, + ] + const summary = summarizeData(data) + expect(summary.rowCount).toBe(4) + const x = summary.fields.x + expect(x.type).toBe("numeric") + if (x.type === "numeric") { + expect(x.min).toBe(1) + expect(x.max).toBe(4) + expect(x.mean).toBe(2.5) + expect(x.median).toBe(2.5) + } + }) + + it("summarizes categorical fields with top values and distinct count", () => { + const data = [ + { category: "A" }, + { category: "A" }, + { category: "B" }, + { category: "C" }, + ] + const summary = summarizeData(data) + const c = summary.fields.category + expect(c.type).toBe("categorical") + if (c.type === "categorical") { + expect(c.distinctCount).toBe(3) + expect(c.topValues[0]).toEqual({ value: "A", count: 2 }) + expect(c.distinctValues).toEqual(["A", "B", "C"]) + } + }) + + it("detects ISO-like date strings", () => { + const data = [{ d: "2024-01-15" }, { d: "2024-06-30" }] + const summary = summarizeData(data) + const d = summary.fields.d + expect(d.type).toBe("date") + if (d.type === "date") { + expect(d.min.startsWith("2024-01-15")).toBe(true) + expect(d.max.startsWith("2024-06-30")).toBe(true) + } + }) + + it("handles Date instances", () => { + const data = [{ d: new Date("2024-01-01") }, { d: new Date("2024-12-31") }] + const summary = summarizeData(data) + expect(summary.fields.d.type).toBe("date") + }) + + it("handles empty data gracefully", () => { + const summary = summarizeData([]) + expect(summary.rowCount).toBe(0) + expect(summary.fields).toEqual({}) + expect(summary.sample).toEqual([]) + }) + + it("handles null/undefined input", () => { + expect(summarizeData(null).rowCount).toBe(0) + expect(summarizeData(undefined).rowCount).toBe(0) + }) + + it("discovers fields across ragged rows", () => { + const data = [{ a: 1 }, { b: 2 }, { a: 3, b: 4 }] + const summary = summarizeData(data) + expect(Object.keys(summary.fields).sort()).toEqual(["a", "b"]) + }) + + it("scales to large numeric arrays without stack overflow", () => { + const data = Array.from({ length: 200_000 }, (_, i) => ({ v: i })) + const summary = summarizeData(data) + const v = summary.fields.v + expect(v.type).toBe("numeric") + if (v.type === "numeric") { + expect(v.min).toBe(0) + expect(v.max).toBe(199_999) + } + }) + + it("limits sample to sampleSize", () => { + const data = Array.from({ length: 50 }, (_, i) => ({ i })) + const summary = summarizeData(data, { sampleSize: 3 }) + expect(summary.sample.length).toBe(3) + }) + + it("returns 'unknown' for fields with only null values", () => { + const data = [{ x: null }, { x: null }] + expect(summarizeData(data).fields.x.type).toBe("unknown") + }) +}) diff --git a/src/components/data/DataSummarizer.ts b/src/components/data/DataSummarizer.ts new file mode 100644 index 00000000..e65ad6ea --- /dev/null +++ b/src/components/data/DataSummarizer.ts @@ -0,0 +1,184 @@ +import type { Datum } from "../charts/shared/datumTypes" + +export type FieldType = "numeric" | "categorical" | "date" | "unknown" + +export interface NumericFieldSummary { + type: "numeric" + min: number + max: number + mean: number + median: number +} + +export interface DateFieldSummary { + type: "date" + min: string + max: string +} + +export interface CategoricalFieldSummary { + type: "categorical" + distinctCount: number + topValues: ReadonlyArray<{ value: string; count: number }> + distinctValues?: ReadonlyArray +} + +export interface UnknownFieldSummary { + type: "unknown" +} + +export type FieldSummary = + | NumericFieldSummary + | DateFieldSummary + | CategoricalFieldSummary + | UnknownFieldSummary + +export interface DataSummary { + rowCount: number + fields: Record + sample: ReadonlyArray +} + +export interface SummarizeOptions { + maxDistinct?: number + sampleSize?: number + /** Scan up to this many rows when discovering field keys (handles ragged rows). */ + keyScanRows?: number +} + +const DATE_LIKE = /^\d{4}[-/]\d{2}/ + +function inferType(val: unknown): FieldType { + if (typeof val === "number") return Number.isFinite(val) ? "numeric" : "unknown" + if (val instanceof Date) return "date" + if (typeof val === "string") { + if (DATE_LIKE.test(val) && !Number.isNaN(Date.parse(val))) return "date" + return "categorical" + } + if (typeof val === "boolean") return "categorical" + return "unknown" +} + +function minMax(values: ReadonlyArray): { min: number; max: number } { + // Avoid Math.min(...values) — spread overflows the call stack around ~100k items. + let min = Infinity + let max = -Infinity + for (let i = 0; i < values.length; i++) { + const v = values[i] + if (v < min) min = v + if (v > max) max = v + } + return { min, max } +} + +function median(sorted: ReadonlyArray): number { + const n = sorted.length + if (n === 0) return NaN + const mid = n >> 1 + return n % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] +} + +/** + * Summarize a dataset for an LLM. Returns row count, per-field statistics, and a small sample. + * + * Designed so a model can answer questions about ranges, peaks, distributions, and categories + * without seeing the full dataset. + */ +export function summarizeData( + data: ReadonlyArray | null | undefined, + options: SummarizeOptions = {} +): DataSummary { + const { maxDistinct = 10, sampleSize = 5, keyScanRows = 100 } = options + + if (!Array.isArray(data) || data.length === 0) { + return { rowCount: 0, fields: {}, sample: [] } + } + + // Discover keys across the first N rows so ragged data doesn't drop fields. + const keys = new Set() + const scanLimit = Math.min(data.length, keyScanRows) + for (let i = 0; i < scanLimit; i++) { + const row = data[i] + if (row && typeof row === "object") { + for (const k of Object.keys(row)) keys.add(k) + } + } + + const fields: Record = {} + + for (const key of keys) { + const raw: unknown[] = [] + for (let i = 0; i < data.length; i++) { + const v = data[i]?.[key] + if (v != null) raw.push(v) + } + + if (raw.length === 0) { + fields[key] = { type: "unknown" } + continue + } + + const type = inferType(raw[0]) + + if (type === "numeric") { + const nums: number[] = [] + for (let i = 0; i < raw.length; i++) { + const n = Number(raw[i]) + if (Number.isFinite(n)) nums.push(n) + } + if (nums.length === 0) { + fields[key] = { type: "unknown" } + continue + } + const { min, max } = minMax(nums) + let sum = 0 + for (let i = 0; i < nums.length; i++) sum += nums[i] + const sorted = [...nums].sort((a, b) => a - b) + fields[key] = { + type: "numeric", + min, + max, + mean: sum / nums.length, + median: median(sorted), + } + } else if (type === "date") { + const times: number[] = [] + for (let i = 0; i < raw.length; i++) { + const v = raw[i] + const t = v instanceof Date ? v.getTime() : Date.parse(v as string) + if (Number.isFinite(t)) times.push(t) + } + if (times.length === 0) { + fields[key] = { type: "unknown" } + continue + } + const { min, max } = minMax(times) + fields[key] = { + type: "date", + min: new Date(min).toISOString(), + max: new Date(max).toISOString(), + } + } else if (type === "categorical") { + const counts = new Map() + for (let i = 0; i < raw.length; i++) { + const v = String(raw[i]) + counts.set(v, (counts.get(v) ?? 0) + 1) + } + const topValues = [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, maxDistinct) + .map(([value, count]) => ({ value, count })) + fields[key] = { + type: "categorical", + distinctCount: counts.size, + topValues, + distinctValues: + counts.size <= maxDistinct ? topValues.map((v) => v.value) : undefined, + } + } else { + fields[key] = { type: "unknown" } + } + } + + return { rowCount: data.length, fields, sample: data.slice(0, sampleSize) } +} diff --git a/src/components/semiotic-ai.ts b/src/components/semiotic-ai.ts index 56ae4368..f1c04050 100644 --- a/src/components/semiotic-ai.ts +++ b/src/components/semiotic-ai.ts @@ -106,6 +106,175 @@ export type { SerializedSelections, SerializedSelection, SerializedFieldSelectio export { fromVegaLite } from "./data/fromVegaLite" export type { VegaLiteSpec, VegaLiteEncoding } from "./data/fromVegaLite" +// AI interrogation — headless hook + data summary +export { useChartInterrogation } from "./store/useChartInterrogation" +export type { + UseChartInterrogationOptions, + UseChartInterrogationResult, + InterrogationContext, + InterrogationFocus, + InterrogationResult, + InterrogationQuery, + InterrogationMessage, +} from "./store/useChartInterrogation" +export { useChartFocus } from "./store/useChartFocus" +export type { UseChartFocusOptions } from "./store/useChartFocus" +export { summarizeData } from "./data/DataSummarizer" +export type { + DataSummary, + FieldSummary, + FieldType, + NumericFieldSummary, + DateFieldSummary, + CategoricalFieldSummary, + UnknownFieldSummary, + SummarizeOptions, +} from "./data/DataSummarizer" + +// Chart capability layer — heuristic recommendations + intent taxonomy +export { profileData } from "./ai/profileData" +export type { ProfileDataOptions } from "./ai/profileData" +export { suggestCharts, scoreChart, explainCapabilityFit } from "./ai/suggestCharts" +export type { + SuggestChartsOptions, + RejectedCapability, + ExplainCapabilityFitResult, +} from "./ai/suggestCharts" +export { inferIntent } from "./ai/inferIntent" +export type { InferIntentResult } from "./ai/inferIntent" +export { suggestDashboard } from "./ai/suggestDashboard" +export type { + DashboardPanel, + DashboardSuggestion, + SuggestDashboardOptions, +} from "./ai/suggestDashboard" + +// Audience-aware suggestion + literacy-growth surface +export { + applyAudienceBias, + effectiveFamiliarity, + stretchFamiliarityCeiling, +} from "./ai/audienceProfile" +export type { + AudienceProfile, + AudienceTarget, + AudienceBiasResult, +} from "./ai/audienceProfile" +export { + executivePersona, + analystPersona, + dataScientistPersona, + BUILT_IN_AUDIENCES, +} from "./ai/audiences" +export { suggestStretchCharts } from "./ai/suggestStretchCharts" +export type { + StretchSuggestion, + SuggestStretchChartsOptions, +} from "./ai/suggestStretchCharts" + +// Streaming intent — parallel API for live charts (schema-based, not row-based) +export { + suggestStreamCharts, + registerStreamChartCapability, + unregisterStreamChartCapability, + getStreamCapabilities, +} from "./ai/suggestStreamCharts" +export type { SuggestStreamChartsOptions } from "./ai/suggestStreamCharts" +export type { + StreamSchema, + StreamFieldSchema, + StreamFieldKind, + StreamChartCapability, + StreamIntentScorer, + StreamSuggestion, +} from "./ai/streamingTypes" +export { diffProfile } from "./ai/diffProfile" +export type { ProfileDiff, FieldTypeChange, PrimaryRoleChange, PrimaryRole } from "./ai/diffProfile" +export { repairChartConfig } from "./ai/repairChartConfig" +export type { + RepairResult, + RepairOkResult, + RepairAlternativeResult, + RepairUnknownResult, + RepairOptions, +} from "./ai/repairChartConfig" +export { runQualityScorecard } from "./ai/qualityScorecard" +export type { + ScorecardFixture, + ScorecardReport, + PerCapabilityScore, + PerFixtureScore, +} from "./ai/qualityScorecard" +export { CANONICAL_FIXTURES } from "./ai/qualityFixtures" +export { useChartSuggestions } from "./ai/useChartSuggestions" +export type { UseChartSuggestionsOptions, UseChartSuggestionsResult } from "./ai/useChartSuggestions" +export { + getCapabilities, + getCapability, + registerChartCapability, + unregisterChartCapability, + // XY + LineChartCapability, + AreaChartCapability, + StackedAreaChartCapability, + ScatterplotCapability, + ConnectedScatterplotCapability, + BubbleChartCapability, + QuadrantChartCapability, + MultiAxisLineChartCapability, + MinimapChartCapability, + DifferenceChartCapability, + CandlestickChartCapability, + HeatmapCapability, + // Ordinal + BarChartCapability, + GroupedBarChartCapability, + StackedBarChartCapability, + DotPlotCapability, + PieChartCapability, + DonutChartCapability, + FunnelChartCapability, + GaugeChartCapability, + LikertChartCapability, + SwimlaneChartCapability, + // Distribution + HistogramCapability, + BoxPlotCapability, + SwarmPlotCapability, + ViolinPlotCapability, + RidgelinePlotCapability, + // Network + ForceDirectedGraphCapability, + SankeyDiagramCapability, + ChordDiagramCapability, + ProcessSankeyCapability, + // Hierarchy + TreeDiagramCapability, + TreemapCapability, + CirclePackCapability, + OrbitDiagramCapability, + // Geo + ChoroplethMapCapability, + ProportionalSymbolMapCapability, + FlowMapCapability, + DistanceCartogramCapability, +} from "./ai/chartCapabilities" +export type { + ChartCapability, + ChartDataProfile, + ChartFamily, + ChartImportPath, + ChartRubric, + ChartVariant, + FieldCandidate, + FieldKind, + FitResult, + IntentScorer, + Suggestion, +} from "./ai/chartCapabilityTypes" +export { listIntents, getIntent, registerIntent, BUILT_IN_INTENT_IDS } from "./ai/intents" +export type { BuiltInIntentId, IntentId, IntentDescriptor } from "./ai/intents" + // AI Observation hooks export { useChartObserver } from "./store/useObservation" export type { UseChartObserverOptions, UseChartObserverResult } from "./store/useObservation" diff --git a/src/components/store/useChartFocus.test.tsx b/src/components/store/useChartFocus.test.tsx new file mode 100644 index 00000000..4694a53a --- /dev/null +++ b/src/components/store/useChartFocus.test.tsx @@ -0,0 +1,103 @@ +import React from "react" +import { renderHook, act } from "@testing-library/react" +import { describe, it, expect } from "vitest" +import { useChartFocus } from "./useChartFocus" +import { ObservationProvider, useObservationSelector } from "./ObservationStore" +import type { ChartObservation, ObservationStoreState } from "./ObservationStore" + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +function makeHover(overrides: Partial = {}): ChartObservation { + return { + type: "hover", + datum: { month: 4, revenue: 32 }, + x: 100, + y: 200, + timestamp: Date.now(), + chartType: "line", + ...overrides, + } as ChartObservation +} + +function makeHoverEnd(overrides: Partial = {}): ChartObservation { + return { + type: "hover-end", + timestamp: Date.now(), + chartType: "line", + ...overrides, + } as ChartObservation +} + +function useFocusWithPush(options?: Parameters[0]) { + const focus = useChartFocus(options) + const push = useObservationSelector((s: ObservationStoreState) => s.pushObservation) + return { focus, push } +} + +describe("useChartFocus", () => { + it("returns null with no observations", () => { + const { result } = renderHook(() => useChartFocus(), { wrapper }) + expect(result.current).toBeNull() + }) + + it("converts the latest hover into a focus object", () => { + const { result } = renderHook(() => useFocusWithPush(), { wrapper }) + act(() => { + result.current.push(makeHover()) + }) + expect(result.current.focus).toEqual({ + datum: { month: 4, revenue: 32 }, + x: 100, + y: 200, + source: "hover", + }) + }) + + it("clears focus on hover-end", () => { + const { result } = renderHook(() => useFocusWithPush(), { wrapper }) + act(() => { + result.current.push(makeHover({ timestamp: 1 })) + result.current.push(makeHoverEnd({ timestamp: 2 })) + }) + expect(result.current.focus).toBeNull() + }) + + it("respects type filter — click-only mode ignores hovers", () => { + const { result } = renderHook(() => useFocusWithPush({ types: ["click"] }), { wrapper }) + act(() => { + result.current.push(makeHover()) + }) + expect(result.current.focus).toBeNull() + }) + + it("filters by chartId when set", () => { + const { result } = renderHook( + () => useFocusWithPush({ chartId: "chartA" }), + { wrapper }, + ) + act(() => { + result.current.push(makeHover({ chartId: "chartB" })) + }) + expect(result.current.focus).toBeNull() + + act(() => { + result.current.push(makeHover({ chartId: "chartA", datum: { id: 1 } })) + }) + expect(result.current.focus?.datum).toEqual({ id: 1 }) + }) + + it("does not error when latest observation has no datum", () => { + const { result } = renderHook(() => useFocusWithPush(), { wrapper }) + act(() => { + result.current.push({ + type: "hover", + timestamp: Date.now(), + chartType: "line", + // no datum + } as ChartObservation) + }) + expect(result.current.focus).toBeNull() + }) +}) diff --git a/src/components/store/useChartFocus.ts b/src/components/store/useChartFocus.ts new file mode 100644 index 00000000..c790f35e --- /dev/null +++ b/src/components/store/useChartFocus.ts @@ -0,0 +1,83 @@ +"use client" +import { useMemo } from "react" +import { useChartObserver } from "./useObservation" +import type { ChartObservation } from "./ObservationStore" +import type { InterrogationFocus } from "./useChartInterrogation" + +export interface UseChartFocusOptions { + /** Limit attention to a specific chart instance. Required when the page has more than one. */ + chartId?: string + /** + * Which observation types count as "focused." Default is hover + click + + * selection — anything that signals user attention. Set to ["click"] for + * sticky-focus UIs where hover doesn't change the AI's reference point. + */ + types?: ChartObservation["type"][] +} + +/** + * Default observation types this hook subscribes to. The "-end" variants + * are included so a hover-out / click-elsewhere event can *clear* an + * existing focus rather than leaving it stuck on the previous datum. + */ +const DEFAULT_FOCUS_TYPES: ChartObservation["type"][] = [ + "hover", + "hover-end", + "click", + "click-end", + "selection", + "selection-end", +] + +/** + * Convenience hook: returns the latest `InterrogationFocus` for use with + * `useChartInterrogation`'s `focus` option. Internally subscribes to the + * observation store and converts the latest matching observation into the + * focus shape. + * + * Pair with `` and an + * `` ancestor. + * + * Returns `null` when no qualifying observation has fired yet. + * + * @example + * function ChartWithChat({ data }) { + * const focus = useChartFocus({ chartId: "sales" }) + * const { ask, history, annotations } = useChartInterrogation({ + * data, + * focus, // ← latest hovered/clicked datum threads in + * onQuery: async (q, ctx) => { + * // ctx.focus is the same `focus` value passed above + * return askLLM({ question: q, focus: ctx.focus, summary: ctx.summary }) + * }, + * }) + * return ( + * <> + * + * + * + * ) + * } + */ +export function useChartFocus(options: UseChartFocusOptions = {}): InterrogationFocus | null { + const { chartId, types = DEFAULT_FOCUS_TYPES } = options + const { latest } = useChartObserver({ chartId, types, limit: 1 }) + + return useMemo(() => { + if (!latest) return null + // Hover-end / selection-end observations carry no datum and should not + // create a focus — they mean "user moved away," not "user focused on + // something new." Treat them as cleared focus. + if (latest.type === "hover-end" || latest.type === "selection-end" || latest.type === "brush-end") { + return null + } + const datum = (latest as { datum?: unknown }).datum + if (!datum || typeof datum !== "object") return null + return { + datum: datum as Record, + x: (latest as { x?: number }).x, + y: (latest as { y?: number }).y, + source: latest.type as InterrogationFocus["source"], + } + }, [latest]) +} diff --git a/src/components/store/useChartInterrogation.test.tsx b/src/components/store/useChartInterrogation.test.tsx new file mode 100644 index 00000000..d565fd4b --- /dev/null +++ b/src/components/store/useChartInterrogation.test.tsx @@ -0,0 +1,247 @@ +import { renderHook, act, waitFor } from "@testing-library/react" +import { describe, it, expect, vi } from "vitest" +import { useChartInterrogation } from "./useChartInterrogation" +import type { InterrogationQuery } from "./useChartInterrogation" + +const data = [ + { month: "Jan", revenue: 100 }, + { month: "Feb", revenue: 200 }, + { month: "Mar", revenue: 150 }, +] + +describe("useChartInterrogation", () => { + it("exposes a memoized summary derived from data", () => { + const onQuery: InterrogationQuery = async () => ({ answer: "" }) + const { result } = renderHook(() => useChartInterrogation({ data, onQuery })) + expect(result.current.summary.rowCount).toBe(3) + expect(result.current.summary.fields.revenue.type).toBe("numeric") + }) + + it("appends user and assistant messages on ask()", async () => { + const onQuery: InterrogationQuery = async () => ({ answer: "Peak in Feb." }) + const { result } = renderHook(() => useChartInterrogation({ data, onQuery })) + await act(async () => { + await result.current.ask("when is the peak?") + }) + expect(result.current.history).toEqual([ + { role: "user", text: "when is the peak?" }, + { role: "assistant", text: "Peak in Feb." }, + ]) + expect(result.current.loading).toBe(false) + }) + + it("forwards data, summary, componentName, and props to onQuery", async () => { + const onQuery = vi.fn().mockResolvedValue({ answer: "ok" }) + const { result } = renderHook(() => + useChartInterrogation({ + data, + onQuery, + componentName: "LineChart", + props: { xAccessor: "month", yAccessor: "revenue" }, + }) + ) + await act(async () => { + await result.current.ask("hi") + }) + const [query, ctx] = onQuery.mock.calls[0] + expect(query).toBe("hi") + expect(ctx.componentName).toBe("LineChart") + expect(ctx.props).toEqual({ xAccessor: "month", yAccessor: "revenue" }) + expect(ctx.summary.rowCount).toBe(3) + expect(ctx.data).toBe(data) + }) + + it("merges initial and AI annotations", async () => { + const onQuery: InterrogationQuery = async () => ({ + answer: "marking peak", + annotations: [{ type: "callout", month: "Feb", revenue: 200 }], + }) + const initialAnnotations = [{ type: "label", month: "Jan" }] + const { result } = renderHook(() => + useChartInterrogation({ data, onQuery, initialAnnotations }) + ) + await act(async () => { + await result.current.ask("peak?") + }) + expect(result.current.annotations).toHaveLength(2) + expect(result.current.annotations[0]).toMatchObject({ type: "label" }) + expect(result.current.annotations[1]).toMatchObject({ type: "callout" }) + }) + + it("ignores blank queries", async () => { + const onQuery = vi.fn().mockResolvedValue({ answer: "" }) + const { result } = renderHook(() => useChartInterrogation({ data, onQuery })) + await act(async () => { + await result.current.ask(" ") + }) + expect(onQuery).not.toHaveBeenCalled() + expect(result.current.history).toHaveLength(0) + }) + + it("captures errors without throwing", async () => { + const onQuery: InterrogationQuery = async () => { + throw new Error("LLM offline") + } + const { result } = renderHook(() => useChartInterrogation({ data, onQuery })) + await act(async () => { + await result.current.ask("anything") + }) + expect(result.current.error?.message).toBe("LLM offline") + expect(result.current.history.at(-1)?.role).toBe("assistant") + }) + + it("flips loading during the in-flight query", async () => { + let resolve: (v: { answer: string }) => void = () => {} + const onQuery: InterrogationQuery = () => + new Promise((r) => { + resolve = r + }) + const { result } = renderHook(() => useChartInterrogation({ data, onQuery })) + act(() => { + void result.current.ask("hi") + }) + await waitFor(() => expect(result.current.loading).toBe(true)) + await act(async () => { + resolve({ answer: "done" }) + }) + await waitFor(() => expect(result.current.loading).toBe(false)) + }) + + it("reset() clears history, annotations, and error", async () => { + const onQuery: InterrogationQuery = async () => ({ + answer: "x", + annotations: [{ type: "callout" }], + }) + const { result } = renderHook(() => useChartInterrogation({ data, onQuery })) + await act(async () => { + await result.current.ask("q") + }) + expect(result.current.history.length).toBe(2) + act(() => result.current.reset()) + expect(result.current.history).toEqual([]) + expect(result.current.annotations).toEqual([]) + expect(result.current.error).toBeNull() + }) + + it("forwards focus to onQuery when set", async () => { + const onQuery = vi.fn().mockResolvedValue({ answer: "about feb" }) + const focus = { + datum: { month: "Feb", revenue: 200 }, + x: 120, + y: 80, + source: "click" as const, + } + const { result } = renderHook(() => useChartInterrogation({ data, onQuery, focus })) + await act(async () => { + await result.current.ask("why this point?") + }) + expect(onQuery.mock.calls[0][1].focus).toEqual(focus) + }) + + it("omits focus from context when not set", async () => { + const onQuery = vi.fn().mockResolvedValue({ answer: "ok" }) + const { result } = renderHook(() => useChartInterrogation({ data, onQuery })) + await act(async () => { + await result.current.ask("anything") + }) + expect(onQuery.mock.calls[0][1].focus).toBeUndefined() + }) + + it("passes the *latest* focus to ask(), not the focus at hook-creation time", async () => { + const onQuery = vi.fn().mockResolvedValue({ answer: "ok" }) + let focus: { datum: Record } | null = { + datum: { month: "Feb", revenue: 200 }, + } + const { result, rerender } = renderHook(() => useChartInterrogation({ data, onQuery, focus })) + // Update focus before asking + focus = { datum: { month: "Mar", revenue: 150 } } + rerender() + await act(async () => { + await result.current.ask("about this") + }) + expect(onQuery.mock.calls[0][1].focus?.datum.month).toBe("Mar") + }) + + describe("announce()", () => { + const onQuery: InterrogationQuery = async () => ({ answer: "" }) + + it("appends an assistant-only message to the transcript", () => { + const { result } = renderHook(() => useChartInterrogation({ data, onQuery })) + act(() => { + result.current.announce({ text: "Spike detected at 14:32" }) + }) + expect(result.current.history).toEqual([ + { role: "assistant", text: "Spike detected at 14:32" }, + ]) + }) + + it("does not call onQuery", async () => { + const spy = vi.fn().mockResolvedValue({ answer: "" }) + const { result } = renderHook(() => useChartInterrogation({ data, onQuery: spy })) + act(() => { + result.current.announce({ text: "Proactive note" }) + }) + expect(spy).not.toHaveBeenCalled() + }) + + it("APPENDS annotations (unlike ask which replaces them)", () => { + const { result } = renderHook(() => useChartInterrogation({ data, onQuery })) + act(() => { + result.current.announce({ + text: "First spike", + annotations: [{ type: "callout", ts: 1, label: "A" }], + }) + }) + act(() => { + result.current.announce({ + text: "Second spike", + annotations: [{ type: "callout", ts: 2, label: "B" }], + }) + }) + expect(result.current.annotations).toHaveLength(2) + expect(result.current.annotations.map((a) => a.label)).toEqual(["A", "B"]) + }) + + it("ignores empty / whitespace-only messages", () => { + const { result } = renderHook(() => useChartInterrogation({ data, onQuery })) + act(() => { + result.current.announce({ text: " " }) + }) + expect(result.current.history).toEqual([]) + }) + + it("interleaves cleanly with ask()", async () => { + const spyQuery: InterrogationQuery = async () => ({ + answer: "user answer", + annotations: [{ type: "callout", label: "user-pick" }], + }) + const { result } = renderHook(() => useChartInterrogation({ data, onQuery: spyQuery })) + act(() => { + result.current.announce({ + text: "Watcher: spike", + annotations: [{ type: "callout", label: "watcher" }], + }) + }) + await act(async () => { + await result.current.ask("what was that?") + }) + // ask() REPLACES annotations; the watcher's annotation is gone after a fresh ask + expect(result.current.annotations.map((a) => a.label)).toEqual(["user-pick"]) + // History interleaves + expect(result.current.history.map((m) => m.role)).toEqual(["assistant", "user", "assistant"]) + }) + + it("reset() clears announcements", () => { + const { result } = renderHook(() => useChartInterrogation({ data, onQuery })) + act(() => { + result.current.announce({ + text: "Note", + annotations: [{ type: "callout" }], + }) + }) + act(() => result.current.reset()) + expect(result.current.history).toEqual([]) + expect(result.current.annotations).toEqual([]) + }) + }) +}) diff --git a/src/components/store/useChartInterrogation.ts b/src/components/store/useChartInterrogation.ts new file mode 100644 index 00000000..e33b84f1 --- /dev/null +++ b/src/components/store/useChartInterrogation.ts @@ -0,0 +1,270 @@ +"use client" +import { useCallback, useMemo, useRef, useState } from "react" +import type { Datum } from "../charts/shared/datumTypes" +import { summarizeData, type DataSummary } from "../data/DataSummarizer" +import { profileData } from "../ai/profileData" +import { suggestCharts } from "../ai/suggestCharts" +import type { ChartDataProfile, Suggestion } from "../ai/chartCapabilityTypes" +import type { IntentId } from "../ai/intents" + +/** + * Identifies a single point of interest on the chart — typically the datum + * the user is currently hovering, clicked, or otherwise focused on. When + * provided, the LLM gets the explicit signal that the user is asking + * "about *this specific point*" rather than the chart at large. + */ +export interface InterrogationFocus { + /** The row the user is focused on. */ + datum: Datum + /** Pixel x coordinate, when known. Useful for anchoring response annotations. */ + x?: number + /** Pixel y coordinate, when known. */ + y?: number + /** Optional source label — "hover" / "click" / "selection". Surfaces in the LLM prompt. */ + source?: "hover" | "click" | "selection" | "manual" +} + +export interface InterrogationContext { + /** The data extracted from the chart (or whatever caller passed in). */ + data: ReadonlyArray + /** Statistical summary, ready to send to an LLM. */ + summary: DataSummary + /** Shape profile — present when `includeProfile` or `includeSuggestions` is enabled. */ + profile?: ChartDataProfile + /** Heuristic chart suggestions — present when `includeSuggestions` is enabled. */ + suggestions?: ReadonlyArray + /** Optional caller-supplied chart component name (e.g. "LineChart"). */ + componentName?: string + /** Optional caller-supplied chart props (accessor names, scales, etc.). */ + props?: Record + /** + * The current focused datum — what the user is interactively pointing at. + * Lets the LLM tailor responses to a specific point ("why is *this* one + * higher than the rest?") and to anchor visual responses (callouts, + * comments) back at the same coordinates. + */ + focus?: InterrogationFocus +} + +export interface InterrogationResult { + /** Natural-language answer to display to the user. */ + answer: string + /** Optional Semiotic annotations to overlay on the chart. */ + annotations?: ReadonlyArray +} + +export type InterrogationQuery = ( + query: string, + context: InterrogationContext +) => Promise + +export interface InterrogationMessage { + role: "user" | "assistant" + text: string +} + +export interface UseChartInterrogationOptions { + /** Data backing the chart. Use whatever shape the chart consumes (rows, nodes, etc.). */ + data: ReadonlyArray | null | undefined + /** Async handler — typically calls your LLM with the query + summary. */ + onQuery: InterrogationQuery + /** Annotations to seed the merged set (e.g. existing chart annotations). */ + initialAnnotations?: ReadonlyArray + /** Optional context passed through to onQuery for richer prompts. */ + componentName?: string + /** Optional context passed through to onQuery. */ + props?: Record + /** + * Include the shape `profile` in the interrogation context. Required to let an LLM + * reason about candidate axes, distinct counts, hierarchy/network/geo detection, etc. + */ + includeProfile?: boolean + /** + * Include heuristic chart `suggestions` in the interrogation context. Implies `includeProfile`. + * Lets an LLM answer "would another chart show this better?" without re-deriving rules. + */ + includeSuggestions?: boolean + /** When `includeSuggestions` is true, rank by this intent. */ + suggestionsIntent?: IntentId | IntentId[] + /** When `includeSuggestions` is true, cap the suggestion list. Default 5. */ + suggestionsMax?: number + /** + * The point on the chart the user is currently focused on. Forwarded to + * onQuery so an LLM can answer "about this specific datum" rather than + * "about the chart in general." Typically wired from a chart's + * `onObservation` callback or the convenience `useChartFocus` hook. + */ + focus?: InterrogationFocus | null +} + +export interface UseChartInterrogationResult { + /** Ask a question. Updates history, annotations, loading, and error. */ + ask: (query: string) => Promise + /** + * Append an AI-initiated message to the transcript without a user query. + * + * Use for proactive narration — a streaming watcher that detected an + * anomaly, a background analysis that surfaced an insight, an LLM that + * decided to volunteer information mid-session. Synchronous; no `onQuery` + * call. Annotations merge into the chart's `annotations` array like + * any other AI response. + * + * @example + * announce({ + * text: "Spike detected at 14:32 — 3.2σ above rolling mean.", + * annotations: [{ type: "callout", ts: now, value: 850, note: "Slow query?" }], + * }) + */ + announce: (message: { text: string; annotations?: ReadonlyArray }) => void + /** Conversation history, oldest first. */ + history: ReadonlyArray + /** Statistical summary of the data — memoized, safe to pass to a prompt. */ + summary: DataSummary + /** Merged annotations: initial + latest AI response. Pass to the chart's `annotations` prop. */ + annotations: ReadonlyArray + /** True while onQuery is in flight. */ + loading: boolean + /** Last error from onQuery, if any. */ + error: Error | null + /** Clear history, AI annotations, and error. */ + reset: () => void +} + +/** + * Headless interrogation hook — a sibling to `useChartObserver`. + * + * Generates an LLM-friendly statistical summary of your chart's data, runs queries through + * a caller-supplied `onQuery`, and merges any annotations the response returns so the chart + * can highlight what the model is talking about. + * + * The hook owns no UI. Render whatever input/transcript surface fits your product. + * + * @example + * const { ask, history, annotations, loading } = useChartInterrogation({ + * data, + * onQuery: async (q, ctx) => { + * const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ q, summary: ctx.summary }) }) + * return res.json() + * }, + * }) + * + * + */ +export function useChartInterrogation( + options: UseChartInterrogationOptions +): UseChartInterrogationResult { + const { + data, + onQuery, + initialAnnotations, + componentName, + props, + includeProfile, + includeSuggestions, + suggestionsIntent, + suggestionsMax, + focus, + } = options + + const [history, setHistory] = useState([]) + const [aiAnnotations, setAiAnnotations] = useState>([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const summary = useMemo(() => summarizeData(data ?? []), [data]) + + const wantsProfile = includeProfile || includeSuggestions + const profile = useMemo( + () => (wantsProfile ? profileData(data ?? []) : undefined), + [wantsProfile, data] + ) + const suggestions = useMemo( + () => + includeSuggestions && profile + ? suggestCharts(data, { + profile, + intent: suggestionsIntent, + maxResults: suggestionsMax ?? 5, + }) + : undefined, + [includeSuggestions, profile, data, suggestionsIntent, suggestionsMax] + ) + + // Latest callback ref so ask() always sees the current onQuery without re-creating itself. + const onQueryRef = useRef(onQuery) + onQueryRef.current = onQuery + const componentNameRef = useRef(componentName) + componentNameRef.current = componentName + const propsRef = useRef(props) + propsRef.current = props + const dataRef = useRef(data) + dataRef.current = data + const summaryRef = useRef(summary) + summaryRef.current = summary + const profileRef = useRef(profile) + profileRef.current = profile + const suggestionsRef = useRef(suggestions) + suggestionsRef.current = suggestions + const focusRef = useRef(focus) + focusRef.current = focus + + const ask = useCallback(async (query: string) => { + const trimmed = query.trim() + if (!trimmed) return + setLoading(true) + setError(null) + setHistory((prev) => [...prev, { role: "user", text: trimmed }]) + try { + const result = await onQueryRef.current(trimmed, { + data: (dataRef.current ?? []) as ReadonlyArray, + summary: summaryRef.current, + profile: profileRef.current, + suggestions: suggestionsRef.current, + componentName: componentNameRef.current, + props: propsRef.current, + focus: focusRef.current ?? undefined, + }) + setHistory((prev) => [...prev, { role: "assistant", text: result.answer }]) + if (result.annotations) setAiAnnotations(result.annotations) + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)) + setError(e) + setHistory((prev) => [ + ...prev, + { role: "assistant", text: "Sorry, I couldn't process that query." }, + ]) + } finally { + setLoading(false) + } + }, []) + + const announce = useCallback( + ({ text, annotations: newAnnotations }: { text: string; annotations?: ReadonlyArray }) => { + const trimmed = text.trim() + if (!trimmed) return + setHistory((prev) => [...prev, { role: "assistant", text: trimmed }]) + if (newAnnotations && newAnnotations.length > 0) { + // Merge — proactive announcements should ADD to the existing AI annotation + // set, not replace it the way a fresh user question does. A live watcher + // calling announce() repeatedly should accumulate notes on the chart. + setAiAnnotations((prev) => [...prev, ...newAnnotations]) + } + }, + [], + ) + + const reset = useCallback(() => { + setHistory([]) + setAiAnnotations([]) + setError(null) + }, []) + + const annotations = useMemo(() => { + const initial = initialAnnotations ?? [] + if (initial.length === 0) return aiAnnotations + if (aiAnnotations.length === 0) return initial + return [...initial, ...aiAnnotations] + }, [initialAnnotations, aiAnnotations]) + + return { ask, announce, history, summary, annotations, loading, error, reset } +} From 8e06a3a4449ebba93857fc5ced3d15d53e87338c Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Sun, 24 May 2026 21:10:38 -0700 Subject: [PATCH 02/11] Improve AI capabilities, data parsing & tests Multiple changes across docs, scripts, AI components, fixtures and tests: - docs: add eslint-disable react/no-unescaped-entities to several blog entries and fix small text wrapping. - scripts/check-capability-coverage.mjs: reformat path/regex strings, improve diagnostic note formatting and expand console summary to show covered charts count. - tests: update MCP tools/list expectation to match registered tools and adjust audienceProfile tests imports/formatting. - ai: simplify suggestStretchCharts logic to use a single top familiar pick as anchor; remove unused top-by-component map. - capabilities: remove unused OHLC constant from Candlestick capability. - DifferenceChart.capability: pivot long-form two-series data into wide {x,a,b} rows in buildProps and expose series labels so the chart receives the expected shape. - DataSummarizer: detect numeric strings (e.g. "42", "3.14e6") as numeric so summaries preserve numeric stats. - useChartFocus: normalize interaction payloads (selection/hover/click), handle additional end types (click-end), and ensure consistent focus datum shape. - qualityFixtures: reformat and normalize fixture data objects/arrays for readability (no functional changes intended). Overall these edits clean up code, improve robustness when handling common input shapes, and align tests with current capabilities. --- .../charts-that-know-what-theyre-for.js | 8 +- .../process-sankey-vs-classic-sankey.js | 1 + docs/src/blog/entries/release-3-5-2.js | 1 + docs/src/blog/entries/release-3-5-3.js | 1 + docs/src/blog/entries/release-3-5-4.js | 1 + scripts/check-capability-coverage.mjs | 38 +- src/__tests__/scenarios/mcp-protocol.test.ts | 8 +- src/components/ai/audienceProfile.test.ts | 54 ++- src/components/ai/qualityFixtures.ts | 341 +++++++++++++----- src/components/ai/suggestStretchCharts.ts | 10 +- .../charts/xy/CandlestickChart.capability.ts | 2 - .../charts/xy/DifferenceChart.capability.ts | 44 ++- src/components/data/DataSummarizer.ts | 5 + src/components/store/useChartFocus.ts | 22 +- 14 files changed, 401 insertions(+), 135 deletions(-) diff --git a/docs/src/blog/entries/charts-that-know-what-theyre-for.js b/docs/src/blog/entries/charts-that-know-what-theyre-for.js index 50cf8e67..2e289eb3 100644 --- a/docs/src/blog/entries/charts-that-know-what-theyre-for.js +++ b/docs/src/blog/entries/charts-that-know-what-theyre-for.js @@ -1,3 +1,4 @@ +/* eslint-disable react/no-unescaped-entities */ import React, { useMemo, useState } from "react" import { Link } from "react-router-dom" import { @@ -13,7 +14,6 @@ import { StackedAreaChart, StackedBarChart, SwarmPlot, - ThemeProvider, ViolinPlot, } from "semiotic" import { @@ -289,7 +289,7 @@ function Playground() { ) : ( Type a phrase like "trend over time", "which is biggest", "show the distribution", or - "is there a correlation" — inferIntent will classify it. + "is there a correlation" — inferIntent will classify it. )}
    @@ -766,8 +766,8 @@ function AskTheData({ data, question }) { useChartInterrogation with annotation-returning onQuery.
  • - Capability Matrix — the AI-readable - inventory of which charts support which features (SSR, push, linked hover, etc.). + Capability Matrix — the AI-readable inventory + of which charts support which features (SSR, push, linked hover, etc.).
  • Strategy memos in docs/strategy/: chart-capability-layer.md{" "} diff --git a/docs/src/blog/entries/process-sankey-vs-classic-sankey.js b/docs/src/blog/entries/process-sankey-vs-classic-sankey.js index d8fd318d..9a459b1c 100644 --- a/docs/src/blog/entries/process-sankey-vs-classic-sankey.js +++ b/docs/src/blog/entries/process-sankey-vs-classic-sankey.js @@ -1,3 +1,4 @@ +/* eslint-disable react/no-unescaped-entities */ import React from "react" import { Link } from "react-router-dom" import WaterCycleFlow from "../../examples/recipes/WaterCycleFlow.js" diff --git a/docs/src/blog/entries/release-3-5-2.js b/docs/src/blog/entries/release-3-5-2.js index 4f8e2a8b..9d5ef2cf 100644 --- a/docs/src/blog/entries/release-3-5-2.js +++ b/docs/src/blog/entries/release-3-5-2.js @@ -1,3 +1,4 @@ +/* eslint-disable react/no-unescaped-entities */ import React from "react" import { Link } from "react-router-dom" diff --git a/docs/src/blog/entries/release-3-5-3.js b/docs/src/blog/entries/release-3-5-3.js index db292454..3a0734c7 100644 --- a/docs/src/blog/entries/release-3-5-3.js +++ b/docs/src/blog/entries/release-3-5-3.js @@ -1,3 +1,4 @@ +/* eslint-disable react/no-unescaped-entities */ import React from "react" import { Link } from "react-router-dom" diff --git a/docs/src/blog/entries/release-3-5-4.js b/docs/src/blog/entries/release-3-5-4.js index 7592d23f..0893b53f 100644 --- a/docs/src/blog/entries/release-3-5-4.js +++ b/docs/src/blog/entries/release-3-5-4.js @@ -1,3 +1,4 @@ +/* eslint-disable react/no-unescaped-entities */ import React from "react" import { Link } from "react-router-dom" diff --git a/scripts/check-capability-coverage.mjs b/scripts/check-capability-coverage.mjs index 1d22593f..235f3206 100644 --- a/scripts/check-capability-coverage.mjs +++ b/scripts/check-capability-coverage.mjs @@ -31,10 +31,17 @@ const inventory = JSON.parse(fs.readFileSync(capabilitiesPath, "utf8")) const allCharts = Object.keys(inventory.charts ?? {}).sort() // 2. Read the capability registry source and extract the components it imports. -const registryPath = path.join(repoRoot, "src", "components", "ai", "chartCapabilities.ts") +const registryPath = path.join( + repoRoot, + "src", + "components", + "ai", + "chartCapabilities.ts" +) const registrySrc = fs.readFileSync(registryPath, "utf8") const importedCapabilities = new Set() -const importRe = /import\s+\{\s*(\w+Capability)\s*\}\s+from\s+"[^"]+\/(\w+)\.capability"/g +const importRe = + /import\s+\{\s*(\w+Capability)\s*\}\s+from\s+"[^"]+\/(\w+)\.capability"/g let match while ((match = importRe.exec(registrySrc)) !== null) { const componentName = match[2] @@ -46,13 +53,16 @@ while ((match = importRe.exec(registrySrc)) !== null) { // (XY/Ordinal/NetworkCustomChart) and LinkedCharts aren't in capabilities.json // because they don't fit the standard chart-spec model. const DELIBERATELY_EXCLUDED = new Map([ - ["RealtimeLineChart", "realtime — streaming source, static suggestion engine doesn't apply"], + [ + "RealtimeLineChart", + "realtime — streaming source, static suggestion engine doesn't apply" + ], ["RealtimeHistogram", "realtime — streaming source"], ["TemporalHistogram", "realtime sibling — streaming source"], ["RealtimeSwarmChart", "realtime"], ["RealtimeWaterfallChart", "realtime"], ["RealtimeHeatmap", "realtime"], - ["ScatterplotMatrix", "multi-chart composition — data shape is a tuple"], + ["ScatterplotMatrix", "multi-chart composition — data shape is a tuple"] ]) // 4. Cross-check @@ -92,16 +102,24 @@ for (const dir of chartDirs) { const orphanFiles = colocatedFiles.filter((c) => !importedCapabilities.has(c)) if (missing.length) { - note(`Charts in ai/capabilities.json without a registered capability descriptor:\n ${missing.join(", ")}\n Either add a *.capability.ts file and register it in src/components/ai/chartCapabilities.ts, or add an entry to DELIBERATELY_EXCLUDED in this script with a reason.`) + note( + `Charts in ai/capabilities.json without a registered capability descriptor:\n ${missing.join(", ")}\n Either add a *.capability.ts file and register it in src/components/ai/chartCapabilities.ts, or add an entry to DELIBERATELY_EXCLUDED in this script with a reason.` + ) } if (unexpectedExclusion.length) { - note(`Charts that have a registered capability AND appear in DELIBERATELY_EXCLUDED:\n ${unexpectedExclusion.join(", ")}\n Remove them from one or the other.`) + note( + `Charts that have a registered capability AND appear in DELIBERATELY_EXCLUDED:\n ${unexpectedExclusion.join(", ")}\n Remove them from one or the other.` + ) } if (phantomExclusions.length) { - note(`DELIBERATELY_EXCLUDED entries that don't match any chart in ai/capabilities.json (typo?):\n ${phantomExclusions.join(", ")}`) + note( + `DELIBERATELY_EXCLUDED entries that don't match any chart in ai/capabilities.json (typo?):\n ${phantomExclusions.join(", ")}` + ) } if (orphanFiles.length) { - note(`Capability descriptor files on disk but not imported by the registry:\n ${orphanFiles.join(", ")}`) + note( + `Capability descriptor files on disk but not imported by the registry:\n ${orphanFiles.join(", ")}` + ) } if (errors.length) { @@ -111,4 +129,6 @@ if (errors.length) { } const coveredCount = allCharts.length - DELIBERATELY_EXCLUDED.size -console.log(`✅ Capability coverage: ${importedCapabilities.size} descriptors registered, ${DELIBERATELY_EXCLUDED.size} deliberate exclusions, ${allCharts.length} charts total.`) +console.log( + `✅ Capability coverage: ${importedCapabilities.size} descriptors registered, ${coveredCount} covered charts, ${DELIBERATELY_EXCLUDED.size} deliberate exclusions, ${allCharts.length} charts total.` +) diff --git a/src/__tests__/scenarios/mcp-protocol.test.ts b/src/__tests__/scenarios/mcp-protocol.test.ts index 297b3722..a891e7fe 100644 --- a/src/__tests__/scenarios/mcp-protocol.test.ts +++ b/src/__tests__/scenarios/mcp-protocol.test.ts @@ -309,7 +309,7 @@ describe("MCP protocol round-trip", () => { } }) - it("tools/list returns all 6 tools", async () => { + it("tools/list returns all registered tools", async () => { const result = await sendRequest(proc, "tools/list", {}, "list-1") expect(result.result).toBeDefined() @@ -318,9 +318,15 @@ describe("MCP protocol round-trip", () => { "applyTheme", "diagnoseConfig", "getSchema", + "interrogateChart", "renderChart", + "repairChartConfig", "reportIssue", "suggestChart", + "suggestCharts", + "suggestDashboard", + "suggestStreamCharts", + "suggestStretchCharts", ]) }) diff --git a/src/components/ai/audienceProfile.test.ts b/src/components/ai/audienceProfile.test.ts index 6fdd8f0c..6635d54b 100644 --- a/src/components/ai/audienceProfile.test.ts +++ b/src/components/ai/audienceProfile.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect } from "vitest" -import { applyAudienceBias, effectiveFamiliarity, stretchFamiliarityCeiling } from "./audienceProfile" +import { + applyAudienceBias, + effectiveFamiliarity, + stretchFamiliarityCeiling +} from "./audienceProfile" import type { AudienceProfile } from "./audienceProfile" import { suggestCharts } from "./suggestCharts" -import { executivePersona, dataScientistPersona, analystPersona } from "./audiences" +import { dataScientistPersona, analystPersona } from "./audiences" const baseRubric = { familiarity: 3, accuracy: 4, precision: 4 } @@ -24,7 +28,7 @@ describe("applyAudienceBias", () => { it("applies increase target as positive score delta", () => { const audience: AudienceProfile = { - targets: { BoxPlot: { direction: "increase", weight: 2 } }, + targets: { BoxPlot: { direction: "increase", weight: 2 } } } const r = applyAudienceBias(3.0, baseRubric, "BoxPlot", audience) // No familiarity override; target +1.0 * 2 = +2.0 @@ -33,7 +37,7 @@ describe("applyAudienceBias", () => { it("applies decrease target as negative score delta", () => { const audience: AudienceProfile = { - targets: { PieChart: { direction: "decrease", weight: 3 } }, + targets: { PieChart: { direction: "decrease", weight: 3 } } } const r = applyAudienceBias(4.5, baseRubric, "PieChart", audience) // Target -1.0 * 3 = -3.0 @@ -43,7 +47,7 @@ describe("applyAudienceBias", () => { it("combines familiarity + target", () => { const audience: AudienceProfile = { familiarity: { BoxPlot: 2 }, - targets: { BoxPlot: { direction: "increase", weight: 2 } }, + targets: { BoxPlot: { direction: "increase", weight: 2 } } } const r = applyAudienceBias(3.0, baseRubric, "BoxPlot", audience) // Familiarity (2-3)*0.5 = -0.5; target +2.0 → +1.5 total @@ -53,7 +57,7 @@ describe("applyAudienceBias", () => { it("clamps target weight to 1..3", () => { const audience: AudienceProfile = { - targets: { X: { direction: "increase", weight: 10 } }, + targets: { X: { direction: "increase", weight: 10 } } } const r = applyAudienceBias(0, baseRubric, "X", audience) expect(r.score).toBe(3) // 1.0 * 3 (clamped) @@ -62,7 +66,9 @@ describe("applyAudienceBias", () => { it("includes appliedReason when target fires", () => { const audience: AudienceProfile = { name: "Acme", - targets: { BoxPlot: { direction: "increase", reason: "we want distributions" } }, + targets: { + BoxPlot: { direction: "increase", reason: "we want distributions" } + } } const r = applyAudienceBias(3.0, baseRubric, "BoxPlot", audience) expect(r.appliedReason).toContain("Acme") @@ -100,17 +106,20 @@ describe("suggestCharts × audience", () => { { product: "A", units: 30 }, { product: "B", units: 50 }, { product: "C", units: 20 }, - { product: "D", units: 45 }, + { product: "D", units: 45 } ] it("data scientist persona meaningfully decreases PieChart for rank intent", () => { - const withoutAudience = suggestCharts(categorical, { intent: "rank", includeVariants: false }) + const withoutAudience = suggestCharts(categorical, { + intent: "rank", + includeVariants: false + }) const withAudience = suggestCharts(categorical, { intent: "rank", audience: dataScientistPersona, includeVariants: false, // Lower minScore so we can see the biased score even if it goes negative - minScore: -10, + minScore: -10 }) const pieBase = withoutAudience.find((s) => s.component === "PieChart") const pieAud = withAudience.find((s) => s.component === "PieChart") @@ -128,7 +137,7 @@ describe("suggestCharts × audience", () => { const suggestions = suggestCharts(categorical, { intent: "rank", audience: dataScientistPersona, - includeVariants: false, + includeVariants: false }) expect(suggestions.find((s) => s.component === "PieChart")).toBeUndefined() }) @@ -136,17 +145,30 @@ describe("suggestCharts × audience", () => { it("appends audience rationale to suggestion.reasons when a target fires", () => { const suggestions = suggestCharts(categorical, { audience: dataScientistPersona, - includeVariants: false, + includeVariants: false }) const pie = suggestions.find((s) => s.component === "PieChart") if (pie) { - expect(pie.reasons.some((r) => r.toLowerCase().includes("length") || r.toLowerCase().includes("decrease"))).toBe(true) + expect( + pie.reasons.some( + (r) => + r.toLowerCase().includes("length") || + r.toLowerCase().includes("decrease") + ) + ).toBe(true) } }) it("returns the same ranking as no-audience when audience is empty", () => { - const a = suggestCharts(categorical, { intent: "rank", includeVariants: false }) - const b = suggestCharts(categorical, { intent: "rank", includeVariants: false, audience: {} }) + const a = suggestCharts(categorical, { + intent: "rank", + includeVariants: false + }) + const b = suggestCharts(categorical, { + intent: "rank", + includeVariants: false, + audience: {} + }) expect(a.map((s) => s.component)).toEqual(b.map((s) => s.component)) }) @@ -156,7 +178,7 @@ describe("suggestCharts × audience", () => { const suggestions = suggestCharts(categorical, { intent: "rank", audience: analystPersona, - includeVariants: false, + includeVariants: false }) expect(suggestions[0].component).toBe("BarChart") }) diff --git a/src/components/ai/qualityFixtures.ts b/src/components/ai/qualityFixtures.ts index 4515b369..222d23a8 100644 --- a/src/components/ai/qualityFixtures.ts +++ b/src/components/ai/qualityFixtures.ts @@ -21,14 +21,14 @@ const monthlyRevenueMultiSeries = (() => { months.map((month) => ({ month, revenue: 800 + month * (200 + regionIdx * 40) + Math.sin(month) * 150, - region, - })), + region + })) ) })() const monthlyRevenueOneSeries = Array.from({ length: 12 }, (_, i) => ({ month: i + 1, - revenue: 1000 + i * 150 + Math.sin(i / 2) * 100, + revenue: 1000 + i * 150 + Math.sin(i / 2) * 100 })) const productSales = [ @@ -36,13 +36,16 @@ const productSales = [ { product: "Gadget", units: 620 }, { product: "Sprocket", units: 290 }, { product: "Whatsit", units: 740 }, - { product: "Doohickey", units: 410 }, + { product: "Doohickey", units: 410 } ] const surveySatisfaction = Array.from({ length: 150 }, (_, i) => ({ respondent_id: i + 1, - satisfaction: Math.max(1, Math.min(10, 6 + Math.sin(i / 7) * 2 + Math.random() * 3 - 1)), - cohort: ["Beta", "GA", "Enterprise"][i % 3], + satisfaction: Math.max( + 1, + Math.min(10, 6 + Math.sin(i / 7) * 2 + Math.random() * 3 - 1) + ), + cohort: ["Beta", "GA", "Enterprise"][i % 3] })) const studyHoursVsGrade = Array.from({ length: 80 }, (_, i) => { @@ -50,7 +53,7 @@ const studyHoursVsGrade = Array.from({ length: 80 }, (_, i) => { return { student_id: `s${i + 1}`, hours, - grade: Math.min(100, hours * 1.8 + 30 + (Math.random() - 0.5) * 20), + grade: Math.min(100, hours * 1.8 + 30 + (Math.random() - 0.5) * 20) } }) @@ -58,61 +61,169 @@ const conversionFunnel = [ { stage: "Visit", users: 10000 }, { stage: "Signup", users: 2400 }, { stage: "Trial", users: 1100 }, - { stage: "Paid", users: 380 }, + { stage: "Paid", users: 380 } ] const orgHierarchy = { name: "Acme", children: [ - { name: "Engineering", children: [ - { name: "Platform", value: 18 }, - { name: "Product", value: 22 }, - ]}, - { name: "Sales", children: [ - { name: "EMEA", value: 12 }, - { name: "AMER", value: 26 }, - ]}, - { name: "Ops", value: 9 }, - ], + { + name: "Engineering", + children: [ + { name: "Platform", value: 18 }, + { name: "Product", value: 22 } + ] + }, + { + name: "Sales", + children: [ + { name: "EMEA", value: 12 }, + { name: "AMER", value: 26 } + ] + }, + { name: "Ops", value: 9 } + ] } const transitionNetwork = { nodes: [ - { id: "draft" }, { id: "review" }, { id: "approved" }, { id: "shipped" }, { id: "rejected" }, + { id: "draft" }, + { id: "review" }, + { id: "approved" }, + { id: "shipped" }, + { id: "rejected" } ], edges: [ { source: "draft", target: "review", value: 100 }, { source: "review", target: "approved", value: 60 }, { source: "review", target: "rejected", value: 40 }, - { source: "approved", target: "shipped", value: 58 }, - ], + { source: "approved", target: "shipped", value: 58 } + ] } const usGeoFeatures = { type: "FeatureCollection", features: [ - { type: "Feature", id: "CA", properties: { name: "California", value: 39 }, geometry: { type: "Polygon", coordinates: [[[-124,32],[-114,32],[-114,42],[-124,42],[-124,32]]] } }, - { type: "Feature", id: "TX", properties: { name: "Texas", value: 29 }, geometry: { type: "Polygon", coordinates: [[[-106,26],[-93,26],[-93,36],[-106,36],[-106,26]]] } }, - { type: "Feature", id: "NY", properties: { name: "New York", value: 19 }, geometry: { type: "Polygon", coordinates: [[[-79,40],[-72,40],[-72,45],[-79,45],[-79,40]]] } }, - ], + { + type: "Feature", + id: "CA", + properties: { name: "California", value: 39 }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-124, 32], + [-114, 32], + [-114, 42], + [-124, 42], + [-124, 32] + ] + ] + } + }, + { + type: "Feature", + id: "TX", + properties: { name: "Texas", value: 29 }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-106, 26], + [-93, 26], + [-93, 36], + [-106, 36], + [-106, 26] + ] + ] + } + }, + { + type: "Feature", + id: "NY", + properties: { name: "New York", value: 19 }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-79, 40], + [-72, 40], + [-72, 45], + [-79, 45], + [-79, 40] + ] + ] + } + } + ] } const flatSingleColumn = Array.from({ length: 50 }, (_, i) => ({ - observation: 50 + Math.sin(i / 4) * 12 + Math.random() * 6, + observation: 50 + Math.sin(i / 4) * 12 + Math.random() * 6 })) // Three-numeric scatter — fixture for BubbleChart const economiesByCountry = [ - { country: "USA", gdp_per_capita: 70, hours_worked: 1700, population_size: 330 }, - { country: "UK", gdp_per_capita: 48, hours_worked: 1500, population_size: 67 }, - { country: "Germany", gdp_per_capita: 53, hours_worked: 1330, population_size: 84 }, - { country: "Japan", gdp_per_capita: 40, hours_worked: 1600, population_size: 125 }, - { country: "France", gdp_per_capita: 45, hours_worked: 1480, population_size: 67 }, - { country: "Italy", gdp_per_capita: 38, hours_worked: 1700, population_size: 60 }, - { country: "Spain", gdp_per_capita: 32, hours_worked: 1640, population_size: 47 }, - { country: "Canada", gdp_per_capita: 52, hours_worked: 1690, population_size: 38 }, - { country: "Australia", gdp_per_capita: 56, hours_worked: 1700, population_size: 26 }, - { country: "South Korea", gdp_per_capita: 35, hours_worked: 1900, population_size: 52 }, + { + country: "USA", + gdp_per_capita: 70, + hours_worked: 1700, + population_size: 330 + }, + { + country: "UK", + gdp_per_capita: 48, + hours_worked: 1500, + population_size: 67 + }, + { + country: "Germany", + gdp_per_capita: 53, + hours_worked: 1330, + population_size: 84 + }, + { + country: "Japan", + gdp_per_capita: 40, + hours_worked: 1600, + population_size: 125 + }, + { + country: "France", + gdp_per_capita: 45, + hours_worked: 1480, + population_size: 67 + }, + { + country: "Italy", + gdp_per_capita: 38, + hours_worked: 1700, + population_size: 60 + }, + { + country: "Spain", + gdp_per_capita: 32, + hours_worked: 1640, + population_size: 47 + }, + { + country: "Canada", + gdp_per_capita: 52, + hours_worked: 1690, + population_size: 38 + }, + { + country: "Australia", + gdp_per_capita: 56, + hours_worked: 1700, + population_size: 26 + }, + { + country: "South Korea", + gdp_per_capita: 35, + hours_worked: 1900, + population_size: 52 + } ] // Multi-measure time series for MultiAxisLineChart @@ -120,7 +231,7 @@ const websiteMetrics = Array.from({ length: 24 }, (_, i) => ({ month: i + 1, page_views: Math.round(50000 + i * 1200 + Math.sin(i / 3) * 8000), conversion_rate: 2.5 + Math.sin(i / 4) * 0.8 + i * 0.05, - avg_session_seconds: Math.round(120 + i * 2 + Math.cos(i / 5) * 15), + avg_session_seconds: Math.round(120 + i * 2 + Math.cos(i / 5) * 15) })) // Categorical × series × value for GroupedBarChart / StackedBarChart @@ -136,27 +247,21 @@ const salesByRegionAndProduct = [ { product: "Sprocket", region: "APAC", units: 150 }, { product: "Whatsit", region: "EU", units: 290 }, { product: "Whatsit", region: "NA", units: 550 }, - { product: "Whatsit", region: "APAC", units: 180 }, + { product: "Whatsit", region: "APAC", units: 180 } ] -// Exactly-two-series temporal for DifferenceChart -const revenueVsExpenses = Array.from({ length: 24 }, (_, i) => ({ - month: i + 1, - amount: 100 + i * 8 + Math.sin(i / 3) * 25, - series: i % 2 === 0 ? "revenue" : "expenses", -})) // Coerce to exactly-two-series shape by partitioning evenly const revenueVsExpensesTwoSeries = [ ...Array.from({ length: 24 }, (_, i) => ({ month: i + 1, amount: 100 + i * 8 + Math.sin(i / 3) * 25, - series: "revenue", + series: "revenue" })), ...Array.from({ length: 24 }, (_, i) => ({ month: i + 1, amount: 80 + i * 6 + Math.cos(i / 4) * 15, - series: "expenses", - })), + series: "expenses" + })) ] // OHLC time series for CandlestickChart @@ -173,13 +278,13 @@ const stockPrices = Array.from({ length: 30 }, (_, i) => { const usaUnemploymentVsInflation = Array.from({ length: 20 }, (_, i) => ({ year: 2005 + i, unemployment: 5 + Math.sin(i / 2) * 2 + (i > 4 && i < 10 ? 3 : 0), - inflation: 2 + Math.cos(i / 3) * 1.5, + inflation: 2 + Math.cos(i / 3) * 1.5 })) const sparseThreeRow = [ { name: "A", value: 12 }, { name: "B", value: 34 }, - { name: "C", value: 8 }, + { name: "C", value: 8 } ] // Flat array of transition events. The canonical input shape for SankeyDiagram / @@ -187,17 +292,83 @@ const sparseThreeRow = [ // the data is rows, not a {nodes, edges} object. Exercises the // detectTransitionNetwork path in profileData. const transitionEvents = [ - { case: "deal-001", stage: "Inbound Lead", nextStage: "Qualified", startTime: "2024-04-01T09:00:00", value: 18 }, - { case: "deal-001", stage: "Qualified", nextStage: "Discovery", startTime: "2024-04-01T13:00:00", value: 16 }, - { case: "deal-001", stage: "Discovery", nextStage: "Proposal", startTime: "2024-04-02T11:00:00", value: 14 }, - { case: "deal-001", stage: "Proposal", nextStage: "Closed Won", startTime: "2024-04-04T09:00:00", value: 12 }, - { case: "deal-002", stage: "Inbound Lead", nextStage: "Qualified", startTime: "2024-04-01T10:00:00", value: 10 }, - { case: "deal-002", stage: "Qualified", nextStage: "Discovery", startTime: "2024-04-02T09:00:00", value: 9 }, - { case: "deal-002", stage: "Discovery", nextStage: "Proposal", startTime: "2024-04-03T09:00:00", value: 7 }, - { case: "deal-002", stage: "Proposal", nextStage: "Closed Lost", startTime: "2024-04-04T11:00:00", value: 5 }, - { case: "deal-003", stage: "Signup", nextStage: "Activated", startTime: "2024-04-01T08:30:00", value: 28 }, - { case: "deal-003", stage: "Activated", nextStage: "Trial", startTime: "2024-04-01T10:00:00", value: 24 }, - { case: "deal-003", stage: "Trial", nextStage: "Subscribed", startTime: "2024-04-02T10:00:00", value: 18 }, + { + case: "deal-001", + stage: "Inbound Lead", + nextStage: "Qualified", + startTime: "2024-04-01T09:00:00", + value: 18 + }, + { + case: "deal-001", + stage: "Qualified", + nextStage: "Discovery", + startTime: "2024-04-01T13:00:00", + value: 16 + }, + { + case: "deal-001", + stage: "Discovery", + nextStage: "Proposal", + startTime: "2024-04-02T11:00:00", + value: 14 + }, + { + case: "deal-001", + stage: "Proposal", + nextStage: "Closed Won", + startTime: "2024-04-04T09:00:00", + value: 12 + }, + { + case: "deal-002", + stage: "Inbound Lead", + nextStage: "Qualified", + startTime: "2024-04-01T10:00:00", + value: 10 + }, + { + case: "deal-002", + stage: "Qualified", + nextStage: "Discovery", + startTime: "2024-04-02T09:00:00", + value: 9 + }, + { + case: "deal-002", + stage: "Discovery", + nextStage: "Proposal", + startTime: "2024-04-03T09:00:00", + value: 7 + }, + { + case: "deal-002", + stage: "Proposal", + nextStage: "Closed Lost", + startTime: "2024-04-04T11:00:00", + value: 5 + }, + { + case: "deal-003", + stage: "Signup", + nextStage: "Activated", + startTime: "2024-04-01T08:30:00", + value: 28 + }, + { + case: "deal-003", + stage: "Activated", + nextStage: "Trial", + startTime: "2024-04-01T10:00:00", + value: 24 + }, + { + case: "deal-003", + stage: "Trial", + nextStage: "Subscribed", + startTime: "2024-04-02T10:00:00", + value: 18 + } ] export const CANONICAL_FIXTURES: ReadonlyArray = [ @@ -207,28 +378,28 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "12 months × 3 regions, numeric month, numeric revenue", data: monthlyRevenueMultiSeries, intent: "trend", - expected: ["LineChart", "AreaChart", "MinimapChart"], + expected: ["LineChart", "AreaChart", "MinimapChart"] }, { name: "monthly revenue with regions, intent=compare-series", shape: "12 months × 3 regions", data: monthlyRevenueMultiSeries, intent: "compare-series", - expected: ["LineChart", "GroupedBarChart"], + expected: ["LineChart", "GroupedBarChart"] }, { name: "monthly revenue with regions, intent=composition-over-time", shape: "12 months × 3 regions, additive", data: monthlyRevenueMultiSeries, intent: "composition-over-time", - expected: ["StackedAreaChart", "StackedBarChart"], + expected: ["StackedAreaChart", "StackedBarChart"] }, { name: "monthly revenue single series, intent=trend", shape: "12 months, no series", data: monthlyRevenueOneSeries, intent: "trend", - expected: ["LineChart", "AreaChart"], + expected: ["LineChart", "AreaChart"] }, // Categorical family { @@ -236,14 +407,14 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "5 products, single numeric measure", data: productSales, intent: "rank", - expected: ["BarChart", "DotPlot"], + expected: ["BarChart", "DotPlot"] }, { name: "product sales, intent=part-to-whole", shape: "5 products, single numeric measure", data: productSales, intent: "part-to-whole", - expected: ["PieChart", "DonutChart", "BarChart"], + expected: ["PieChart", "DonutChart", "BarChart"] }, // Distribution family { @@ -251,14 +422,14 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "150 numeric observations across 3 cohorts", data: surveySatisfaction, intent: "distribution", - expected: ["Histogram", "BoxPlot", "ViolinPlot"], + expected: ["Histogram", "BoxPlot", "ViolinPlot"] }, { name: "satisfaction scores, intent=compare-categories", shape: "150 obs × 3 cohorts", data: surveySatisfaction, intent: "compare-categories", - expected: ["BoxPlot", "ViolinPlot", "SwarmPlot"], + expected: ["BoxPlot", "ViolinPlot", "SwarmPlot"] }, // Relationship family { @@ -266,14 +437,14 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "80 students, hours + grade", data: studyHoursVsGrade, intent: "correlation", - expected: ["Scatterplot"], + expected: ["Scatterplot"] }, { name: "hours vs grade, intent=outlier-detection", shape: "80 students", data: studyHoursVsGrade, intent: "outlier-detection", - expected: ["Scatterplot"], + expected: ["Scatterplot"] }, // Flow family { @@ -281,7 +452,7 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "4 stages, descending values", data: conversionFunnel, intent: "flow", - expected: ["FunnelChart"], + expected: ["FunnelChart"] }, // Hierarchy family (rawInput payload) { @@ -290,7 +461,7 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ data: [], rawInput: orgHierarchy, intent: "hierarchy", - expected: ["TreeDiagram", "Treemap", "CirclePack"], + expected: ["TreeDiagram", "Treemap", "CirclePack"] }, // Network family (rawInput payload) { @@ -299,7 +470,7 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ data: [], rawInput: transitionNetwork, intent: "flow", - expected: ["SankeyDiagram", "ChordDiagram"], + expected: ["SankeyDiagram", "ChordDiagram"] }, // Geo family (rawInput payload) { @@ -308,7 +479,7 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ data: [], rawInput: usGeoFeatures, intent: "geo", - expected: ["ChoroplethMap", "ProportionalSymbolMap"], + expected: ["ChoroplethMap", "ProportionalSymbolMap"] }, // Three-numeric scatter — exercises BubbleChart @@ -317,7 +488,7 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "10 countries × 3 numeric measures (gdp, hours, population)", data: economiesByCountry, intent: "correlation", - expected: ["Scatterplot", "BubbleChart"], + expected: ["Scatterplot", "BubbleChart"] }, // Multi-measure time-series — exercises MultiAxisLineChart { @@ -325,7 +496,7 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "24 months × 3 numeric measures with different ranges", data: websiteMetrics, intent: "compare-series", - expected: ["MultiAxisLineChart", "LineChart"], + expected: ["MultiAxisLineChart", "LineChart"] }, // Category × series × value — exercises GroupedBarChart / StackedBarChart { @@ -333,14 +504,14 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "12 rows = 4 products × 3 regions", data: salesByRegionAndProduct, intent: "compare-series", - expected: ["GroupedBarChart", "StackedBarChart"], + expected: ["GroupedBarChart", "StackedBarChart"] }, { name: "sales by region and product, intent=part-to-whole", shape: "12 rows = 4 products × 3 regions", data: salesByRegionAndProduct, intent: "part-to-whole", - expected: ["StackedBarChart", "PieChart"], + expected: ["StackedBarChart", "PieChart"] }, // Exactly-two-series temporal — exercises DifferenceChart { @@ -348,7 +519,7 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "48 rows = 24 months × 2 series", data: revenueVsExpensesTwoSeries, intent: "compare-series", - expected: ["DifferenceChart", "LineChart", "GroupedBarChart"], + expected: ["DifferenceChart", "LineChart", "GroupedBarChart"] }, // OHLC — exercises CandlestickChart { @@ -356,7 +527,7 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "30 days × open/high/low/close", data: stockPrices, intent: "change-detection", - expected: ["CandlestickChart", "LineChart"], + expected: ["CandlestickChart", "LineChart"] }, // Ordered-sequence scatter — exercises ConnectedScatterplot { @@ -364,7 +535,7 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "20 years × 2 measures, ordered by year", data: usaUnemploymentVsInflation, intent: "correlation", - expected: ["ConnectedScatterplot", "Scatterplot"], + expected: ["ConnectedScatterplot", "Scatterplot"] }, // Transition events — flat array of edges with stage/nextStage/startTime/value. @@ -374,7 +545,7 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "11 stage transitions across 3 deals with startTime + value", data: transitionEvents, intent: "flow", - expected: ["SankeyDiagram", "ProcessSankey", "ChordDiagram"], + expected: ["SankeyDiagram", "ProcessSankey", "ChordDiagram"] }, // Stress fixtures — expect no fitting chart for these. @@ -383,13 +554,13 @@ export const CANONICAL_FIXTURES: ReadonlyArray = [ shape: "50 rows, one numeric column", data: flatSingleColumn, // intentionally no intent — we want the engine to refuse this whole class. - expected: ["Histogram"], // a histogram is genuinely the best (only) fit here + expected: ["Histogram"] // a histogram is genuinely the best (only) fit here }, { name: "sparse 3-row data, intent=rank", shape: "3 rows total", data: sparseThreeRow, intent: "rank", - expected: ["BarChart", "DotPlot"], - }, + expected: ["BarChart", "DotPlot"] + } ] diff --git a/src/components/ai/suggestStretchCharts.ts b/src/components/ai/suggestStretchCharts.ts index 7c9361ff..4391b0da 100644 --- a/src/components/ai/suggestStretchCharts.ts +++ b/src/components/ai/suggestStretchCharts.ts @@ -100,16 +100,14 @@ export function suggestStretchCharts( deny: options.deny, }) - // Bucket baseline by intent so we can find a familiar counterpart per stretch. - // For multi-intent or no-intent cases, just take the top-scoring familiar pick. + // Top-scoring familiar pick — used as the "you'd already pick this" anchor + // each stretch is paired against. Multi-intent / no-intent cases just take + // the global top; per-intent buckets aren't needed because `suggestCharts` + // has already ranked by the requested intent (or by overall fit). const familiarPicks = baseline.filter( (s) => (familiarityByComponent.get(s.component) ?? s.rubric.familiarity) >= 4, ) const topFamiliar = familiarPicks[0] - const topFamiliarByComponent = new Map() - for (const s of familiarPicks) { - if (!topFamiliarByComponent.has(s.component)) topFamiliarByComponent.set(s.component, s) - } // Identify stretches: charts that fit, with audience familiarity ≤ ceiling. const stretchCandidates: PairCandidate[] = [] diff --git a/src/components/charts/xy/CandlestickChart.capability.ts b/src/components/charts/xy/CandlestickChart.capability.ts index 57ed3593..466ca43d 100644 --- a/src/components/charts/xy/CandlestickChart.capability.ts +++ b/src/components/charts/xy/CandlestickChart.capability.ts @@ -1,7 +1,5 @@ import type { ChartCapability } from "../../ai/chartCapabilityTypes" -const OHLC = ["open", "high", "low", "close"] - export const CandlestickChartCapability: ChartCapability = { component: "CandlestickChart", family: "time-series", diff --git a/src/components/charts/xy/DifferenceChart.capability.ts b/src/components/charts/xy/DifferenceChart.capability.ts index 7ba34c7f..e5a2bfc2 100644 --- a/src/components/charts/xy/DifferenceChart.capability.ts +++ b/src/components/charts/xy/DifferenceChart.capability.ts @@ -25,14 +25,44 @@ export const DifferenceChartCapability: ChartCapability = { }, buildProps: (profile) => { - // DifferenceChart expects two-axis-per-row, so this is a "show A vs B" pre-aggregated form. - // We approximate by passing the raw data plus the accessor; consumers who want true A/B - // shape will pre-pivot. The capability stays generic. + // DifferenceChart wants wide-form `{x, a, b}` rows. The fits() guard above + // ensures we're looking at long-form `{x, series, y}` with exactly two + // series — pivot it into the expected shape so the returned props are + // immediately runnable instead of a misleading "A=B" zero-difference. + const xKey = profile.primary.x as string + const yKey = profile.primary.y as string + const seriesKey = profile.primary.series as string + + const seriesNames: string[] = [] + for (const row of profile.data) { + const name = String(row[seriesKey]) + if (!seriesNames.includes(name)) seriesNames.push(name) + if (seriesNames.length === 2) break + } + const [aName, bName] = seriesNames + + const byX = new Map>() + for (const row of profile.data) { + const x = row[xKey] + const series = String(row[seriesKey]) + const y = row[yKey] + let entry = byX.get(x) + if (!entry) { + entry = { [xKey]: x } + byX.set(x, entry) + } + if (series === aName) entry.a = y + else if (series === bName) entry.b = y + } + const wide = Array.from(byX.values()).filter((r) => r.a != null && r.b != null) + return { - data: profile.data, - xAccessor: profile.primary.x, - seriesAAccessor: profile.primary.y, - seriesBAccessor: profile.primary.y, + data: wide, + xAccessor: xKey, + seriesAAccessor: "a", + seriesBAccessor: "b", + seriesALabel: aName, + seriesBLabel: bName, } }, } diff --git a/src/components/data/DataSummarizer.ts b/src/components/data/DataSummarizer.ts index e65ad6ea..76fe4d74 100644 --- a/src/components/data/DataSummarizer.ts +++ b/src/components/data/DataSummarizer.ts @@ -47,12 +47,17 @@ export interface SummarizeOptions { } const DATE_LIKE = /^\d{4}[-/]\d{2}/ +const NUMERIC_STRING = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/ function inferType(val: unknown): FieldType { if (typeof val === "number") return Number.isFinite(val) ? "numeric" : "unknown" if (val instanceof Date) return "date" if (typeof val === "string") { if (DATE_LIKE.test(val) && !Number.isNaN(Date.parse(val))) return "date" + // CSV/JSON often carries numerics as strings ("42", "3.14e6"). The numeric + // branch later coerces via Number(), so classify those as numeric up-front + // rather than dropping them into categorical and losing min/max/mean. + if (NUMERIC_STRING.test(val) && Number.isFinite(Number(val))) return "numeric" return "categorical" } if (typeof val === "boolean") return "categorical" diff --git a/src/components/store/useChartFocus.ts b/src/components/store/useChartFocus.ts index c790f35e..74df69ea 100644 --- a/src/components/store/useChartFocus.ts +++ b/src/components/store/useChartFocus.ts @@ -65,13 +65,25 @@ export function useChartFocus(options: UseChartFocusOptions = {}): Interrogation return useMemo(() => { if (!latest) return null - // Hover-end / selection-end observations carry no datum and should not - // create a focus — they mean "user moved away," not "user focused on - // something new." Treat them as cleared focus. - if (latest.type === "hover-end" || latest.type === "selection-end" || latest.type === "brush-end") { + // *-end observations signal "user moved away" — clear focus. + if ( + latest.type === "hover-end" || + latest.type === "selection-end" || + latest.type === "brush-end" || + latest.type === "click-end" + ) { + return null + } + // Hover/click carry the datum directly; selection carries it under + // selection.fields. Normalize so the focus shape is consistent. + let datum: unknown + if (latest.type === "selection") { + datum = latest.selection.fields + } else if (latest.type === "hover" || latest.type === "click") { + datum = latest.datum + } else { return null } - const datum = (latest as { datum?: unknown }).datum if (!datum || typeof datum !== "object") return null return { datum: datum as Record, From df17e9ac42c6d19efdeecc201317ff4bcb63672e Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Sun, 24 May 2026 21:32:46 -0700 Subject: [PATCH 03/11] Update mcp-server.ts --- ai/mcp-server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ai/mcp-server.ts b/ai/mcp-server.ts index a8f2309c..8dc309a1 100644 --- a/ai/mcp-server.ts +++ b/ai/mcp-server.ts @@ -550,7 +550,7 @@ async function suggestDashboardHandler(args: { return { content: [{ type: "text", text: lines.join("\n") }], - structuredContent: dashboard, + structuredContent: dashboard as unknown as Record, } } @@ -626,7 +626,7 @@ async function repairChartConfigHandler(args: { return { content: [{ type: "text", text: lines.join("\n") }], - structuredContent: result, + structuredContent: result as unknown as Record, } } From 7b6aeeb38f685583499b494ce925cc209f5ff9ce Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Sun, 24 May 2026 21:47:09 -0700 Subject: [PATCH 04/11] Update semiotic-ai.api.md --- etc/api-surface/semiotic-ai.api.md | 135 +++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/etc/api-surface/semiotic-ai.api.md b/etc/api-surface/semiotic-ai.api.md index 8a809ff5..6ce989da 100644 --- a/etc/api-surface/semiotic-ai.api.md +++ b/etc/api-surface/semiotic-ai.api.md @@ -4,6 +4,51 @@ _Auto-generated by `scripts/generate-api-surface.mjs` from `dist/semiotic-ai.d.t _Edit dist/semiotic-ai.d.ts's sources, then re-run `npm run docs:api-surface` to refresh._ ``` +const AreaChartCapability +const BUILT_IN_AUDIENCES +const BUILT_IN_INTENT_IDS +const BarChartCapability +const BoxPlotCapability +const BubbleChartCapability +const CANONICAL_FIXTURES +const CandlestickChartCapability +const ChordDiagramCapability +const ChoroplethMapCapability +const CirclePackCapability +const ConnectedScatterplotCapability +const DifferenceChartCapability +const DistanceCartogramCapability +const DonutChartCapability +const DotPlotCapability +const FlowMapCapability +const ForceDirectedGraphCapability +const FunnelChartCapability +const GaugeChartCapability +const GroupedBarChartCapability +const HeatmapCapability +const HistogramCapability +const LikertChartCapability +const LineChartCapability +const MinimapChartCapability +const MultiAxisLineChartCapability +const OrbitDiagramCapability +const PieChartCapability +const ProcessSankeyCapability +const ProportionalSymbolMapCapability +const QuadrantChartCapability +const RidgelinePlotCapability +const SankeyDiagramCapability +const ScatterplotCapability +const StackedAreaChartCapability +const StackedBarChartCapability +const SwarmPlotCapability +const SwimlaneChartCapability +const TreeDiagramCapability +const TreemapCapability +const ViolinPlotCapability +const analystPersona +const dataScientistPersona +const executivePersona function AreaChart function BarChart function BoxPlot @@ -55,55 +100,145 @@ function TooltipProvider function TreeDiagram function Treemap function ViolinPlot +function applyAudienceBias function configToJSX function copyConfig function deserializeSelections function diagnoseConfig +function diffProfile +function effectiveFamiliarity +function explainCapabilityFit function exportChart function fromConfig function fromURL function fromVegaLite +function getCapabilities +function getCapability +function getIntent +function getStreamCapabilities +function inferIntent +function listIntents +function profileData +function registerChartCapability +function registerIntent +function registerStreamChartCapability +function repairChartConfig +function runQualityScorecard +function scoreChart function serializeSelections +function stretchFamiliarityCeiling +function suggestCharts +function suggestDashboard +function suggestStreamCharts +function suggestStretchCharts +function summarizeData function toConfig function toURL +function unregisterChartCapability +function unregisterStreamChartCapability function useBrushSelection function useCategoryColors +function useChartInterrogation function useChartObserver +function useChartSuggestions function useFilteredData function useLinkedHover function useSelection function useTheme function validateProps interface AnomalyConfig +interface AudienceBiasResult +interface AudienceProfile +interface AudienceTarget interface BrushEndObservation interface BrushObservation +interface CategoricalFieldSummary interface CategoryColorProviderProps +interface ChartCapability interface ChartConfig interface ChartContainerHandle interface ChartContainerProps +interface ChartDataProfile interface ChartGridProps +interface ChartRubric +interface ChartVariant interface ClickEndObservation interface ClickObservation interface ContextLayoutProps +interface DashboardPanel +interface DashboardSuggestion +interface DataSummary +interface DateFieldSummary interface DetailsPanelProps interface Diagnosis interface DiagnosisResult +interface ExplainCapabilityFitResult +interface FieldCandidate +interface FieldTypeChange interface ForecastConfig interface HoverEndObservation interface HoverObservation +interface InferIntentResult +interface IntentDescriptor +interface InterrogationContext +interface InterrogationMessage +interface InterrogationResult +interface NumericFieldSummary +interface PerCapabilityScore +interface PerFixtureScore +interface PrimaryRoleChange +interface ProfileDataOptions +interface ProfileDiff +interface RejectedCapability +interface RepairAlternativeResult +interface RepairOkResult +interface RepairOptions +interface RepairUnknownResult +interface ScorecardFixture +interface ScorecardReport interface SelectionEndObservation interface SelectionObservation interface SerializedSelection +interface StreamChartCapability +interface StreamFieldSchema +interface StreamSchema +interface StreamSuggestion +interface StretchSuggestion +interface SuggestChartsOptions +interface SuggestDashboardOptions +interface SuggestStreamChartsOptions +interface SuggestStretchChartsOptions +interface Suggestion +interface SummarizeOptions interface ToConfigOptions +interface UnknownFieldSummary +interface UseChartInterrogationOptions +interface UseChartInterrogationResult interface UseChartObserverOptions interface UseChartObserverResult +interface UseChartSuggestionsOptions +interface UseChartSuggestionsResult interface ValidationResult interface VegaLiteEncoding interface VegaLiteSpec +type BuiltInIntentId type CategoryColorMap +type ChartFamily +type ChartImportPath type ChartObservation type CopyFormat +type FieldKind +type FieldSummary +type FieldType +type FitResult +type IntentId +type IntentScorer +type InterrogationQuery type OnObservationCallback +type PrimaryRole +type RepairResult type SerializedFieldSelection type SerializedSelections +type StreamFieldKind +type StreamIntentScorer ``` From 8532433b6404fd570b8ab163d9ed313fc6973a02 Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Sun, 24 May 2026 22:09:51 -0700 Subject: [PATCH 05/11] Update semiotic-ai.api.md --- etc/api-surface/semiotic-ai.api.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/etc/api-surface/semiotic-ai.api.md b/etc/api-surface/semiotic-ai.api.md index 6ce989da..d79b071c 100644 --- a/etc/api-surface/semiotic-ai.api.md +++ b/etc/api-surface/semiotic-ai.api.md @@ -138,6 +138,7 @@ function unregisterChartCapability function unregisterStreamChartCapability function useBrushSelection function useCategoryColors +function useChartFocus function useChartInterrogation function useChartObserver function useChartSuggestions @@ -181,6 +182,7 @@ interface HoverObservation interface InferIntentResult interface IntentDescriptor interface InterrogationContext +interface InterrogationFocus interface InterrogationMessage interface InterrogationResult interface NumericFieldSummary @@ -212,6 +214,7 @@ interface Suggestion interface SummarizeOptions interface ToConfigOptions interface UnknownFieldSummary +interface UseChartFocusOptions interface UseChartInterrogationOptions interface UseChartInterrogationResult interface UseChartObserverOptions From ebe53f3f9c30bb0e6ed3bb3bca6a66ed3927a686 Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Sun, 24 May 2026 22:34:59 -0700 Subject: [PATCH 06/11] Sync bundle size numbers in docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update auto-generated bundle size figures across docs. Adjusts gzip sizes for several entry points (e.g. semiotic/xy 85→86KB, semiotic/ordinal 69→70KB, semiotic/network 63→64KB, semiotic/realtime 90→91KB, semiotic/ai 189→211KB, full semiotic 188→190KB) in README.md, CLAUDE.md, and ai/system-prompt.md to reflect the latest build metrics. --- CLAUDE.md | 2 +- README.md | 12 ++++++------ ai/system-prompt.md | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fd75036b..06981165 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ - Install: `npm install semiotic` -- **Use sub-path imports** — `semiotic/xy` (85KB gz), `semiotic/ordinal` (69KB gz), `semiotic/network` (63KB gz), `semiotic/geo` (52KB gz), `semiotic/realtime` (90KB gz), `semiotic/server` (122KB gz), `semiotic/utils` (22KB gz), `semiotic/recipes` (5KB gz), `semiotic/themes` (4KB gz), `semiotic/data` (3KB gz), `semiotic/ai` (189KB gz). Full `semiotic` is 188KB gz. +- **Use sub-path imports** — `semiotic/xy` (86KB gz), `semiotic/ordinal` (70KB gz), `semiotic/network` (64KB gz), `semiotic/geo` (52KB gz), `semiotic/realtime` (91KB gz), `semiotic/server` (122KB gz), `semiotic/utils` (22KB gz), `semiotic/recipes` (5KB gz), `semiotic/themes` (4KB gz), `semiotic/data` (3KB gz), `semiotic/ai` (211KB gz). Full `semiotic` is 190KB gz. - CLI: `npx semiotic-ai [--schema|--compact|--examples|--doctor]` - MCP: `npx semiotic-mcp` diff --git a/README.md b/README.md index 843c84ec..e88775d1 100644 --- a/README.md +++ b/README.md @@ -287,18 +287,18 @@ Semiotic ships 12 entry points. **Don't import from `"semiotic"` unless you need | Entry Point | gzip | What's inside | |---|---|---| -| `semiotic/xy` | **85 KB** | LineChart, AreaChart, Scatterplot, Heatmap, + 8 more XY charts | -| `semiotic/ordinal` | **69 KB** | BarChart, PieChart, BoxPlot, Histogram, + 11 more categorical charts | -| `semiotic/network` | **63 KB** | ForceDirectedGraph, SankeyDiagram, ProcessSankey, Treemap, + 4 more | +| `semiotic/xy` | **86 KB** | LineChart, AreaChart, Scatterplot, Heatmap, + 8 more XY charts | +| `semiotic/ordinal` | **70 KB** | BarChart, PieChart, BoxPlot, Histogram, + 11 more categorical charts | +| `semiotic/network` | **64 KB** | ForceDirectedGraph, SankeyDiagram, ProcessSankey, Treemap, + 4 more | | `semiotic/geo` | **52 KB** | ChoroplethMap, FlowMap, DistanceCartogram, ProportionalSymbolMap | -| `semiotic/realtime` | **90 KB** | RealtimeLineChart, RealtimeHistogram, + 3 streaming charts | +| `semiotic/realtime` | **91 KB** | RealtimeLineChart, RealtimeHistogram, + 3 streaming charts | | `semiotic/server` | **122 KB** | renderChart, renderDashboard, renderToImage, renderToAnimatedGif | | `semiotic/utils` | **22 KB** | ThemeProvider, validators, serialization — no chart components | | `semiotic/recipes` | **5 KB** | Pure layout functions (waffle, marimekko, flextree, dagre, …) | | `semiotic/themes` | **4 KB** | Theme presets only (tufte, carbon, etc.) | | `semiotic/data` | **3 KB** | bin, rollup, groupBy, pivot, fromVegaLite | -| `semiotic/ai` | **189 KB** | All 41 HOCs + validation — optimized for LLM code generation | -| `semiotic` | **188 KB** | Everything below (full bundle) | +| `semiotic/ai` | **211 KB** | All 41 HOCs + validation — optimized for LLM code generation | +| `semiotic` | **190 KB** | Everything below (full bundle) | diff --git a/ai/system-prompt.md b/ai/system-prompt.md index 2788cb4e..d909f553 100644 --- a/ai/system-prompt.md +++ b/ai/system-prompt.md @@ -2,7 +2,7 @@ -**Use sub-path imports** — `semiotic/xy` (85KB gz), `semiotic/ordinal` (69KB gz), `semiotic/network` (63KB gz), `semiotic/geo` (52KB gz), `semiotic/realtime` (90KB gz), `semiotic/server` (122KB gz), `semiotic/utils` (22KB gz), `semiotic/recipes` (5KB gz), `semiotic/themes` (4KB gz), `semiotic/data` (3KB gz), `semiotic/ai` (189KB gz). Full `semiotic` is 188KB gz. +**Use sub-path imports** — `semiotic/xy` (86KB gz), `semiotic/ordinal` (70KB gz), `semiotic/network` (64KB gz), `semiotic/geo` (52KB gz), `semiotic/realtime` (91KB gz), `semiotic/server` (122KB gz), `semiotic/utils` (22KB gz), `semiotic/recipes` (5KB gz), `semiotic/themes` (4KB gz), `semiotic/data` (3KB gz), `semiotic/ai` (211KB gz). Full `semiotic` is 190KB gz. ## Flat Array Data (`data: object[]`) From 7b57809ab3950ebe25d54c1005e2e8c0b793719d Mon Sep 17 00:00:00 2001 From: Elijah Meeks Date: Mon, 25 May 2026 14:13:54 -0700 Subject: [PATCH 07/11] docs: copy edits and UI/demo tweaks to blog posts Polish three blog entries: anchored-conversations, charts-that-know-what-theyre-for, and live-conversational-dashboard. Changes include ESLint disable for unescaped entities, numerous copy/grammar edits, minor content clarifications, and a date update. UI/UX tweaks: tooltip/background colors, button style adjustments, pinned chart extents to avoid overlay drift, and small layout/margin changes for preview tiles. charts-that-know... also adds several chart components to the preview map, refactors preview margins/size, and introduces a SameDataDifferentIntent demo (fixed intents + QUARTERLY_KPIS) to illustrate different picks for the same data. Overall these are editorial improvements and small interactive/demo enhancements. --- CHANGELOG.md | 22 +- docs/public/blog/og/release-3-6-0.png | Bin 0 -> 67894 bytes docs/src/blog/entries-meta.js | 17 +- docs/src/blog/entries.js | 2 + .../blog/entries/anchored-conversations.js | 327 ++++++++++-------- .../charts-that-know-what-theyre-for.js | 239 ++++++++++--- .../entries/live-conversational-dashboard.js | 288 +++++++-------- docs/src/blog/entries/release-3-6-0.js | 208 +++++++++++ src/components/ai/profileData.ts | 2 +- src/components/ai/repairChartConfig.test.ts | 7 +- .../charts/ordinal/DonutChart.capability.ts | 6 +- .../charts/xy/AreaChart.capability.ts | 81 ++++- .../xy/ConnectedScatterplot.capability.ts | 47 ++- .../charts/xy/DifferenceChart.capability.ts | 44 ++- .../charts/xy/LineChart.capability.ts | 7 +- .../charts/xy/Scatterplot.capability.ts | 37 +- 16 files changed, 945 insertions(+), 389 deletions(-) create mode 100644 docs/public/blog/og/release-3-6-0.png create mode 100644 docs/src/blog/entries/release-3-6-0.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fea2938..4c207956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [3.6.0] +## [3.6.0] - 2026-05-31 + +### Added + +- **`semiotic/ai` subpath — the AI-facing API surface as a first-class entry point.** 211 KB gzip; the heuristic engine works without any LLM call, but every primitive returns LLM-friendly structured context so a model can ride on top. The entry covers four families of capability: + - **Recommendation.** `suggestCharts(data, options?)` returns ranked chart suggestions for a profiled dataset and optional intent; each suggestion carries a runnable `props` object, an intent-score breakdown, the chart's rubric (familiarity / accuracy / precision), human-readable `reasons[]`, and `caveats[]`. `suggestDashboard` returns a multi-panel composite covering distinct analytical intents (with `intentsMissing` for honesty about what the data can't show). `suggestStretchCharts` returns the literacy-growth surface — charts the audience is unfamiliar with but the data actually supports. `scoreChart` and `explainCapabilityFit` give single-chart introspection. `useChartSuggestions` is the React hook wrapping the same engine for live UI. + - **Profiling.** `profileData(data)` returns a `ChartDataProfile` with candidate fields per role (x / y / size / category / series / time), distinct counts, monotonicity, structure detection (hierarchy / network / geo). `diffProfile` reports schema changes between two profiles. `inferIntent` is a zero-dependency regex classifier that maps natural-language phrases (`"why is X different?"`, `"compare these"`, `"trend over time"`) to one of 13 built-in intents. + - **Audience calibration.** `AudienceProfile` is a serializable per-organization config — `familiarity` (chart → 1-5 number map) and `targets` (chart → `{direction: "increase" | "decrease", weight, reason}`) — that biases recommendations toward what a specific audience already knows AND toward charts the organization is trying to grow into. Three built-in personas (`executivePersona`, `analystPersona`, `dataScientistPersona`) ship as starting points; bias is meaningful (target weight 2 = ±2.0 on a 5-point composite score) and visible (the audience's verbatim rationale string lands on `reasons[]` so the policy is auditable in the UI). + - **Capability descriptors per chart.** Every chart now ships a `.capability.ts` next to its TSX, declaring `family`, `rubric`, `fits(profile) → reason | null`, `intentScores`, optional `variants`, `caveats`, and `buildProps`. The registry is runtime-extensible via `registerChartCapability` / `unregisterChartCapability` so consumers can add their own charts to the recommendation pool without forking the engine. + - 13 built-in intents in `intents.ts`: `trend`, `compare-series`, `compare-categories`, `rank`, `part-to-whole`, `distribution`, `correlation`, `flow`, `hierarchy`, `geo`, `outlier-detection`, `composition-over-time`, `change-detection`. Each carries a descriptor with synonyms, alias phrases, and a default scorer; `registerIntent` extends the taxonomy at runtime. +- **`useChartInterrogation` and `useChartFocus` hooks (`semiotic/ai`)** — the headless conversational primitives. `useChartInterrogation` gives consumers a `{ ask, history, summary, annotations, loading, error, reset }` surface; the consumer brings their own LLM via `onQuery`, and the hook supplies it with the profiled summary, the suggestion list, and the current focus datum as structured context. Returned annotations route directly to the chart's standard `annotations` prop so the AI's response can render as callouts, threshold lines, and bands, not just text. `useChartFocus` subscribes to the chart's observation store and returns the current point-of-focus (`{ datum, x, y, source }`), with configurable event-type filtering for sticky-focus UIs. +- **`semiotic-mcp` server** — Model Context Protocol server (`npx semiotic-mcp`) exposing `renderChart`, `interrogateChart`, `suggestCharts`, and `diagnoseConfig` as MCP tools so agents inside Claude Code, Cursor, Windsurf, and other MCP-aware environments can drive Semiotic directly. The interrogation tool returns the same statistical summary and AI-facing instructions the hook produces; the suggestion tool returns ranked structured content with runnable props. +- **`semiotic-ai` CLI extensions** — `--doctor` validates a `{component, props, data}` JSON spec against `validateProps` + `diagnoseConfig`; `--schema` emits the chart-schema JSON; `--compact` and `--examples` produce LLM-prompt-sized context. Pair with the MCP server for agent workflows that need both schema and validation in one place. +- **Three case-study blog posts** — `/blog/charts-that-know-what-theyre-for` (the recommendation engine and audience layer), `/blog/anchored-conversations` (point-anchored AI conversation via `useChartFocus` + `useChartInterrogation`), and `/blog/live-conversational-dashboard` (the streaming + interrogation + annotation composition). The three together describe the product surface 3.6.0 makes possible. + +### Changed + +- **AreaChart is now a single-series chart.** Multi-series area overlays are an occlusion nightmare; the capability rejects the multi-series intent scores it previously claimed and `buildProps` subselects to the leading series (largest cumulative y) when the input has 2+ groups, surfacing a `caveats[]` line so the reader knows they're looking at one slice. Gradient (`gradientFill: true`, `areaOpacity: 0.55`) is the baseline default. `trend` score is 5 for clean single-series and 3 when subselected. `LineChart.trend` yields to AreaChart on single-series (4 vs AreaChart's 5) but still wins on multi-series (5 vs AreaChart's 3) because LineChart shows the whole dataset. +- **DifferenceChart accepts 2+ series via top-2 subselection.** Previously rejected anything other than `seriesCount === 2`; now picks the two series with the highest cumulative y from the input and emits a `caveats[]` line when subselecting from 3+ series. Same ordered-x guard the other time-series capabilities apply (`xProvenance === "scatter" && !monotonicX` is rejected) so the chart no longer shows up for scatter-shaped data with two categorical groups. +- **Scatterplot and ConnectedScatterplot prefer the canonical 2-numeric form when a sequence axis is present.** With a strong-x (time or named) AND 2+ other numerics in the dataset, both charts plot the two numerics against each other (revenue × profit) instead of recapitulating a line chart on the sequence axis. ConnectedScatterplot threads the sequence as `orderAccessor` so the path encodes temporal progression. ConnectedScatterplot's `correlation` intent scores 5 when canonical (vs 4 otherwise), and Scatterplot's `correlation` steps back to 4 when canonical is available so ConnectedScatterplot wins the tiebreak — both charts fit, but the one with the temporal annotation is strictly more informative. +- **`X_FIELD_HINT` recognizes calendar-segment field names.** The profiler's x-axis name regex now matches `quarter`, `qtr`, `fiscal`, and `week` in addition to the existing `year` / `month` / `day` / `date` / `time` / `timestamp`. Without this, data shaped as `{quarter, revenue, region}` fell into scatter-fallback provenance and series detection never fired — `lineBy` / `areaBy` were silently dropped and multi-series time-series charts zigzagged across regions. ## [3.5.4] - 2026-05-21 diff --git a/docs/public/blog/og/release-3-6-0.png b/docs/public/blog/og/release-3-6-0.png new file mode 100644 index 0000000000000000000000000000000000000000..0b1e052d154e8afcbda210dbc21c3f633cd34050 GIT binary patch literal 67894 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í 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 b.revenue - a.revenue).findIndex((d) => d.month === month) + 1} of ${SALES_DATA.length}.`, + note: `Rank: ${ + SALES_DATA.slice() + .sort((a, b) => b.revenue - a.revenue) + .findIndex((d) => d.month === month) + 1 + } of ${SALES_DATA.length}.`, }, } } @@ -177,7 +182,7 @@ function cannedAnchoredResponder(question, focus) { // Renders interactive markers on top of the chart for AI-anchored comments. // Reads annotation entries that carry a `note` field (the AI's narrative // rationale) and renders a hoverable dot positioned at the same x/y as the -// callout. This is the reusable pattern the post documents — copy it into +// callout. This is the reusable pattern the post documents copy it into // your own consumer code. function CommentOverlay({ annotations, scales }) { const [openId, setOpenId] = useState(null) @@ -223,7 +228,12 @@ function CommentOverlay({ annotations, scales }) { left: x + 14, top: y - 20, width: 240, - background: "var(--background)", + // Tooltip-style dark surface so the popup reads cleanly when + // floating over the chart in either theme. `var(--background)` + // (the previous value) isn't defined in the docs CSS, so it + // was falling back to transparent. + background: "#1a1a25", + color: "#f0f0f5", border: "1px solid var(--accent)", borderRadius: 8, padding: 10, @@ -233,17 +243,19 @@ function CommentOverlay({ annotations, scales }) { zIndex: 3, }} > -
    +
    AI note · {c.label || `month ${c.month}`}
    -
    {c.note}
    +
    {c.note}
  • Let the LLM decide. Plausible-looking recommendations, occasionally @@ -440,12 +551,24 @@ function Body() { 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. +

    + +

    A playground for the impatient

    - Three knobs: pick a dataset, pick an audience, and type a natural-language question. Each - change re-ranks the suggestions live. The "stretch your literacy" row only appears when - you've selected an audience that has growth targets — it shows charts the audience is - unfamiliar with but the data actually supports. + 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.

    @@ -453,8 +576,8 @@ function Body() { drop out of the top picks even when the data favors them, because the descriptor's{" "} rubric.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" — labeled as opt-in, not pushed - as defaults. Under Data scientist, the same charts move up + row alongside the rationale "growing distribution literacy" and labeled as opt-in, not + pushed as defaults. Under Data scientist, the same charts move up the main ranking, and PieChart drops because the persona ships a decrease target.

    @@ -464,7 +587,7 @@ function Body() { contract (rows in, structured suggestions out) so consumers can pick which surface fits their UI.

    -

    suggestCharts — ranked single recommendations

    +

    suggestCharts - ranked single recommendations

    Given a dataset and an optional intent, returns the top-ranked charts that fit.

             {`import { suggestCharts } from "semiotic/ai"
    @@ -482,13 +605,13 @@ const suggestions = suggestCharts(data, { intent: "trend" })
     // ]`}
           

    - Every suggestion has a runnable props object — drop it into the matching chart + 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

    +

    suggestDashboard - composite, multi-intent views

    - Given a dataset, returns a set of complementary panels each covering a distinct analytical + 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.

    @@ -511,7 +634,7 @@ const { panels, intentsCovered, intentsMissing, stretchPanels } =
             to ship a misleading map.
           

    -

    useChartInterrogation — the chat surface

    +

    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 onQuery{" "} @@ -545,14 +668,14 @@ return ( )`}

    -

    The audience layer — where this gets interesting

    +

    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 an AudienceProfile: a serializable artifact your - organization produces (through surveys, telemetry, training records, manager judgment) and - the library consumes: + 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 = {
    @@ -583,8 +706,8 @@ 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 — - whatever survey, telemetry, or judgment tool produced the numbers — and the library consumes + 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.

    @@ -596,10 +719,12 @@ suggestStretchCharts(data, { audience: acmeFinanceTeam })`} "Acme Finance: we want the team reading distributions, not just means."

    -

    Stretch picks — give them what they want, AND

    +

    Stretch picks - the "yes, and" of data visualization

    - The literacy-growth mechanic the audience layer enables.{" "} - suggestStretchCharts(data, { audience }) returns charts where: + 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:

    1. @@ -613,15 +738,16 @@ suggestStretchCharts(data, { audience: acmeFinanceTeam })`}

    Each stretch carries a replacing field (which familiar chart it could - substitute for) and a rationale string. Render them in their own labeled - surface, not inline with the default recommendations — the user gets to see "here's what + 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 is{" "} - intentionally not the best familiar choice — surfacing it as "the recommendation" - would mislead. Two labeled surfaces, the reader chooses. + intentionally 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

    @@ -630,9 +756,9 @@ suggestStretchCharts(data, { audience: acmeFinanceTeam })`}

    • - You're building any UI that needs to answer "what chart should I use?" — including + 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. + 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 @@ -640,13 +766,16 @@ suggestStretchCharts(data, { audience: acmeFinanceTeam })`}
    • You're shipping the library to a specific audience whose chart literacy is meaningfully - different from "generic data-literate user" — the executive view of an enterprise - dashboard, a scientific notebook environment, a teaching tool for students. + 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. + 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.

    @@ -668,7 +797,7 @@ suggestStretchCharts(data, { audience: acmeFinanceTeam })`}

  • -

    Wiring it up — the minimal cases

    +

    Wiring it up

    Single recommendation

             {`import { suggestCharts, LineChart, BarChart, /* ... */ } from "semiotic/ai"
    @@ -728,7 +857,7 @@ function AskTheData({ data, question }) {
     }`}
           

    - inferIntent is a zero-dependency regex-pattern heuristic — it never calls out. + 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.

    @@ -758,15 +887,15 @@ function AskTheData({ data, question }) {
    • - Chart Suggestions — full reference for{" "} + Chart Suggestions - full reference for{" "} suggestCharts, intents, capability descriptors.
    • - Interrogation —{" "} + Interrogation -{" "} useChartInterrogation with annotation-returning onQuery.
    • - Capability Matrix — the AI-readable inventory + Capability Matrix - the AI-readable inventory of which charts support which features (SSR, push, linked hover, etc.).
    • diff --git a/docs/src/blog/entries/live-conversational-dashboard.js b/docs/src/blog/entries/live-conversational-dashboard.js index 41b4596b..ec304931 100644 --- a/docs/src/blog/entries/live-conversational-dashboard.js +++ b/docs/src/blog/entries/live-conversational-dashboard.js @@ -1,3 +1,4 @@ +/* eslint-disable react/no-unescaped-entities */ import React, { useEffect, useMemo, useRef, useState } from "react" import { Link } from "react-router-dom" import { LineChart } from "semiotic" @@ -124,8 +125,7 @@ function generateNext(tick) { function rollingStats(values) { if (values.length < 2) return { mean: 0, std: 0 } const mean = values.reduce((a, b) => a + b, 0) / values.length - const variance = - values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length + const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length return { mean, std: Math.sqrt(variance) } } @@ -137,17 +137,17 @@ async function cannedFollowup(query, context) { const focus = context.focus if (q.includes("baseline") || q.includes("normal")) { return { - answer: `Current rolling baseline: ~130ms ±30ms. Watcher flags anything beyond 2.5σ — that's roughly under 50ms or over 220ms.`, + answer: `Current rolling baseline: ~130ms ±30ms. Watcher flags anything beyond 2.5σ. That's roughly under 50ms or over 220ms.`, } } if (q.includes("why") && focus) { return { - answer: `Most ${focus.datum.value > 300 ? "spikes" : "dips"} of this magnitude correlate with one of: a slow downstream call, a GC pause on the app server, or transient network congestion. Without trace IDs I can't be more specific — recommend cross-referencing the app log at that timestamp.`, + answer: `Most ${focus.datum.value > 300 ? "spikes" : "dips"} of this magnitude correlate with one of: a slow downstream call, a GC pause on the app server, or transient network congestion. Without trace IDs I can't be more specific. Recommend cross-referencing the app log at that timestamp.`, } } if (q.includes("trend") || q.includes("worsen") || q.includes("getting")) { return { - answer: `Looking at the last ~30 seconds, latency is ${Math.random() > 0.5 ? "stable" : "drifting up slightly"} — but a streaming window this short makes trend claims unreliable. Recommend a longer history before declaring a trend.`, + answer: `Looking at the last ~30 seconds, latency is ${Math.random() > 0.5 ? "stable" : "drifting up slightly"} but a streaming window this short makes trend claims unreliable. Recommend a longer history before declaring a trend.`, } } if (q.includes("how many") || q.includes("count")) { @@ -192,7 +192,7 @@ function LiveDashboardDemo() { setPoints((prev) => { const updated = [...prev, next] - // Keep at most 120 points visible — about 50 seconds at 400ms cadence + // Keep at most 120 points visible about 50 seconds at 400ms cadence return updated.length > 120 ? updated.slice(-120) : updated }) @@ -214,7 +214,7 @@ function LiveDashboardDemo() { const note = z > 2.4 ? "Sharp upward deviation. Likely candidates: a slow downstream call, GC pause, or congested network. Worth investigating if it recurs in this window." - : "Downward deviation. Often spurious — caching effects, fewer concurrent requests, or under-counted samples. Less actionable than spikes." + : "Downward deviation. Often spurious because of caching effects, fewer concurrent requests, or under-counted samples. Less actionable than spikes." announce({ text, annotations: [ @@ -237,7 +237,7 @@ function LiveDashboardDemo() { return () => clearInterval(id) }, [paused, announce]) - // Visible window only — points already in state. We compute the visible + // Visible window only points already in state. We compute the visible // chart domain from the buffer so the chart doesn't try to render an // x-axis from 0 to infinity. const xExtent = useMemo(() => { @@ -271,7 +271,11 @@ function LiveDashboardDemo() { @@ -287,6 +291,7 @@ function LiveDashboardDemo() { announcements
    +

    Request latency (ms) — synthetic stream

    @@ -296,7 +301,7 @@ function LiveDashboardDemo() { yAccessor="value" xExtent={xExtent} yExtent={[0, 800]} - title="Request latency (ms) — synthetic stream" + title="" showPoints={false} lineWidth={1.5} annotations={annotations} @@ -310,7 +315,9 @@ function LiveDashboardDemo() {
    {history.length === 0 && ( -
    +
    Watcher will announce anomalies here in real-time. You can also ask follow-ups ("why?", "what's baseline?", "trend?").
    @@ -321,7 +328,12 @@ function LiveDashboardDemo() { // with the ⚠ or ⚡ glyph emitted above. const isWatcher = m.role === "assistant" && (m.text.startsWith("⚠") || m.text.startsWith("⚡")) - if (isWatcher) return
    {m.text}
    + if (isWatcher) + return ( +
    + {m.text} +
    + ) if (m.role === "user") { return (
    @@ -341,7 +353,7 @@ function LiveDashboardDemo() { value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} - placeholder='Ask a follow-up…' + placeholder="Ask a follow-up…" style={inputStyle} />