Skip to content

Commit 3ce5e7b

Browse files
committed
Stabilize accessors, add raster output for MCP
1 parent 60599d5 commit 3ce5e7b

9 files changed

Lines changed: 302 additions & 21 deletions

File tree

CLAUDE.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,5 +467,29 @@ import { CategoryColorProvider, LinkedCharts } from "semiotic"
467467
468468
**`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.
469469
470+
## Performance
471+
472+
**Prefer string accessors over function accessors**: `xAccessor="value"` is always referentially stable. `xAccessor={d => d.value}` creates a new function on every parent render, which can trigger unnecessary scene rebuilds. The pipeline stores use `.toString()` comparison to detect functionally identical inline arrow functions and skip re-resolution, but this heuristic doesn't work for closures that capture changing variables. When you must use a function accessor, memoize it with `useCallback` or define it outside the component:
473+
474+
```jsx
475+
// Best: string accessor (always stable)
476+
<LineChart data={d} xAccessor="time" yAccessor="value" />
477+
478+
// Good: function defined outside component (stable reference)
479+
const getX = (d) => d.timestamp.getTime()
480+
function MyChart() {
481+
return <LineChart data={d} xAccessor={getX} yAccessor="value" />
482+
}
483+
484+
// Acceptable: memoized function (stable across re-renders)
485+
function MyChart() {
486+
const getX = useCallback((d) => d.timestamp.getTime(), [])
487+
return <LineChart data={d} xAccessor={getX} yAccessor="value" />
488+
}
489+
490+
// Avoid: inline arrow function (new reference every render)
491+
<LineChart data={d} xAccessor={d => d.timestamp.getTime()} yAccessor="value" />
492+
```
493+
470494
## Differentiators
471495
Network viz, geographic viz (choropleth, flow maps, distance cartograms), streaming canvas, realtime encoding, coordinated views, statistical summaries, AI hooks, chart serialization, CSS custom property theming (17 tokens), ThemeProvider + CSS-only theming, color-blind safe palettes, tooltip theming, annotation theming, keyboard navigation, interactive legends (highlight/isolate), direct labeling, gap handling, empty/loading states, landmark tick labels, LinkedCharts unified legend

ai/dist/mcp-server.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ async function renderChartHandler(args) {
315315
const component = args.component;
316316
const props = args.props ?? {};
317317
const theme = args.theme;
318+
const format = args.format || "svg";
318319
if (!component) {
319320
return {
320321
content: [{ type: "text", text: `Missing 'component' field. Provide { component: '<name>', props: { ... } }. Available: ${componentNames.join(", ")}` }],
@@ -345,6 +346,31 @@ async function renderChartHandler(args) {
345346
svg = `<div style="${validVars}">${svg}</div>`;
346347
}
347348
}
349+
// PNG rasterization via sharp (optional dependency)
350+
if (format === "png") {
351+
try {
352+
// Dynamic import — sharp is an optional dependency
353+
// eslint-disable-next-line @typescript-eslint/no-var-requires
354+
const sharpMod = await Function('return import("sharp")')();
355+
const sharpFn = sharpMod.default || sharpMod;
356+
const pngBuffer = await sharpFn(Buffer.from(svg)).png().toBuffer();
357+
const base64 = pngBuffer.toString("base64");
358+
return {
359+
content: [{ type: "text", text: `data:image/png;base64,${base64}` }],
360+
};
361+
}
362+
catch (err) {
363+
if (err.code === "MODULE_NOT_FOUND" || err.code === "ERR_MODULE_NOT_FOUND") {
364+
return {
365+
content: [{ type: "text", text: `PNG output requires the 'sharp' package. Install it with: npm install sharp\n\nFalling back to SVG output:\n\n${svg}` }],
366+
};
367+
}
368+
return {
369+
content: [{ type: "text", text: `PNG conversion failed: ${err.message}\n\nSVG output:\n\n${svg}` }],
370+
isError: true,
371+
};
372+
}
373+
}
348374
return {
349375
content: [{ type: "text", text: svg }],
350376
};
@@ -476,10 +502,11 @@ function createServer() {
476502
data: zod_1.z.array(zod_1.z.record(zod_1.z.string(), zod_1.z.unknown())).min(1).max(5).describe("1-5 sample data objects"),
477503
intent: zod_1.z.enum(["comparison", "trend", "distribution", "relationship", "composition", "geographic", "network", "hierarchy"]).optional().describe("Visualization intent to narrow suggestions"),
478504
}, suggestChartHandler);
479-
srv.tool("renderChart", `Render a Semiotic chart to static SVG. Returns SVG string or validation errors. Optionally pass theme CSS custom properties (--semiotic-bg, --semiotic-text, etc.) to style the output. Available components: ${componentNames.join(", ")}.`, {
505+
srv.tool("renderChart", `Render a Semiotic chart to static SVG or PNG. Returns SVG string (default) or Base64-encoded PNG image. Optionally pass theme CSS custom properties (--semiotic-bg, --semiotic-text, etc.) to style the output. PNG requires the 'sharp' package to be installed. Available components: ${componentNames.join(", ")}.`, {
480506
component: zod_1.z.string().describe("Chart component name, e.g. 'LineChart', 'BarChart'"),
481507
props: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional().describe("Chart props object, e.g. { data: [...], xAccessor: 'x' }."),
482508
theme: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional().describe("CSS custom properties for theming, e.g. { '--semiotic-bg': '#1a1a2e', '--semiotic-text': '#ededed' }. Only --semiotic-* variables are applied."),
509+
format: zod_1.z.enum(["svg", "png"]).optional().describe("Output format: 'svg' (default) returns SVG markup, 'png' returns a Base64-encoded PNG image. PNG requires the 'sharp' package."),
483510
}, renderChartHandler);
484511
srv.tool("diagnoseConfig", "Diagnose a Semiotic chart configuration for common problems (empty data, bad dimensions, missing accessors, wrong data shape, color contrast issues, etc). Checks WCAG color contrast ratios and suggests COLOR_BLIND_SAFE_CATEGORICAL for accessibility. Returns a human-readable diagnostic report with actionable fixes.", {
485512
component: zod_1.z.string().describe("Chart component name, e.g. 'LineChart'"),

ai/mcp-server.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,10 +305,11 @@ async function suggestChartHandler(args: { data?: any[]; intent?: string }): Pro
305305
}
306306
}
307307

308-
async function renderChartHandler(args: { component?: string; props?: Record<string, any>; theme?: Record<string, string> }): Promise<ToolResult> {
308+
async function renderChartHandler(args: { component?: string; props?: Record<string, any>; theme?: Record<string, string>; format?: string }): Promise<ToolResult> {
309309
const component = args.component
310310
const props: Record<string, any> = args.props ?? {}
311311
const theme = args.theme
312+
const format = args.format || "svg"
312313

313314
if (!component) {
314315
return {
@@ -345,6 +346,31 @@ async function renderChartHandler(args: { component?: string; props?: Record<str
345346
}
346347
}
347348

349+
// PNG rasterization via sharp (optional dependency)
350+
if (format === "png") {
351+
try {
352+
// Dynamic import — sharp is an optional dependency
353+
// eslint-disable-next-line @typescript-eslint/no-var-requires
354+
const sharpMod = await (Function('return import("sharp")')() as Promise<any>)
355+
const sharpFn = sharpMod.default || sharpMod
356+
const pngBuffer: Buffer = await sharpFn(Buffer.from(svg)).png().toBuffer()
357+
const base64 = pngBuffer.toString("base64")
358+
return {
359+
content: [{ type: "text" as const, text: `data:image/png;base64,${base64}` }],
360+
}
361+
} catch (err: any) {
362+
if (err.code === "MODULE_NOT_FOUND" || err.code === "ERR_MODULE_NOT_FOUND") {
363+
return {
364+
content: [{ type: "text" as const, text: `PNG output requires the 'sharp' package. Install it with: npm install sharp\n\nFalling back to SVG output:\n\n${svg}` }],
365+
}
366+
}
367+
return {
368+
content: [{ type: "text" as const, text: `PNG conversion failed: ${err.message}\n\nSVG output:\n\n${svg}` }],
369+
isError: true,
370+
}
371+
}
372+
}
373+
348374
return {
349375
content: [{ type: "text" as const, text: svg }],
350376
}
@@ -506,11 +532,12 @@ function createServer(): McpServer {
506532

507533
srv.tool(
508534
"renderChart",
509-
`Render a Semiotic chart to static SVG. Returns SVG string or validation errors. Optionally pass theme CSS custom properties (--semiotic-bg, --semiotic-text, etc.) to style the output. Available components: ${componentNames.join(", ")}.`,
535+
`Render a Semiotic chart to static SVG or PNG. Returns SVG string (default) or Base64-encoded PNG image. Optionally pass theme CSS custom properties (--semiotic-bg, --semiotic-text, etc.) to style the output. PNG requires the 'sharp' package to be installed. Available components: ${componentNames.join(", ")}.`,
510536
{
511537
component: z.string().describe("Chart component name, e.g. 'LineChart', 'BarChart'"),
512538
props: z.record(z.string(), z.unknown()).optional().describe("Chart props object, e.g. { data: [...], xAccessor: 'x' }."),
513539
theme: z.record(z.string(), z.string()).optional().describe("CSS custom properties for theming, e.g. { '--semiotic-bg': '#1a1a2e', '--semiotic-text': '#ededed' }. Only --semiotic-* variables are applied."),
540+
format: z.enum(["svg", "png"]).optional().describe("Output format: 'svg' (default) returns SVG markup, 'png' returns a Base64-encoded PNG image. PNG requires the 'sharp' package."),
514541
},
515542
renderChartHandler
516543
)

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,9 @@
248248
"react": "^18.1.0 || ^19.0.0",
249249
"react-dom": "^18.1.0 || ^19.0.0"
250250
},
251+
"optionalDependencies": {
252+
"sharp": ">=0.33.0"
253+
},
251254
"dependencies": {
252255
"@modelcontextprotocol/sdk": "^1.12.1",
253256
"@types/d3-quadtree": "^3.0.6",

src/components/charts/shared/diagnoseConfig.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,35 @@ function checkMarginOverflow(
472472
}
473473
}
474474

475+
const ACCESSOR_PROPS = [
476+
"xAccessor", "yAccessor", "timeAccessor", "valueAccessor",
477+
"categoryAccessor", "colorBy", "sizeBy", "lineBy", "areaBy",
478+
"stackBy", "groupBy", "orderAccessor", "y0Accessor",
479+
"sourceAccessor", "targetAccessor", "nodeIDAccessor",
480+
"childrenAccessor", "valueAccessor", "costAccessor",
481+
]
482+
483+
function checkFunctionAccessors(
484+
_component: string,
485+
props: Record<string, any>,
486+
out: Diagnosis[]
487+
): void {
488+
const fnAccessors: string[] = []
489+
for (const prop of ACCESSOR_PROPS) {
490+
if (typeof props[prop] === "function") {
491+
fnAccessors.push(prop)
492+
}
493+
}
494+
if (fnAccessors.length > 0) {
495+
out.push({
496+
severity: "warning",
497+
code: "FUNCTION_ACCESSOR",
498+
message: `Function accessor${fnAccessors.length > 1 ? "s" : ""} detected: ${fnAccessors.join(", ")}. If defined inline (e.g. \`xAccessor={d => d.value}\`), every parent re-render creates a new reference which may trigger unnecessary scene rebuilds.`,
499+
fix: `Use string accessors when possible (e.g. xAccessor="value"), or memoize with useCallback / define outside the component.`,
500+
})
501+
}
502+
}
503+
475504
// ---------------------------------------------------------------------------
476505
// Public API
477506
// ---------------------------------------------------------------------------
@@ -521,6 +550,7 @@ export function diagnoseConfig(
521550
checkLegendMarginTight(componentName, props, diagnoses)
522551
checkHeatmapStringAccessor(componentName, props, diagnoses)
523552
checkColorContrast(componentName, props, diagnoses)
553+
checkFunctionAccessors(componentName, props, diagnoses)
524554

525555
return {
526556
ok: diagnoses.every(d => d.severity === "warning"),

src/components/stream/OrdinalPipelineStore.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
import type { Changeset, Style, DecayConfig } from "./types"
1313
import { computeEasing, computeRawProgress, lerp, now as getTimestamp } from "./pipelineTransitionUtils"
1414
import type { ActiveTransition } from "./pipelineTransitionUtils"
15-
import { resolveAccessor, resolveStringAccessor } from "./accessorUtils"
15+
import { resolveAccessor, resolveStringAccessor, accessorsEquivalent } from "./accessorUtils"
1616
import { STREAMING_PALETTE } from "../charts/shared/colorUtils"
1717
import { buildBarScene, buildClusterBarScene } from "./ordinalSceneBuilders/barScene"
1818
import { buildPointScene, buildSwarmScene } from "./ordinalSceneBuilders/pointScene"
@@ -1000,10 +1000,54 @@ export class OrdinalPipelineStore {
10001000
}
10011001

10021002
updateConfig(config: Partial<OrdinalPipelineConfig>): void {
1003+
const prev = this.config
1004+
10031005
if (config.colorScheme !== this.config.colorScheme) {
10041006
this._colorSchemeMap = null
10051007
this._colorSchemeIndex = 0
10061008
}
1009+
10071010
Object.assign(this.config, config)
1011+
1012+
// Re-resolve accessors only when the accessor source actually changed.
1013+
// Uses .toString() comparison to skip re-resolution for inline arrow functions
1014+
// that are recreated on every parent render but have identical source code.
1015+
if (config.oAccessor !== undefined || config.categoryAccessor !== undefined) {
1016+
const newO = config.oAccessor || config.categoryAccessor
1017+
const prevO = prev.oAccessor || prev.categoryAccessor
1018+
if (!accessorsEquivalent(newO, prevO)) {
1019+
this.getO = resolveStringAccessor(
1020+
this.config.oAccessor || this.config.categoryAccessor,
1021+
"category"
1022+
) as (d: any) => string
1023+
}
1024+
}
1025+
if (config.rAccessor !== undefined) {
1026+
if (!accessorsEquivalent(
1027+
Array.isArray(config.rAccessor) ? config.rAccessor[0] : config.rAccessor,
1028+
Array.isArray(prev.rAccessor) ? prev.rAccessor[0] : prev.rAccessor
1029+
)) {
1030+
const rawR = this.config.rAccessor
1031+
if (Array.isArray(rawR)) {
1032+
this.rAccessors = rawR.map(acc => resolveAccessor(acc, "value"))
1033+
this.getR = this.rAccessors[0]
1034+
} else {
1035+
this.getR = resolveAccessor(rawR, "value")
1036+
this.rAccessors = [this.getR]
1037+
}
1038+
}
1039+
}
1040+
if (config.stackBy !== undefined && !accessorsEquivalent(config.stackBy, prev.stackBy)) {
1041+
this.getStack = resolveStringAccessor(this.config.stackBy)
1042+
}
1043+
if (config.groupBy !== undefined && !accessorsEquivalent(config.groupBy, prev.groupBy)) {
1044+
this.getGroup = resolveStringAccessor(this.config.groupBy)
1045+
}
1046+
if (config.colorAccessor !== undefined && !accessorsEquivalent(config.colorAccessor, prev.colorAccessor)) {
1047+
this.getColor = resolveStringAccessor(this.config.colorAccessor)
1048+
}
1049+
if (config.connectorAccessor !== undefined && !accessorsEquivalent(config.connectorAccessor, prev.connectorAccessor)) {
1050+
this.getConnector = resolveStringAccessor(this.config.connectorAccessor)
1051+
}
10081052
}
10091053
}

src/components/stream/PipelineStore.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
buildRectNode,
3636
buildHeatcellNode
3737
} from "./SceneGraph"
38-
import { resolveAccessor, resolveRawAccessor, resolveStringAccessor } from "./accessorUtils"
38+
import { resolveAccessor, resolveRawAccessor, resolveStringAccessor, accessorsEquivalent } from "./accessorUtils"
3939
import { STREAMING_PALETTE } from "../charts/shared/colorUtils"
4040
import { computeEasing, computeRawProgress, lerp, now as getTimestamp } from "./pipelineTransitionUtils"
4141
import type { ActiveTransition } from "./pipelineTransitionUtils"
@@ -2140,6 +2140,8 @@ export class PipelineStore {
21402140
}
21412141

