Skip to content

Commit acfd7d5

Browse files
committed
Improve tests & docs
1 parent 3ce5e7b commit acfd7d5

8 files changed

Lines changed: 444 additions & 3 deletions

File tree

docs/src/App.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import ObservationHooksPage from "./pages/features/ObservationHooksPage"
8080
import SerializationPage from "./pages/features/SerializationPage"
8181
import VegaLiteTranslatorPage from "./pages/features/VegaLiteTranslatorPage"
8282
import StreamingSystemModelPage from "./pages/features/StreamingSystemModelPage"
83+
import PerformancePage from "./pages/features/PerformancePage"
8384

8485
// New cookbook pages
8586
import CandlestickChartPage from "./pages/cookbook/CandlestickChartPage"
@@ -369,6 +370,7 @@ export default function DocsApp() {
369370
<Route path="serialization" element={<SerializationPage />} />
370371
<Route path="vega-lite" element={<VegaLiteTranslatorPage />} />
371372
<Route path="streaming-system-model" element={<StreamingSystemModelPage />} />
373+
<Route path="performance" element={<PerformancePage />} />
372374
</Route>
373375

374376
{/* Using Server-Side Rendering */}

docs/src/components/navData.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ const navData = [
107107
{ title: "AI Observation Hooks", path: "/features/observation-hooks" },
108108
{ title: "Serialization", path: "/features/serialization" },
109109
{ title: "Vega-Lite Translator", path: "/features/vega-lite" },
110-
{ title: "Streaming System Model", path: "/features/streaming-system-model" }
110+
{ title: "Streaming System Model", path: "/features/streaming-system-model" },
111+
{ title: "Performance", path: "/features/performance" }
111112
]
112113
},
113114
{
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import React from "react"
2+
import CodeBlock from "../../components/CodeBlock"
3+
import PageLayout from "../../components/PageLayout"
4+
import { Link } from "react-router-dom"
5+
6+
export default function PerformancePage() {
7+
return (
8+
<PageLayout title="Performance" subtitle="Accessor stability, memoization, and rendering efficiency">
9+
<section>
10+
<h2>Accessor Stability</h2>
11+
<p>
12+
Semiotic's canvas-based rendering pipeline rebuilds the scene graph when chart
13+
configuration changes. Every time a parent component re-renders, React creates
14+
new prop values — including new function objects for inline accessors like{" "}
15+
<code>{"xAccessor={d => d.time}"}</code>. Without mitigation, each re-render
16+
triggers a full scene rebuild even when nothing meaningful changed.
17+
</p>
18+
<p>
19+
Semiotic handles this automatically via <strong>accessor equivalence checking</strong>.
20+
When <code>updateConfig</code> receives new accessor values, it compares them to the
21+
previous values using <code>accessorsEquivalent()</code>:
22+
</p>
23+
<ul>
24+
<li><strong>String accessors</strong> — compared by value (<code>"x" === "x"</code>)</li>
25+
<li><strong>Same function reference</strong> — identical (<code>fn === fn</code>)</li>
26+
<li><strong>Inline arrows with identical source</strong> — compared via <code>.toString()</code></li>
27+
<li><strong>Different types</strong> — always treated as changed</li>
28+
</ul>
29+
<p>
30+
This means <code>{"xAccessor={d => d.time}"}</code> written inline in JSX will
31+
<em>not</em> trigger a scene rebuild on re-render, as long as the source code
32+
is identical each time. However, string accessors remain the most reliable and
33+
efficient option.
34+
</p>
35+
36+
<h3>Accessor preference order</h3>
37+
<p>From most stable to least:</p>
38+
<CodeBlock>{`// 1. String accessor (best) — always stable
39+
<LineChart xAccessor="time" yAccessor="value" />
40+
41+
// 2. Function defined outside the component — stable reference
42+
const getX = (d) => d.timestamp / 1000
43+
function MyChart() {
44+
return <LineChart xAccessor={getX} yAccessor="value" />
45+
}
46+
47+
// 3. useCallback — stable across re-renders
48+
function MyChart({ divisor }) {
49+
const getX = useCallback((d) => d.timestamp / divisor, [divisor])
50+
return <LineChart xAccessor={getX} yAccessor="value" />
51+
}
52+
53+
// 4. Inline arrow (OK) — caught by .toString() heuristic
54+
<LineChart xAccessor={d => d.timestamp / 1000} yAccessor="value" />`}
55+
</CodeBlock>
56+
57+
<h3>When .toString() does not help</h3>
58+
<p>
59+
The <code>.toString()</code> comparison catches inline arrows with identical source text.
60+
It does <strong>not</strong> help when the function body is the same but it closes over a
61+
changing variable:
62+
</p>
63+
<CodeBlock>{`// WARNING: multiplier changes but the source text is always "d => d.value * multiplier"
64+
// .toString() sees them as equal even though behavior changed
65+
function MyChart({ multiplier }) {
66+
return <LineChart yAccessor={d => d.value * multiplier} />
67+
}
68+
69+
// FIX: use useCallback with multiplier in the dependency array
70+
function MyChart({ multiplier }) {
71+
const getY = useCallback((d) => d.value * multiplier, [multiplier])
72+
return <LineChart yAccessor={getY} />
73+
}`}
74+
</CodeBlock>
75+
76+
<h3>diagnoseConfig warning</h3>
77+
<p>
78+
The <code>diagnoseConfig</code> utility (and <code>npx semiotic-ai --doctor</code>)
79+
emits a <code>FUNCTION_ACCESSOR</code> warning when it detects function accessors in
80+
your props. This is informational — function accessors work fine, but string accessors
81+
are preferred for maximum stability and clarity.
82+
</p>
83+
<CodeBlock>{`import { diagnoseConfig } from "semiotic/ai"
84+
85+
const result = diagnoseConfig("LineChart", {
86+
data: myData,
87+
xAccessor: d => d.time,
88+
yAccessor: "value",
89+
})
90+
// result.diagnoses includes:
91+
// { code: "FUNCTION_ACCESSOR", severity: "warning",
92+
// message: "Function accessor detected: xAccessor. ..." }`}
93+
</CodeBlock>
94+
</section>
95+
96+
<section>
97+
<h2>MCP Rasterized Output</h2>
98+
<p>
99+
The Semiotic MCP server's <code>renderChart</code> tool supports both SVG and PNG
100+
output formats. SVG is the default. PNG output requires the optional{" "}
101+
<code>sharp</code> package.
102+
</p>
103+
<CodeBlock>{`// MCP tool call with format parameter
104+
{
105+
"component": "LineChart",
106+
"props": {
107+
"data": [{ "x": 1, "y": 10 }, { "x": 2, "y": 20 }],
108+
"xAccessor": "x",
109+
"yAccessor": "y"
110+
},
111+
"format": "png" // "svg" (default) or "png"
112+
}`}
113+
</CodeBlock>
114+
115+
<h3>Installing sharp</h3>
116+
<p>
117+
PNG output uses <a href="https://sharp.pixelplumbing.com/">sharp</a> for SVG-to-PNG
118+
conversion. It's listed as an optional dependency — install it when you need PNG:
119+
</p>
120+
<CodeBlock>{`npm install sharp`}</CodeBlock>
121+
<p>
122+
If <code>sharp</code> is not installed and <code>format: "png"</code> is requested,
123+
the tool returns the SVG output with an installation hint instead of failing.
124+
</p>
125+
126+
<h3>Output format</h3>
127+
<p>
128+
PNG output is returned as a Base64 data URI string:{" "}
129+
<code>data:image/png;base64,...</code>. This can be embedded directly in HTML
130+
or saved to a file by decoding the Base64 payload.
131+
</p>
132+
</section>
133+
134+
<section>
135+
<h2>General Tips</h2>
136+
<ul>
137+
<li>
138+
<strong>Use sub-entries for smaller bundles.</strong>{" "}
139+
Import from <code>semiotic/xy</code>, <code>semiotic/ordinal</code>, etc. instead
140+
of the main <code>semiotic</code> entry to tree-shake unused chart types.
141+
See <Link to="/getting-started">Getting Started</Link>.
142+
</li>
143+
<li>
144+
<strong>Canvas rendering is default.</strong> All Stream Frames and HOC charts
145+
render to <code>&lt;canvas&gt;</code> with SVG overlays for annotations and tooltips.
146+
This gives better performance than pure SVG for large datasets.
147+
</li>
148+
<li>
149+
<strong>Streaming data uses ring buffers.</strong> The{" "}
150+
<code>windowSize</code> prop controls how many points are kept in memory.
151+
Older points are evicted automatically.
152+
</li>
153+
<li>
154+
<strong>Push API avoids re-render overhead.</strong> Use{" "}
155+
<code>ref.current.push(datum)</code> instead of updating a state array.
156+
See <Link to="/features/realtime-encoding">Realtime Encoding</Link>.
157+
</li>
158+
</ul>
159+
</section>
160+
</PageLayout>
161+
)
162+
}

src/components/charts/shared/diagnoseConfig.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,45 @@ describe("diagnoseConfig", () => {
209209
expect(diag.severity).toBe("warning")
210210
})
211211

212+
it("warns about function accessors", () => {
213+
const result = diagnoseConfig("LineChart", {
214+
data: [{ x: 1, y: 2 }],
215+
xAccessor: (d: any) => d.x,
216+
yAccessor: (d: any) => d.y,
217+
})
218+
const codes = result.diagnoses.map(d => d.code)
219+
expect(codes).toContain("FUNCTION_ACCESSOR")
220+
const diag = result.diagnoses.find(d => d.code === "FUNCTION_ACCESSOR")!
221+
expect(diag.severity).toBe("warning")
222+
expect(diag.message).toContain("xAccessor")
223+
expect(diag.message).toContain("yAccessor")
224+
// ok should still be true since it's only a warning
225+
expect(result.ok).toBe(true)
226+
})
227+
228+
it("does not warn about function accessors when only string accessors used", () => {
229+
const result = diagnoseConfig("LineChart", {
230+
data: [{ x: 1, y: 2 }],
231+
xAccessor: "x",
232+
yAccessor: "y",
233+
})
234+
const codes = result.diagnoses.map(d => d.code)
235+
expect(codes).not.toContain("FUNCTION_ACCESSOR")
236+
})
237+
238+
it("warns about a single function accessor among strings", () => {
239+
const result = diagnoseConfig("BarChart", {
240+
data: [{ category: "A", value: 10 }],
241+
categoryAccessor: "category",
242+
valueAccessor: (d: any) => d.value,
243+
})
244+
const codes = result.diagnoses.map(d => d.code)
245+
expect(codes).toContain("FUNCTION_ACCESSOR")
246+
const diag = result.diagnoses.find(d => d.code === "FUNCTION_ACCESSOR")!
247+
expect(diag.message).toContain("valueAccessor")
248+
expect(diag.message).not.toContain("categoryAccessor")
249+
})
250+
212251
it("detects string accessor on heatmap", () => {
213252
const result = diagnoseConfig("Heatmap", {
214253
data: [{ x: "Mon", y: 1, value: 10 }],
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { OrdinalPipelineStore } from "./OrdinalPipelineStore"
2+
import type { OrdinalPipelineConfig } from "./ordinalTypes"
3+
4+
function makeConfig(overrides: Partial<OrdinalPipelineConfig> = {}): OrdinalPipelineConfig {
5+
return {
6+
chartType: "bar",
7+
windowSize: 1000,
8+
windowMode: "sliding",
9+
extentPadding: 0.05,
10+
projection: "vertical",
11+
oAccessor: "category",
12+
rAccessor: "value",
13+
...overrides
14+
}
15+
}
16+
17+
describe("OrdinalPipelineStore — Accessor Stability", () => {
18+
it("does not re-resolve oAccessor when string is unchanged", () => {
19+
const store = new OrdinalPipelineStore(makeConfig({ oAccessor: "name" }))
20+
const originalGetO = store.getOAccessor()
21+
22+
store.updateConfig({ oAccessor: "name" })
23+
expect(store.getOAccessor()).toBe(originalGetO)
24+
})
25+
26+
it("re-resolves oAccessor when string changes", () => {
27+
const store = new OrdinalPipelineStore(makeConfig({ oAccessor: "name" }))
28+
const originalGetO = store.getOAccessor()
29+
30+
store.updateConfig({ oAccessor: "label" })
31+
expect(store.getOAccessor()).not.toBe(originalGetO)
32+
expect(store.getOAccessor()({ label: "X" })).toBe("X")
33+
})
34+
35+
it("does not re-resolve oAccessor for equivalent inline arrows", () => {
36+
const store = new OrdinalPipelineStore(makeConfig({
37+
oAccessor: (d: any) => d.name
38+
}))
39+
const originalGetO = store.getOAccessor()
40+
41+
// Simulate React re-render: new function, same source
42+
store.updateConfig({ oAccessor: (d: any) => d.name })
43+
expect(store.getOAccessor()).toBe(originalGetO)
44+
})
45+
46+
it("re-resolves oAccessor when function source changes", () => {
47+
const store = new OrdinalPipelineStore(makeConfig({
48+
oAccessor: (d: any) => d.name
49+
}))
50+
const originalGetO = store.getOAccessor()
51+
52+
store.updateConfig({ oAccessor: (d: any) => d.label })
53+
expect(store.getOAccessor()).not.toBe(originalGetO)
54+
})
55+
56+
it("does not re-resolve rAccessor for equivalent string", () => {
57+
const store = new OrdinalPipelineStore(makeConfig({ rAccessor: "count" }))
58+
const originalGetR = store.getRAccessor()
59+
60+
store.updateConfig({ rAccessor: "count" })
61+
expect(store.getRAccessor()).toBe(originalGetR)
62+
})
63+
64+
it("does not re-resolve rAccessor for equivalent function", () => {
65+
const store = new OrdinalPipelineStore(makeConfig({
66+
rAccessor: (d: any) => d.count
67+
}))
68+
const originalGetR = store.getRAccessor()
69+
70+
store.updateConfig({ rAccessor: (d: any) => d.count })
71+
expect(store.getRAccessor()).toBe(originalGetR)
72+
})
73+
74+
it("does not re-resolve stackBy for equivalent string", () => {
75+
const store = new OrdinalPipelineStore(makeConfig({
76+
stackBy: "group"
77+
}))
78+
79+
// No error, accessor stays stable
80+
store.updateConfig({ stackBy: "group" })
81+
// Verify store still functions
82+
store.ingest({
83+
inserts: [{ category: "A", value: 10, group: "X" }],
84+
bounded: true
85+
})
86+
store.computeScene({ width: 200, height: 200 })
87+
expect(store.scene.length).toBeGreaterThan(0)
88+
})
89+
90+
it("does not re-resolve colorAccessor for equivalent function", () => {
91+
const store = new OrdinalPipelineStore(makeConfig({
92+
colorAccessor: (d: any) => d.type
93+
}))
94+
95+
// Simulate re-render
96+
store.updateConfig({ colorAccessor: (d: any) => d.type })
97+
98+
// Verify store still works
99+
store.ingest({
100+
inserts: [{ category: "A", value: 10, type: "primary" }],
101+
bounded: true
102+
})
103+
store.computeScene({ width: 200, height: 200 })
104+
expect(store.scene.length).toBeGreaterThan(0)
105+
})
106+
107+
it("categoryAccessor alias follows same equivalence rules", () => {
108+
const store = new OrdinalPipelineStore(makeConfig({
109+
oAccessor: undefined,
110+
categoryAccessor: "region"
111+
}))
112+
const originalGetO = store.getOAccessor()
113+
114+
store.updateConfig({ categoryAccessor: "region" })
115+
expect(store.getOAccessor()).toBe(originalGetO)
116+
})
117+
})

src/components/stream/OrdinalPipelineStore.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1000,7 +1000,7 @@ export class OrdinalPipelineStore {
10001000
}
10011001

10021002
updateConfig(config: Partial<OrdinalPipelineConfig>): void {
1003-
const prev = this.config
1003+
const prev = { ...this.config }
10041004

10051005
if (config.colorScheme !== this.config.colorScheme) {
10061006
this._colorSchemeMap = null

0 commit comments

Comments
 (0)