{"filename":"semiotic-react-dataviz-cursorrules-prompt-file.mdc","content":"---\ndescription: \"Cursor rules for Semiotic data visualization library with 30+ chart types, MCP server, and AI-assisted chart generation.\"\nglobs: **/*\nalwaysApply: false\n---\n# Semiotic — AI Assistant Guide\n\n## Quick Start\n- Install: `npm install semiotic`\n- Import: `semiotic`, `semiotic/xy`, `semiotic/ordinal`, `semiotic/network`, `semiotic/geo`, `semiotic/realtime`, `semiotic/ai`, `semiotic/data`, `semiotic/server`\n- CLI: `npx semiotic-ai [--schema|--compact|--examples|--doctor]`\n- MCP: `npx semiotic-mcp`\n- Every HOC has a built-in error boundary (never blanks the page) and dev-mode validation warnings\n\n## Architecture\n- **HOC Charts**: Simple props, sensible defaults. **Stream Frames**: Full control.\n- **Always use HOC charts** (`ForceDirectedGraph`, `SankeyDiagram`, `LineChart`, `RealtimeLineChart`, `ChoroplethMap`, etc.) unless you need sophisticated control they don't expose. Stream Frames (`StreamNetworkFrame`, `StreamXYFrame`, `StreamOrdinalFrame`, `StreamGeoFrame`) are low-level escape hatches — they accept raw `RealtimeNode`/`RealtimeEdge` wrappers in callbacks, not your data objects directly.\n- Every HOC accepts `frameProps` to pass through. TypeScript `strict: true`.\n\n## Common Props (all HOCs)\n`title`, `width` (600), `height` (400), `responsiveWidth`, `responsiveHeight`, `margin`, `className`, `enableHover` (true), `tooltip` (boolean | `(datum) => ReactNode` | config object), `showLegend`, `showGrid` (false), `frameProps`, `onObservation` (callback, see below), `chartId`, `loading` (false), `emptyContent`, `legendInteraction` (\"none\"|\"highlight\"|\"isolate\"), `legendPosition` (\"right\"|\"left\"|\"top\"|\"bottom\", default \"right\"), `emphasis` (\"primary\"|\"secondary\")\n\n### tooltip\n`tooltip` accepts: `true` (default tooltip), `false` (disabled), a **function** `(datum: Record<string, any>) => ReactNode`, or a config `{ fields?: string[], title?: accessor, format?: fn, style?: CSSProperties }`. The function form receives your raw data object directly.\n\n### onObservation\n`onObservation` receives a `ChartObservation` with `type` and event-specific fields:\n- **hover**: `{ type: \"hover\", datum: <your data>, x, y, timestamp, chartType, chartId }`\n- **hover-end**: `{ type: \"hover-end\", timestamp, chartType, chartId }`\n- **click**: `{ type: \"click\", datum: <your data>, x, y, timestamp, chartType, chartId }`\n- **brush**: `{ type: \"brush\", extent: { x: [min, max], y: [min, max] }, timestamp, chartType }`\n- **selection**: `{ type: \"selection\", selection: { name, fields }, timestamp, chartType }`\n\nThe `datum` field contains your original data object (not a wrapper).\n\n## XY Charts (`semiotic/xy`)\n\n**LineChart** — `data`, `xAccessor` (\"x\"), `yAccessor` (\"y\"), `lineBy`, `lineDataAccessor` (\"coordinates\"), `colorBy`, `colorScheme`, `curve`, `lineWidth` (2), `showPoints`, `pointRadius` (3), `fillArea`, `areaOpacity` (0.3), `anomaly` (AnomalyConfig), `forecast` (ForecastConfig), `directLabel` (boolean|{position,fontSize}), `gapStrategy` (\"break\"|\"interpolate\"|\"zero\"), `xScaleType` (\"linear\"|\"log\"), `yScaleType` (\"linear\"|\"log\")\n\n**AreaChart** — LineChart props + `areaBy`, `y0Accessor` (band/ribbon), `gradientFill` (boolean|{topOpacity,bottomOpacity}), `areaOpacity` (0.7), `showLine` (true)\n\n**StackedAreaChart** — flat array data + `areaBy` (required, groups into stacked areas), `colorBy`, `normalize` (false). Do NOT use `lineBy` or `lineDataAccessor` — those are LineChart props.\n\n**Scatterplot** — `data`, `xAccessor`, `yAccessor`, `colorBy`, `sizeBy`, `sizeRange`, `pointRadius` (5), `pointOpacity` (0.8), `marginalGraphics`\n\n**BubbleChart** — Scatterplot + `sizeBy` (required), `sizeRange` ([5,40]), `bubbleOpacity` (0.6)\n\n**ConnectedScatterplot** — `data`, `xAccessor`, `yAccessor`, `orderAccessor` (number|Date field for sequencing), `pointRadius` (4). Viridis colored start→end, line width = point radius, white halo under lines when <100 points.\n\n**QuadrantChart** — Scatterplot divided into four labeled, colored quadrants. `data`, `xAccessor`, `yAccessor`, `quadrants` (required: `{ topRight, topLeft, bottomRight, bottomLeft }` each with `label`, `color`, optional `opacity`), `xCenter` (vertical center line in data units), `yCenter` (horizontal center line), `centerlineStyle` (`{ stroke, strokeWidth, strokeDasharray }`), `showQuadrantLabels` (true), `quadrantLabelSize` (12), `colorBy`, `sizeBy`, `sizeRange`, `pointRadius` (5), `pointOpacity` (0.8). Supports push API. Quadrant fills and labels drawn via `canvasPreRenderers`.\n\n**Heatmap** — `data`, `xAccessor`, `yAccessor`, `valueAccessor`, `colorScheme` (\"blues\"|\"reds\"|\"greens\"|\"viridis\" or custom), `showValues`, `cellBorderColor`. Accessors can be string field names (including string/categorical fields) or functions.\n\n## Ordinal Charts (`semiotic/ordinal`)\n\n**BarChart** — `data`, `categoryAccessor`, `valueAccessor`, `orientation`, `colorBy`, `sort`, `barPadding` (40)\n**StackedBarChart** — + `stackBy` (required), `normalize`, `barPadding` (40)\n**GroupedBarChart** — + `groupBy` (required), `barPadding` (60)\n**SwarmPlot** — `data`, `categoryAccessor`, `valueAccessor`, `colorBy`, `sizeBy`, `pointRadius`, `pointOpacity`\n**BoxPlot** — + `showOutliers`, `outlierRadius`\n**Histogram** — + `bins` (25), `relative`. Always horizontal. `categoryAccessor` is optional (defaults to `\"category\"`) — for a single-group histogram, either omit it or ensure your data has a `category` field with a single value.\n**ViolinPlot** — + `bins`, `curve`, `showIQR`\n**DotPlot** — + `sort` (true), `dotRadius`, `showGrid` default true\n**PieChart** — `data`, `categoryAccessor`, `valueAccessor`, `colorBy`, `startAngle`, `slicePadding`\n**DonutChart** — PieChart + `innerRadius` (60), `centerContent` (ReactNode — any React element, e.g. `<div>50%</div>`)\n\n## Network Charts (`semiotic/network`)\n\n**ForceDirectedGraph** — `nodes`, `edges`, `nodeIDAccessor`, `sourceAccessor`, `targetAccessor`, `colorBy`, `colorScheme`, `nodeSize` (number|string|fn), `nodeSizeRange`, `edgeWidth`, `edgeColor`, `edgeOpacity`, `iterations` (300), `forceStrength` (0.1), `showLabels`, `nodeLabel`, `tooltip`, `showLegend`, `legendInteraction`\n**SankeyDiagram** — `edges`, `nodes`, `valueAccessor`, `edgeColorBy`, `orientation`, `nodeAlign`, `nodeWidth`, `showLabels`, `edgeOpacity`\n**ChordDiagram** — `edges`, `nodes`, `valueAccessor`, `edgeColorBy`, `padAngle`, `groupWidth`, `showLabels`\n**TreeDiagram** — `data` (root), `layout`, `orientation`, `childrenAccessor`, `colorBy`, `colorByDepth`, `edgeStyle`\n**Treemap** — `data` (root), `childrenAccessor`, `valueAccessor`, `colorBy`, `colorByDepth`, `showLabels`, `labelMode`\n**CirclePack** — `data` (root), `childrenAccessor`, `valueAccessor`, `colorBy`, `colorByDepth`, `circleOpacity`\n**OrbitDiagram** — animated radial/orbital hierarchy. Use this (not TreeDiagram) when you want animated orbiting nodes. `data` (root), `childrenAccessor`, `nodeIdAccessor`, `orbitMode` (\"flat\"|\"solar\"|\"atomic\"|number[]), `speed` (0.25), `revolution`, `eccentricity`, `orbitSize`, `nodeRadius`, `showRings`, `showLabels`, `animated` (true), `colorBy`, `colorByDepth`, `annotations` (widget annotations anchor by nodeId). For static radial trees, use `TreeDiagram layout=\"radial\"` instead.\n\n## Geo Charts (`semiotic/geo`)\n\nGeographic visualization with d3-geo projections. Canvas-rendered via `StreamGeoFrame`. Import from `semiotic/geo` to avoid adding d3-geo to non-geo bundles.\n\n**ChoroplethMap** — `areas` (GeoJSON Feature[] or reference string like \"world-110m\"), `valueAccessor`, `colorScheme` (\"blues\"|\"reds\"|\"greens\"|\"viridis\"), `areaOpacity` (1), `projection` (\"equalEarth\"), `graticule`, `tooltip`, `showLegend`\n**ProportionalSymbolMap** — `points`, `xAccessor` (\"lon\"), `yAccessor` (\"lat\"), `sizeBy`, `sizeRange` ([3,30]), `colorBy`, `areas` (optional background), `projection`\n**FlowMap** — `flows` ({source, target, value}), `nodes`, `xAccessor`, `yAccessor`, `nodeIdAccessor` (\"id\"), `valueAccessor` (\"value\"), `edgeColorBy`, `edgeOpacity` (0.6), `edgeWidthRange` ([1,8]), `edgeLinecap` (\"round\"), `lineType` (\"geo\"|\"line\"), `areas` (optional background), `showParticles`, `particleStyle` ({ radius, color, opacity, speedMultiplier, maxPerLine, spawnRate }). Particle `color` accepts a string, `\"source\"` (inherit line stroke), or `(datum) => string`.\n**DistanceCartogram** — `points`, `center` (id of center node), `costAccessor`, `strength` (0-1), `lineMode` (\"straight\"|\"fractional\"), `nodeIdAccessor` (\"id\"), `lines`, `projection`, `showRings` (true|false|number[]), `ringStyle` ({ stroke, strokeWidth, ... }), `showNorth` (true), `costLabel` (string for ring labels), `transition` (ms for smooth animation), `pointRadius`\n\nAll geo HOCs support: `selection`, `linkedHover`, `onObservation`, `showLegend`, `legendInteraction`, `tooltip`, `loading`, `emptyContent`, `frameProps`, `fitPadding` (0–1 fraction, insets auto-fit projection from edges), `zoomable` (defaults true with tileURL, false otherwise), `zoomExtent`, `onZoom`, `dragRotate`, `graticule`, `tileURL`, `tileAttribution`, `tileCacheSize`\n\n**Zoom/Pan**: All geo charts accept `zoomable` (boolean), `zoomExtent` ([minZoom, maxZoom], default [1, 8]), and `onZoom` (callback with `{ projection, zoom }`). Re-renders projection directly on every zoom tick (no CSS transform). Imperative API: `ref.current.getZoom()`, `ref.current.resetZoom()`.\n\n**Geo Particles**: `FlowMap` and `StreamGeoFrame` support `showParticles` (boolean) and `particleStyle` to animate dots flowing along line paths. Uses `GeoParticlePool` — an object-pool polyline particle system. Particle `color` accepts: `\"source\"` (inherit line stroke), a CSS string, or `(datum) => string` for per-line color.\n\n**Drag Rotate (Globe Spinning)**: `dragRotate` (boolean) — when true, drag gestures rotate the projection (globe spinning) instead of panning. **Defaults to true for orthographic projection.** Scroll-wheel zoom still works normally. Explicitly set `dragRotate={false}` on orthographic to get standard pan behavior, or `dragRotate={true}` on other projections to enable rotation. Latitude rotation is clamped to [-90, 90] to prevent flipping.\n\n**Tile Maps**: All geo charts accept `tileURL` (string template like `\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\"` or `(z, x, y, dpr) => string`), `tileAttribution` (e.g., `\"© OpenStreetMap contributors\"`), `tileCacheSize` (default 256). Tiles render on a background canvas behind data layers. **Mercator projection only** — a dev warning is emitted for non-Mercator projections. Tiles update on zoom/pan. Retina support via `{r}` placeholder or DPR parameter. **Production**: OpenStreetMap tiles are for development/demo only. For production, use a commercial tile provider (Mapbox, MapTiler, Stadia Maps) with your own API key passed via environment variable (never hard-code keys in client code). Example: `tileURL={\\`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/{z}/{x}/{y}?access_token=\\${process.env.MAPBOX_TOKEN}\\`}`.\n\n**StreamGeoFrame** — low-level frame with full control. Props: `projection`, `areas`, `points`, `lines`, `xAccessor`, `yAccessor`, `areaStyle`, `pointStyle`, `lineStyle`, `graticule`, `projectionTransform` (distance cartogram config), `projectionExtent`, `enableHover`, `tooltipContent`, `zoomable`, `zoomExtent`, `onZoom`, `tileURL`, `tileAttribution`, `tileCacheSize`, `decay`, `pulse`, `transition`. Push API: `ref.current.push(datum)`, `ref.current.pushMany(data)`, `ref.current.clear()`.\n\n**Reference geography**: `resolveReferenceGeography(\"world-110m\")` returns GeoJSON features from Natural Earth data (world-atlas). Supported: `\"world-110m\"`, `\"world-50m\"`, `\"land-110m\"`, `\"land-50m\"`. All geo HOCs accept `areas` as `GeoJSON.Feature[]` or a reference string.\n\n**mergeData(features, data, { featureKey, dataKey })** — join external data into GeoJSON features by key field. Supports nested paths (e.g., `\"properties.iso_a3\"`). World-atlas uses ISO 3166-1 numeric codes as the `id` field. Also available from `semiotic/data` as a general join-by-key utility.\n\n```jsx\n// World choropleth with reference geography + data joining\nimport { ChoroplethMap, resolveReferenceGeography, mergeData } from \"semiotic/geo\"\nconst world = await resolveReferenceGeography(\"world-110m\")\nconst areas = mergeData(world, gdpData, { featureKey: \"id\", dataKey: \"id\" })\n<ChoroplethMap areas={areas} valueAccessor=\"gdpPerCapita\" colorScheme=\"viridis\"\n  projection=\"equalEarth\" zoomable tooltip />\n\n// Distance cartogram (ORBIS-style) with concentric rings overlay\nimport { DistanceCartogram } from \"semiotic/geo\"\n<DistanceCartogram\n  points={cities} center=\"rome\" costAccessor=\"travelDays\"\n  strength={0.8} lines={routes} showLegend zoomable\n  showRings costLabel=\"days\" showNorth\n  ringStyle={{ stroke: \"#999\", strokeWidth: 0.5 }}\n/>\n\n// Tile map basemap with proportional symbols\n<ProportionalSymbolMap\n  points={earthquakes} xAccessor=\"lon\" yAccessor=\"lat\"\n  sizeBy=\"magnitude\" sizeRange={[2, 20]}\n  projection=\"mercator\" zoomable\n  tileURL=\"https://tile.openstreetmap.org/{z}/{x}/{y}.png\"\n  tileAttribution=\"© OpenStreetMap contributors\"\n/>\n\n// Streaming geo points with zoom\nconst geoRef = useRef()\ngeoRef.current.push({ lon: -122.4, lat: 37.8, value: 42 })\n<StreamGeoFrame ref={geoRef} projection=\"mercator\" xAccessor=\"lon\" yAccessor=\"lat\"\n  runtimeMode=\"streaming\" decay={{ type: \"linear\", minOpacity: 0.1 }}\n  zoomable zoomExtent={[1, 12]} onZoom={({ zoom }) => console.log(zoom)} />\n```\n\n## Realtime Charts (`semiotic/realtime`)\n\nPush API: `chartRef.current.push({ time, value })`\n\n**IMPORTANT**: All pushed data must include a time field (default: `\"time\"`). If your data uses a different field name, set `timeAccessor` explicitly. Without a valid time field, charts render blank with no error.\n\nSizing: all Realtime HOCs accept both `size={[600, 400]}` (tuple) and `width={600} height={400}`. Either works.\n\n**RealtimeLineChart** — `size`|`width`+`height`, **`timeAccessor`** (\"time\"), **`valueAccessor`** (\"value\"), `windowSize` (200), `windowMode`, `stroke`, `strokeWidth`\n**RealtimeHistogram** — **`binSize`** (required), **`timeAccessor`** (\"time\"), **`valueAccessor`** (\"value\"), `categoryAccessor`, `colors`. Time field is required even though this shows a distribution — it's used for windowing.\n**RealtimeSwarmChart** — **`timeAccessor`** (\"time\"), **`valueAccessor`** (\"value\"), `categoryAccessor`, `radius`, `opacity`\n**RealtimeWaterfallChart** — **`timeAccessor`** (\"time\"), **`valueAccessor`** (\"value\"), `positiveColor`, `negativeColor`\n**RealtimeHeatmap** — **`timeAccessor`** (\"time\"), **`valueAccessor`** (\"value\"), `heatmapXBins`, `heatmapYBins`, `aggregation`. Both accessors must match your data fields or the chart renders blank.\n**Streaming Sankey** — `StreamNetworkFrame` with `chartType=\"sankey\"`, `showParticles` (boolean), `particleStyle` (`{ radius, opacity, speedMultiplier, maxPerEdge, colorBy }`), `tensionConfig`, `thresholds`. Push **individual edges**: `ref.current.push({ source: \"A\", target: \"B\", value: 42 })`. Use `ref.current.pushMany([...edges])` for batches.\n\nRealtime encoding: `decay`, `pulse`, `transition`, `staleness` — compose freely on all streaming charts.\n\n### Realtime data shape\n```jsx\n// Every pushed datum should have a time field\nref.current.push({ time: Date.now(), value: 42 })              // line, waterfall\nref.current.push({ time: Date.now(), value: 42, category: \"A\" }) // histogram, swarm\nref.current.push({ time: Date.now(), value: 42 })              // heatmap (time=x, value=y)\n```\n\n### Push API on HOC charts\nMany HOC charts support the push API via `forwardRef`. Omit the `data` prop and push data imperatively:\n```jsx\nconst chartRef = useRef()\nchartRef.current.push({ x: 1, y: 2 })          // single point\nchartRef.current.pushMany([...points])           // batch\nchartRef.current.clear()                          // reset\nchartRef.current.getData()                        // read current data\n<Scatterplot ref={chartRef} xAccessor=\"x\" yAccessor=\"y\" />\n```\n**IMPORTANT**: When using the push API, **omit** the `data`/`nodes`/`edges` prop entirely — do NOT pass `data={[]}`, which clears pushed data on every render. Streaming-specific props (`windowSize`, `decay`, `pulse`) go in `frameProps`.\n\nSupported: all XY charts (LineChart, AreaChart, Scatterplot, etc.), all ordinal charts (BarChart, Histogram, etc.), network charts (ForceDirectedGraph, SankeyDiagram, ChordDiagram), and geo point charts (ProportionalSymbolMap, DistanceCartogram). **Not supported**: hierarchy charts (TreeDiagram, Treemap, CirclePack, OrbitDiagram) — their root-object data shape is incompatible with flat push. ChoroplethMap (area-based, not point-based), FlowMap (line-based), and ScatterplotMatrix also do not support push.\n\n## Stream Frame Callbacks (advanced — prefer HOCs)\nStream Frame callbacks (`nodeStyle`, `edgeStyle`, `nodeSize` as function, `colorBy` as function, `nodeLabel` as function) receive **`RealtimeNode`/`RealtimeEdge`** wrappers, NOT your raw data. Access your original data via `.data`:\n```jsx\n// WRONG: nodeSize={(d) => d.weight}         — d is RealtimeNode, d.weight is undefined\n// RIGHT: nodeSize={(d) => d.data?.weight}   — d.data is your original node object\n// RIGHT: nodeSize=\"weight\"                  — string accessor handles this automatically\n// WRONG: nodeStyle={(d) => ({ fill: d.datum.color })}  — .datum does not exist\n// RIGHT: nodeStyle={(d) => ({ fill: d.data?.color })}  — use .data\n```\n`customHoverBehavior` and `customClickBehavior` receive `{ type: \"node\"|\"edge\", data: <your raw object>, x, y } | null`.\n`tooltipContent` receives `{ type: \"node\"|\"edge\", data: <your raw object> }`.\n\n## Coordinated Views\n\n**LinkedCharts** — wraps charts. Props: `selections` (resolution: \"union\"|\"intersect\"|\"crossfilter\"), `showLegend` (auto when CategoryColorProvider present), `legendPosition` (\"top\"|\"bottom\"), `legendInteraction` (\"highlight\"|\"isolate\"|\"none\"), `legendSelectionName` (selection name for legend-driven cross-highlighting), `legendField` (data field for legend selections)\n**CategoryColorProvider** — stable category→color mapping. Props: `colors` (map) or `categories` + `colorScheme`\nChart props: `selection`, `linkedHover`, `linkedBrush`. Hooks: `useSelection`, `useLinkedHover`, `useBrushSelection`, `useFilteredData`\n**ScatterplotMatrix** — `data`, `fields`, `colorBy`, `cellSize`, `hoverMode`, `brushMode`\n\n## ChartContainer\n\n**ChartContainer** — wrapper with title, subtitle, status indicator, toolbar actions. Props: `title`, `subtitle`, `height` (default **400** — set this to match your chart's height or you'll get extra whitespace), `width` (default \"100%\"), `status` (\"live\"|\"stale\"|\"error\"), `loading`, `error`, `errorBoundary`, `actions` (`{ export, fullscreen, copyConfig }`), `controls`, `style`, `className`\n\nWhen using `ChartContainer` with a chart that has `size={[w, h]}`, always set `height={h}` on the container to avoid a mismatch.\n\n## Layout & Composition\n\n**ChartGrid** — CSS Grid layout. `columns` (number|\"auto\"), `minCellWidth` (300), `gap` (16). Children with `emphasis=\"primary\"` span two columns.\n**ContextLayout** — primary + context panel. `context` (ReactNode), `position`, `contextSize` (250)\n\n## Key Patterns\n\n```jsx\n// Force-directed graph with custom sizing and hover\n<ForceDirectedGraph\n  nodes={[{ id: \"A\", group: \"eng\", weight: 10 }, { id: \"B\", group: \"design\", weight: 5 }]}\n  edges={[{ source: \"A\", target: \"B\" }]}\n  colorBy=\"group\"\n  nodeSize=\"weight\"           // string accessor → reads node.weight, scales to nodeSizeRange\n  nodeSizeRange={[5, 25]}\n  showLabels\n  showLegend\n  tooltip={(d) => <div>{d.id}: {d.weight}</div>}\n  frameProps={{\n    customClickBehavior: (d) => { if (d?.type === \"node\") console.log(d.data) },\n    background: \"#f5f5f5\",\n  }}\n/>\n\n// Cross-highlighting dashboard with column spanning\n// emphasis=\"primary\" makes a chart span 2 columns in ChartGrid\n<CategoryColorProvider categories={[\"North\", \"South\", \"East\"]}>\n<LinkedCharts>\n  <ChartGrid columns={2}>\n    <LineChart data={d} colorBy=\"region\" linkedHover={{ name: \"hl\", fields: [\"region\"] }} selection={{ name: \"hl\" }} emphasis=\"primary\" responsiveWidth />\n    <BarChart data={d} colorBy=\"region\" linkedHover={{ name: \"hl\", fields: [\"region\"] }} selection={{ name: \"hl\" }} responsiveWidth />\n    <Scatterplot data={d} colorBy=\"region\" linkedHover={{ name: \"hl\", fields: [\"region\"] }} selection={{ name: \"hl\" }} responsiveWidth />\n  </ChartGrid>\n</LinkedCharts>\n</CategoryColorProvider>\n\n// Forecast + anomaly (auto)\n<LineChart data={ts} xAccessor=\"time\" yAccessor=\"value\"\n  forecast={{ trainEnd: 60, steps: 15, confidence: 0.95 }}\n  anomaly={{ threshold: 2 }} />\n\n// Forecast (pre-computed ML bounds)\n<LineChart data={ml} xAccessor=\"time\" yAccessor=\"value\"\n  forecast={{ isTraining: \"isTraining\", isForecast: \"isForecast\", isAnomaly: \"isAnomaly\", upperBounds: \"upper\", lowerBounds: \"lower\" }} />\n\n// Stacked area (flat array + areaBy, NOT lineBy)\n<StackedAreaChart data={flatData} xAccessor=\"month\" yAccessor=\"value\"\n  areaBy=\"category\" colorBy=\"category\" />\n\n// Percentile band (p5–p95) with main line (p50) — MUST layer two charts\n// AreaChart with y0Accessor renders the band; showLine only draws the TOP edge (p95), not p50\n// To show a separate main line, add a LineChart on top:\n<>\n  <AreaChart data={d} xAccessor=\"x\" yAccessor=\"p95\" y0Accessor=\"p5\"\n    showLine={false} areaOpacity={0.3} gradientFill />\n  <LineChart data={d} xAccessor=\"x\" yAccessor=\"p50\" lineWidth={2} />\n</>\n// Simple gradient area (no band):\n<AreaChart data={d} xAccessor=\"x\" yAccessor=\"y\" gradientFill />\n\n// Realtime — always include time field in pushed data\nconst ref = useRef()\nref.current.push({ time: Date.now(), value: 42 })\n<RealtimeLineChart ref={ref} timeAccessor=\"time\" valueAccessor=\"value\" />\n\n// Realtime histogram — time field required even for distribution charts\nconst histRef = useRef()\nhistRef.current.push({ time: Date.now(), value: Math.abs(delta) })\n<RealtimeHistogram ref={histRef} timeAccessor=\"time\" valueAccessor=\"value\" binSize={100} />\n\n// Streaming sankey with particles — push individual edges, NOT full snapshots\nconst sankeyRef = useRef()\nsankeyRef.current.push({ source: \"Web\", target: \"API\", value: 1 })    // one edge at a time\nsankeyRef.current.pushMany([                                            // or batch\n  { source: \"Web\", target: \"API\", value: 3 },\n  { source: \"API\", target: \"DB\", value: 2 },\n])\n<StreamNetworkFrame\n  ref={sankeyRef}\n  chartType=\"sankey\"\n  showParticles={true}\n  particleStyle={{ radius: 2, colorBy: \"source\", speedMultiplier: 1.5 }}\n  width={600} height={400}\n/>\n\n// SSR — renderToStaticSVG takes frame type string, not component name\nimport { renderOrdinalToStaticSVG } from \"semiotic/server\"\nconst svg = renderOrdinalToStaticSVG({\n  data, categoryAccessor: \"category\", valueAccessor: \"value\", width: 600, height: 400\n})\n```\n\n## Annotations\n- `type: \"widget\"` — place any React element at data coordinates. Works on all frame types. XY/ordinal use data coordinates (`x`/`y` or field names). Network/orbit use `nodeId`. Default: info emoji. Renders as HTML overlay (not SVG) so popups/threads overflow freely.\n```jsx\nannotations={[{ type: \"widget\", month: 4, revenue: 32, dy: -4, content: <MyAlertButton /> }]}\n// OrbitDiagram: annotations={[{ type: \"widget\", nodeId: \"Pipeline\", content: <Alert /> }]}\n```\n\n## Server-Side Rendering\n- All HOC charts and Stream Frames render SVG automatically in server environments (no window/document)\n- `renderToStaticSVG(frameType, props)` — standalone SVG string from `semiotic/server`. `frameType` is `\"xy\"` | `\"ordinal\"` | `\"network\"` | `\"geo\"` (NOT a component name like \"BarChart\")\n- Type-specific shortcuts: `renderXYToStaticSVG(props)`, `renderOrdinalToStaticSVG(props)`, `renderNetworkToStaticSVG(props)`, `renderGeoToStaticSVG(props)`\n- For a bar chart: `renderOrdinalToStaticSVG({ data, categoryAccessor: \"cat\", valueAccessor: \"val\", width: 600, height: 400 })`\n- Works with Next.js App Router, Remix, Astro — same component on server and client\n- **Geo SSR requires pre-resolved features**: `renderGeoToStaticSVG` is synchronous — pass GeoJSON features directly, not reference strings like `\"world-110m\"`. Call `await resolveReferenceGeography(\"world-110m\")` first and pass the result as `areas`.\n\n## AI Features\n- `onObservation` — structured events (hover, click, brush, selection) on all HOCs\n- `useChartObserver` — aggregates observations across LinkedCharts\n- `toConfig`/`fromConfig`/`toURL`/`fromURL`/`copyConfig`/`configToJSX` — chart state serialization\n- `DetailsPanel` — click-driven detail panel inside `ChartContainer`\n- `validateProps(componentName, props)` — prop validation with Levenshtein typo suggestions\n- `diagnoseConfig(componentName, props)` — anti-pattern detector (12 checks: empty data, bad dimensions, missing accessors, margin overflow, etc.)\n- `ChartErrorBoundary` — error boundary\n- `exportChart(containerDiv, { format: \"png\"|\"svg\" })` — pass the **wrapper div** (not the SVG element); it finds canvas + SVG internally. Default: PNG, composites canvas + SVG layers\n- `npx semiotic-ai --doctor` — validate component + props JSON from CLI (uses both validateProps and diagnoseConfig)\n\n## Known Pitfalls\n\n**Tooltip datum shape**: HOC tooltip functions receive your raw data object. When using `frameProps.tooltipContent` on Stream Frames, the datum may be wrapped — access your data via `d.data`. HOC `tooltip` functions don't need this.\n\n**Legend positioning**: `legendPosition` controls where the legend renders. When set to \"bottom\", the chart automatically expands the bottom margin to ~80px to clear axis labels. For \"top\", margin expands to ~50px. If you need more space, override `margin` explicitly. For charts narrower than ~400px, prefer `legendPosition=\"bottom\"` or `\"top\"` (bottom is more common) to avoid squeezing the chart area. Similarly, for short charts (~250px or less), a side legend may compress the chart too much — use top or bottom instead.\n\n**Log scale and zero**: `xScaleType=\"log\"` / `yScaleType=\"log\"` clamp domain minimums to 1e-6 because log(0) is undefined. Data with zero or negative values will be clamped.\n\n**Heatmap with string axes**: Heatmap supports string/categorical x and y values (e.g., weekday names, hour labels). The `colorScheme` prop accepts d3-scale-chromatic names: \"blues\", \"reds\", \"greens\", \"viridis\".\n\n**barPadding is in pixels**: `barPadding` on ordinal charts is an absolute pixel value divided by the chart width to compute a band scale padding ratio. The defaults (40 for bar/stacked, 60 for grouped) work well at 600px width. For very small charts, you may need to reduce it.\n\n**Horizontal bar charts need wider left margins**: When using `orientation=\"horizontal\"` with long category labels, increase the left margin manually: `margin={{ left: 120 }}`. There is no auto-measurement of label width.\n\n**LinkedCharts suppresses child legends**: When a `CategoryColorProvider` wraps `LinkedCharts`, individual chart legends are suppressed in favor of a unified legend. To force a child chart to show its own legend, set `showLegend={true}` explicitly.\n\n**Geo bundle isolation**: `semiotic/geo` is a separate entry point. Do NOT import geo components from `semiotic` — use `import { ChoroplethMap } from \"semiotic/geo\"` to avoid pulling d3-geo (~30KB) into non-geo bundles.\n\n**Push API: omit data, don't pass empty array**: When using `ref.current.push()` on HOCs, **omit** the `data`/`nodes`/`edges` prop entirely. Passing `data={[]}` clears pushed data on every render because the HOC forwards it to the Stream Frame's `setBoundedData([])`. Similarly, `data={undefined}` is fine (prop not present), but `data={null}` is treated the same as omitted.\n\n**`diagnoseConfig` catches common mistakes**: Run `diagnoseConfig(\"BarChart\", props)` to check for empty data, bad dimensions, missing accessors, margin overflow, invisible bar padding, and more. Use `npx semiotic-ai --doctor` from CLI.\n\n## Differentiators\nNetwork viz, geographic viz (choropleth, flow maps, distance cartograms), streaming canvas, realtime encoding, coordinated views, statistical summaries, AI hooks, chart serialization, global theming, keyboard navigation, interactive legends (highlight/isolate), direct labeling, gap handling, empty/loading states, landmark tick labels, LinkedCharts unified legend\n"}