21422142
updateConfig(config: Partial<PipelineConfig>): void {
2143+
const prev = this.config
2144+
21432145
// Invalidate color map caches when relevant config changes
21442146
if (config.colorScheme !== undefined) {
21452147
this._colorMapCache = null
@@ -2156,44 +2158,65 @@ export class PipelineStore {
21562158
this._stackExtentCache = null
21572159
}
21582160

2161+
// Track whether any accessor actually changed (not just new function identity)
2162+
let accessorChanged = false
2163+
21592164
Object.assign(this.config, config)
21602165

2161-
// Re-resolve accessor functions when accessor config changes
2166+
// Re-resolve accessor functions only when the accessor source actually changed.
2167+
// Uses .toString() comparison to detect inline arrow functions that are
2168+
// recreated on every parent render but have identical source code.
21622169
if (config.xAccessor !== undefined || config.yAccessor !== undefined
21632170
|| config.timeAccessor !== undefined || config.valueAccessor !== undefined) {
2164-
const isStreamingType = ["bar", "swarm", "waterfall"].includes(this.config.chartType)
2165-
const useStreamingDefaults = isStreamingType || this.config.runtimeMode === "streaming"
2166-
if (useStreamingDefaults) {
2167-
this.getX = resolveAccessor(this.config.timeAccessor || this.config.xAccessor, "time")
2168-
this.getY = resolveAccessor(this.config.valueAccessor || this.config.yAccessor, "value")
2169-
} else {
2170-
this.getX = resolveAccessor(this.config.xAccessor, "x")
2171-
this.getY = resolveAccessor(this.config.yAccessor, "y")
2171+
const xChanged = !accessorsEquivalent(config.xAccessor ?? config.timeAccessor, prev.xAccessor ?? prev.timeAccessor)
2172+
const yChanged = !accessorsEquivalent(config.yAccessor ?? config.valueAccessor, prev.yAccessor ?? prev.valueAccessor)
2173+
if (xChanged || yChanged) {
2174+
const isStreamingType = ["bar", "swarm", "waterfall"].includes(this.config.chartType)
2175+
const useStreamingDefaults = isStreamingType || this.config.runtimeMode === "streaming"
2176+
if (useStreamingDefaults) {
2177+
this.getX = resolveAccessor(this.config.timeAccessor || this.config.xAccessor, "time")
2178+
this.getY = resolveAccessor(this.config.valueAccessor || this.config.yAccessor, "value")
2179+
} else {
2180+
this.getX = resolveAccessor(this.config.xAccessor, "x")
2181+
this.getY = resolveAccessor(this.config.yAccessor, "y")
2182+
}
2183+
accessorChanged = true
21722184
}
21732185
}
2174-
if (config.groupAccessor !== undefined) {
2186+
if (config.groupAccessor !== undefined && !accessorsEquivalent(config.groupAccessor, prev.groupAccessor)) {
21752187
this.getGroup = resolveStringAccessor(this.config.groupAccessor)
2188+
accessorChanged = true
21762189
}
2177-
if (config.categoryAccessor !== undefined) {
2190+
if (config.categoryAccessor !== undefined && !accessorsEquivalent(config.categoryAccessor, prev.categoryAccessor)) {
21782191
this.getCategory = resolveStringAccessor(this.config.categoryAccessor)
2192+
accessorChanged = true
21792193
}
2180-
if (config.sizeAccessor !== undefined) {
2194+
if (config.sizeAccessor !== undefined && !accessorsEquivalent(config.sizeAccessor, prev.sizeAccessor)) {
21812195
this.getSize = this.config.sizeAccessor
21822196
? resolveAccessor(this.config.sizeAccessor, "size")
21832197
: undefined
2198+
accessorChanged = true
21842199
}
2185-
if (config.colorAccessor !== undefined) {
2200+
if (config.colorAccessor !== undefined && !accessorsEquivalent(config.colorAccessor, prev.colorAccessor)) {
21862201
this.getColor = resolveStringAccessor(this.config.colorAccessor)
2202+
accessorChanged = true
21872203
}
2188-
if (config.y0Accessor !== undefined) {
2204+
if (config.y0Accessor !== undefined && !accessorsEquivalent(config.y0Accessor, prev.y0Accessor)) {
21892205
this.getY0 = this.config.y0Accessor
21902206
? resolveAccessor(this.config.y0Accessor, "y0")
21912207
: undefined
2208+
accessorChanged = true
21922209
}
2193-
if (config.pointIdAccessor !== undefined) {
2210+
if (config.pointIdAccessor !== undefined && !accessorsEquivalent(config.pointIdAccessor, prev.pointIdAccessor)) {
21942211
this.getPointId = resolveStringAccessor(this.config.pointIdAccessor)
2212+
accessorChanged = true
21952213
}
21962214

2197-
this.needsFullRebuild = true
2215+
// Only mark full rebuild needed if non-accessor config changed or accessors actually changed.
2216+
// This prevents unnecessary scene rebuilds from unstable function references.
2217+
const nonAccessorKeys = Object.keys(config).filter(k => !k.endsWith("Accessor") && k !== "timeAccessor" && k !== "valueAccessor")
2218+
if (accessorChanged || nonAccessorKeys.length > 0) {
2219+
this.needsFullRebuild = true
2220+
}
21982221
}
21992222
}

0 commit comments

Comments
 (0)