@taylorwong/ichartjs 2.0.1 β†’ 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -184,11 +184,24 @@ The public package includes ESM exports, TypeScript declarations, Agent document
184
184
 
185
185
  ## Development
186
186
 
187
+ Checks + playground (for all contributors):
188
+
187
189
  ```bash
188
190
  npm run agent:check
189
191
  npm run playground
190
192
  ```
191
193
 
194
+ ### Release (AUTHOR ONLY)
195
+
196
+ Publishing to npm (`@taylorwong/ichartjs`) and merging `develop β†’ master` are restricted to the package author (GitHub + npm account `taylorwong`) due to 2FA and access control.
197
+
198
+ Contributors should:
199
+ 1. Open PRs / commit only to `develop`
200
+ 2. Ensure `npm run agent:check` and `npm test` are green
201
+
202
+ Full author-only release SOP (exact 13-step sequence + must-not rules + rollback procedure):
203
+ πŸ‘‰ [`docs/agent/development/release-sop.md`](docs/agent/development/release-sop.md)
204
+
192
205
  Start with [`docs/agent/quickstart.md`](docs/agent/quickstart.md). Detailed runtime, charting, project, diagram, and editing contracts live in [`docs/agent/`](docs/agent/).
193
206
 
194
207
  Contribution requirements are in [`CONTRIBUTING.md`](CONTRIBUTING.md). Report vulnerabilities privately according to [`SECURITY.md`](SECURITY.md).
@@ -6,7 +6,7 @@ Turn the existing chart catalog into a dependable, commonly complete, and Agent-
6
6
 
7
7
  ## Current Delivery
8
8
 
9
- Iteration 8A–8D is complete. The runtime exposes per-chart capability profiles, deterministic Agent planning and explanation, richer data inspection and diagnostics, common chart presentation behavior, corrected chart geometry, dedicated interaction/accessibility/performance labs, and a maintained full Gallery. Automated, Chromium, Firefox, WebKit, native Safari, 390 px responsive, lifecycle, and local performance acceptance passed. Physical iOS/Android checks remain a recorded host/device follow-up. See `docs/agent/development/iteration-8-acceptance.md`.
9
+ Iteration 8A–8D is complete. The runtime exposes per-chart capability profiles, deterministic Agent planning and explanation, richer data inspection and diagnostics, common chart presentation behavior, corrected chart geometry, dedicated interaction/accessibility/performance labs, and a maintained full Gallery. Automated, Chromium, Firefox, WebKit, native Safari, 390 px responsive, lifecycle, and local performance acceptance passed. Physical iOS/Android checks remain a recorded host/device follow-up. Acceptance record is captured below in **Β§ Acceptance Record**.
10
10
 
11
11
  ## Scope Policy
12
12
 
@@ -114,3 +114,57 @@ Iteration 8A–8D is complete. The runtime exposes per-chart capability profiles
114
114
  ## Completion Definition
115
115
 
116
116
  Iteration 8 is complete when all existing chart types provide their applicable common features reliably; unsupported behavior is explicitly discoverable; an Agent can select, configure, validate, render, explain, and verify charts without reading source code; SVG and Canvas semantics are consistent; browser, mobile, accessibility, performance, lifecycle, packaging, and preview gates are recorded; and the repository is ready for a final iChart.js 2.0 release decision without adding new chart types.
117
+
118
+ ## Acceptance Record
119
+
120
+ Date: 2026-09-16
121
+
122
+ ### Result
123
+
124
+ Iteration 8A–8D is implemented and accepted for the repository runtime, public Agent contracts, maintained Playground pages, responsive browser viewport, lifecycle checks, and repeatable performance fixtures. Chromium, Firefox 144, WebKit 26, and native Safari 26.6.2 checks pass. Physical iOS/Android checks remain a separately recorded host/device follow-up.
125
+
126
+ ### Automated Evidence
127
+
128
+ - `npm run agent:check`: passed.
129
+ - `git diff --check`: passed.
130
+ - Core tests: 51 passed, 0 failed.
131
+ - Agent documentation check: 16 charts, 24 commands, and 7 schemas.
132
+ - Syntax check: every `src/*.mjs` file passed `node --check`.
133
+ - Node.js 18.20.8, 20.20.2, and 22.22.2: Agent checks and the executable Agent workflow passed.
134
+ - Package-consumer coverage: public ESM imports, capability discovery, planning, validation, explanation, chart creation, lifecycle, and active Playground entry files are covered by the core suite.
135
+
136
+ ### Browser Evidence
137
+
138
+ - Environments: Codex in-app Chromium, Playwright Firefox 144, and Playwright WebKit 26 against `npm run playground` on `127.0.0.1:3000`.
139
+ - Full Gallery: all 16 public chart types rendered; 0 error cards and no uncaught page errors.
140
+ - Renderer switch: all Gallery cases rendered with SVG when forced; default mixed SVG/Canvas cases also passed.
141
+ - Responsive checks: desktop and a true 390 x 844 viewport completed without body overflow.
142
+ - Agent Workbench: `id` remained a stable identifier, `month` was selected as the dimension, validation passed, record IDs were preserved, and the expected missing-value warning remained visible.
143
+ - Interaction Lab: zoom in, zoom out to the full window, pan, reset, selection clearing, keyboard behavior, and Canvas switching passed.
144
+ - Accessibility Lab: Line, Pie, Heatmap, Radar, Gantt, and Flow exposed role, accessible label, focusability, and family-specific semantic output.
145
+ - Regression pages: Foundational Gallery, Project Intelligence, Business Editing, and Diagram Editor loaded with ready states and no uncaught page errors.
146
+ - Lifecycle fixture: 25 create/destroy cycles left 0 chart children and 0 tooltips.
147
+ - Firefox/WebKit matrix: 22/22 desktop and 390 x 844 touch-viewport page scenarios passed after fixing Project Intelligence grid shrink behavior and redundant ResizeObserver rendering.
148
+ - Native Safari 26.6.2: 11/11 desktop and 390 x 844 page scenarios passed without horizontal overflow.
149
+
150
+ ### Performance Sample
151
+
152
+ These are local acceptance samples, not universal device budgets.
153
+
154
+ | Scenario | Renderer | Create | Resize | JSON export |
155
+ | --- | --- | ---: | ---: | ---: |
156
+ | Line, 100 rows | Canvas | 1.7 ms | 0.6 ms | 0.1 ms |
157
+ | Line, 1,000 rows | Canvas | 6.3 ms | 4.9 ms | 0.3 ms |
158
+ | Line, 5,000 rows, Firefox | Canvas | 110 ms | 96 ms | 3 ms |
159
+ | Line, 5,000 rows, WebKit | Canvas | 81 ms | 75 ms | 2 ms |
160
+ | Heatmap, 5,000 rows, Firefox | Canvas | 19 ms | 15 ms | 2 ms |
161
+ | Heatmap, 5,000 rows, WebKit | Canvas | 15 ms | 10 ms | 2 ms |
162
+
163
+ The Performance Lab also includes repeatable 5,000-row Line, Heatmap, Gantt, and Diagram scenarios for release-host measurements.
164
+
165
+ ### Post-release Host/Device Follow-up
166
+
167
+ - Physical-device touch acceptance on iOS and Android.
168
+ - Release-host performance budgets across representative low-end and high-end devices.
169
+
170
+ These checks remain important integration evidence, but the three-engine browser matrix, native Safari run, responsive touch viewport, and deterministic local performance fixtures are sufficient for the 2.0 source release.
@@ -0,0 +1,73 @@
1
+ # Release SOP β€” @taylorwong/ichartjs (AUTHOR ONLY)
2
+
3
+ > **Scope / Permissions:** Release actions (npm publish + develop→master merge + git tag push)
4
+ > are **exclusively for the package author**. Requires:
5
+ > - GitHub write access to `wanghetommy/ichartjs` (master + develop + tags)
6
+ > - npm login session as `taylorwong` (2FA or PAT with bypass-2fa enabled)
7
+ > - No contributor / secondary account should run these steps.
8
+ >
9
+ > **Agent Policy:** An Agent MUST NOT initiate any step in this SOP on its own.
10
+ > It MAY execute the sequence **only when explicitly instructed** by the author
11
+ > (e.g. "publish v2.0.3", "commit and release patch", or equivalent intent).
12
+
13
+ ## 0. Preconditions (MUST ALL be green)
14
+
15
+ 0. `git status -sb` β†’ on `develop`, working tree clean
16
+ 1. `npm run check` β†’ exit 0
17
+ 2. `npm test` β†’ exit 0 (60/60)
18
+ 3. `npm run docs:check` β†’ exit 0
19
+ 4. `npm run agent:check` β†’ exit 0
20
+ 5. `npm whoami` β†’ output = `taylorwong`
21
+ 6. `npm config get registry` β†’ output = `https://registry.npmjs.org/`
22
+
23
+ ## 1. Develop bump & commit
24
+
25
+ 7. Edit `package.json` version β†’ semver:
26
+ - **patch** (x.y.Z): pure bug fix w/o API change
27
+ - **minor** (x.Y.0): backward-compatible new feature(s)
28
+ - **major** (X.0.0): breaking API change
29
+ 8. `git add -A`
30
+ 9. `git commit -m "<prefix>: <one short English sentence>"`
31
+ - allowed `<prefix>`: `fix | feat | chore | docs | refactor | perf | test`
32
+ - ❌ no Chinese, ❌ no multi-line body, ❌ no long run-on sentences
33
+
34
+ ## 2. Tag + push develop
35
+
36
+ 10. `git tag vX.Y.Z` matching `package.json#version` exactly
37
+ 11. `git push origin develop --tags`
38
+
39
+ ## 3. NPM publish
40
+
41
+ 12. `npm publish` (package.json already declares `publishConfig.access=public` + npmjs registry)
42
+ - If 2FA challenged: `npm publish --otp=<6-digit OTP or PAT>`
43
+ - Successful stdout MUST include: `+ @taylorwong/ichartjs@X.Y.Z`
44
+ 13. Verify availability (CDN may take 1–2 min; failure to query immediately is OK as long as publish exited 0):
45
+ ```bash
46
+ npm view @taylorwong/ichartjs versions --json # should include X.Y.Z within ~2 min
47
+ curl -sfI https://unpkg.com/@taylorwong/ichartjs@X.Y.Z/src/index.mjs # HTTP 200
48
+ ```
49
+
50
+ ## 4. Merge develop β†’ master
51
+
52
+ 14. `git checkout master && git pull --rebase origin master`
53
+ 15. `git merge --no-ff develop -m "Merge develop into master (vX.Y.Z: <one short English summary>)"`
54
+ - MUST use `--no-ff` so release rollback can target a single merge commit
55
+ 16. `git push origin master`
56
+
57
+ ## 5. Final state
58
+
59
+ 17. `git status -sb` β†’ `## master...origin/master` clean, no ahead/behind
60
+ 18. `git branch -a` β†’ `master` and `develop` both present; if desired, `git checkout develop` to resume default branch
61
+
62
+ ## MUST NOT
63
+
64
+ - ❌ Never publish **before** pushing tag / develop commit (npm version is immutable)
65
+ - ❌ Never use `git merge --ff-only develop` into `master` (lose merge commit)
66
+ - ❌ Never commit release with Chinese text, emoji, or commit message body > 1 line
67
+ - ❌ Never initiate publishing without the author's explicit instruction (Agent rule)
68
+
69
+ ## Rollback (if required)
70
+
71
+ - Within 72h of publish: `npm unpublish @taylorwong/ichartjs@X.Y.Z` (follow npm ToS)
72
+ - After 72h: `npm deprecate @taylorwong/ichartjs@X.Y.Z "message"` and release next patch
73
+ - Git: `git revert <merge-commit-hash>` on master, then push; **do not force-push master**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taylorwong/ichartjs",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Agent-first, renderer-independent charting and project visualization runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -44,6 +44,7 @@ Use `ichartjs` for package imports. Use `examples/agent-workflow.mjs` as the exe
44
44
  - For Flow or Swimlane, preserve node, edge, lane, group, and port IDs; use diagram recipes and validated edit commands.
45
45
  - For business edits, preview first, preserve the preview ID and revision, require confirmation when declared, then commit or reject atomically.
46
46
  - For browser deliverables, start `npm run playground` and return the exact maintained Playground URL.
47
+ - Release workflow (npm publish + develop→master merge) is **AUTHOR ONLY**. Read the 13-step SOP in [`docs/agent/development/release-sop.md`](../../../docs/agent/development/release-sop.md). Never initiate any release step unless the author explicitly instructs.
47
48
 
48
49
  ## Guardrails
49
50
 
package/src/charts.mjs CHANGED
@@ -139,7 +139,7 @@ function addProjectScene(scene, spec, data, state, colors) {
139
139
  return;
140
140
  }
141
141
  if (spec.type === 'burndown') {
142
- const rows = projectRows(spec, data), dates = rows.map(row => new Date(row.date).getTime()).filter(Number.isFinite), min = Math.min(...(dates.length ? dates : [Date.now()])), max = Math.max(...(dates.length ? dates : [min + 86400000])), values = rows.map(row => Number(row.remaining ?? row.actual ?? row.value)).filter(Number.isFinite), maxValue = Math.max(1, ...values, ...rows.map(row => Number(row.ideal)).filter(Number.isFinite)); addProjectAxes(scene, plot, min, max); const point = row => ({ x: projectDate(row.date, min, max, plot), y: plot.y + plot.height - (Number(row.remaining ?? row.actual ?? row.value) / maxValue) * plot.height }); const actual = rows.map(point).filter(point => Number.isFinite(point.y)); if (actual.length > 1) scene.add(new SceneNode({ id: 'burndown-actual', type: 'path', geometry: { points: actual }, style: { stroke: colors[0], strokeWidth: 2 } })); const ideal = rows.map((row, index) => ({ x: projectDate(row.date, min, max, plot), y: plot.y + plot.height - (Number(row.ideal ?? maxValue * (1 - index / Math.max(1, rows.length - 1))) / maxValue) * plot.height })); if (ideal.length > 1) scene.add(new SceneNode({ id: 'burndown-ideal', type: 'path', geometry: { points: ideal }, style: { stroke: '#94a3b8', strokeWidth: 1, opacity: 0.8 } })); rows.forEach((row, index) => { if (index && Number(row.scopeChange)) { const x = projectDate(row.date, min, max, plot); scene.add(new SceneNode({ id: `burndown-scope-${index}`, type: 'line', geometry: { x1: x, y1: plot.y, x2: x, y2: plot.y + plot.height }, style: { stroke: '#f59e0b', strokeWidth: 1.5, opacity: 0.8 }, dataRef: { dataIndex: index, field: 'scopeChange', datum: row } })); } }); actual.forEach((point, index) => scene.add(new SceneNode({ id: `burndown-item-${index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 7, y: point.y - 7, width: 14, height: 14 }, style: { fill: colors[0] }, dataRef: { dataIndex: index, field: 'remaining', datum: rows[index] }, interactive: true }))); const last = actual.at(-1), lastRow = rows.at(-1); if (last && lastRow && Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) > 0 && rows.length > 1) { const slope = (Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) - Number(rows[0].remaining ?? rows[0].actual ?? rows[0].value)) / Math.max(1, dates.length - 1), projected = slope < 0 ? new Date(new Date(lastRow.date).getTime() + Math.ceil(Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) / -slope) * 86400000) : null; if (projected) addText(scene, 'burndown-projected', `Projected ${formatTime(projected, max - min)}`, Math.min(plot.x + plot.width, last.x + 12), plot.y + 18, { fill: '#dc2626', font: '12px system-ui' }); } return;
142
+ const rows = projectRows(spec, data), dates = rows.map(row => new Date(row.date).getTime()).filter(Number.isFinite), min = Math.min(...(dates.length ? dates : [Date.now()])), max = Math.max(...(dates.length ? dates : [min + 86400000])), values = rows.map(row => Number(row.remaining ?? row.actual ?? row.value)).filter(Number.isFinite), maxValue = Math.max(1, ...values, ...rows.map(row => Number(row.ideal)).filter(Number.isFinite)); addProjectAxes(scene, plot, min, max); const point = row => ({ x: projectDate(row.date, min, max, plot), y: plot.y + plot.height - (Number(row.remaining ?? row.actual ?? row.value) / maxValue) * plot.height }); const actual = rows.map(point).filter(point => Number.isFinite(point.y)); if (actual.length > 1) scene.add(new SceneNode({ id: 'burndown-actual', type: 'path', geometry: { points: actual, closed: false }, style: { stroke: colors[0], strokeWidth: 2 } })); const ideal = rows.map((row, index) => ({ x: projectDate(row.date, min, max, plot), y: plot.y + plot.height - (Number(row.ideal ?? maxValue * (1 - index / Math.max(1, rows.length - 1))) / maxValue) * plot.height })); if (ideal.length > 1) scene.add(new SceneNode({ id: 'burndown-ideal', type: 'path', geometry: { points: ideal, closed: false }, style: { stroke: '#94a3b8', strokeWidth: 1, opacity: 0.8 } })); rows.forEach((row, index) => { if (index && Number(row.scopeChange)) { const x = projectDate(row.date, min, max, plot); scene.add(new SceneNode({ id: `burndown-scope-${index}`, type: 'line', geometry: { x1: x, y1: plot.y, x2: x, y2: plot.y + plot.height }, style: { stroke: '#f59e0b', strokeWidth: 1.5, opacity: 0.8 }, dataRef: { dataIndex: index, field: 'scopeChange', datum: row } })); } }); actual.forEach((point, index) => scene.add(new SceneNode({ id: `burndown-item-${index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 7, y: point.y - 7, width: 14, height: 14 }, style: { fill: colors[0] }, dataRef: { dataIndex: index, field: 'remaining', datum: rows[index] }, interactive: true }))); const last = actual.at(-1), lastRow = rows.at(-1); if (last && lastRow && Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) > 0 && rows.length > 1) { const slope = (Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) - Number(rows[0].remaining ?? rows[0].actual ?? rows[0].value)) / Math.max(1, dates.length - 1), projected = slope < 0 ? new Date(new Date(lastRow.date).getTime() + Math.ceil(Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) / -slope) * 86400000) : null; if (projected) addText(scene, 'burndown-projected', `Projected ${formatTime(projected, max - min)}`, Math.min(plot.x + plot.width, last.x + 12), plot.y + 18, { fill: '#dc2626', font: '12px system-ui' }); } return;
143
143
  }
144
144
  if (['flow', 'swimlane'].includes(spec.type)) {
145
145
  const nodes = spec.nodes || spec.data.nodes || [], edges = spec.edges || spec.data.edges || [], lanes = spec.lanes || spec.data.lanes || [], laneHeight = plot.height / Math.max(1, lanes.length || 1); const nodeMap = new Map(); nodes.forEach((node, index) => { const laneIndex = lanes.length ? Math.max(0, lanes.findIndex(lane => lane.id === node.laneId)) : 0, columns = Math.max(1, Math.ceil(Math.sqrt(nodes.length))), column = index % columns, row = Math.floor(index / columns), x = plot.x + (column + 0.5) * plot.width / columns, y = lanes.length ? plot.y + laneIndex * laneHeight + laneHeight / 2 : plot.y + (row + 0.5) * plot.height / Math.max(1, Math.ceil(nodes.length / columns)); nodeMap.set(node.id, { ...node, x, y }); if (lanes.length) { scene.add(new SceneNode({ id: `lane-${laneIndex}`, type: 'rect', geometry: { x: plot.x, y: plot.y + laneIndex * laneHeight, width: plot.width, height: laneHeight }, style: { fill: laneIndex % 2 ? '#f8fafc' : '#ffffff', stroke: '#e2e8f0', strokeWidth: 1 }, interactive: false })); addText(scene, `lane-label-${laneIndex}`, lanes[laneIndex].label || lanes[laneIndex].id, plot.x - 8, plot.y + laneIndex * laneHeight + 18, { fill: '#475569', font: '12px system-ui', textAnchor: 'end' }); } }); edges.forEach((edge, index) => { const from = nodeMap.get(edge.from), to = nodeMap.get(edge.to); if (!from || !to) return; scene.add(new SceneNode({ id: `edge-${index}`, type: 'line', geometry: { x1: from.x, y1: from.y, x2: to.x, y2: to.y }, style: { stroke: '#64748b', strokeWidth: 1.5 }, dataRef: { edgeIndex: index, from: edge.from, to: edge.to } })); }); nodeMap.forEach((node, id) => { const geometry = { x: node.x - 48, y: node.y - 18, width: 96, height: 36 }; scene.add(new SceneNode({ id: `node-${id}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[0], stroke: '#ffffff', strokeWidth: 1 }, dataRef: { nodeId: id, field: 'node' }, interactive: true })); addText(scene, `node-label-${id}`, node.label || id, node.x, node.y + 4, { fill: '#ffffff', font: '12px system-ui', textAnchor: 'middle' }); });
@@ -208,12 +208,12 @@ export function buildScene(spec) {
208
208
  else if (spec.type === 'radar') { addRadarScene(scene, spec, data, state, colors); addLegend(scene, spec, data.rows.map((row, index) => ({ name: row.name || row.id || `Series ${index + 1}`, color: colors[index % colors.length] }))); }
209
209
  else if (spec.type === 'line' || spec.type === 'area') {
210
210
  const mode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode, accumulators = data.rows.map(() => ({ positive: 0, negative: 0 }));
211
- series.forEach((encoding, seriesIndex) => { const yMap = seriesIndex === 1 && !mode ? state.yRight : state.y, values = data.rows.map(row => Number(row[encoding.field])), totals = data.rows.map((row, index) => series.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || Math.abs(values[index]) || 1); const points = [], bases = []; data.rows.forEach((row, index) => { let value = values[index]; if (!Number.isFinite(value)) return; if (mode === 'percent') value /= totals[index]; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = mode ? start + value : value; if (mode) accumulators[index][accumulator] = end; points.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(end), row, index, value }); bases.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(start) }); }); if (spec.type === 'area' && points.length) scene.add(new SceneNode({ id: `area-fill-${seriesIndex}`, type: 'path', geometry: { points: [...bases.slice().reverse(), ...points.map(point => ({ x: point.x, y: point.y }))], closed: true }, style: { fill: `${colors[seriesIndex]}33`, stroke: 'transparent' } })); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points }, style: { stroke: colors[seriesIndex], strokeWidth: 2, fill: 'none' } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 5 }, bounds: { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 8, dataRef); }); });
211
+ series.forEach((encoding, seriesIndex) => { const yMap = seriesIndex === 1 && !mode ? state.yRight : state.y, values = data.rows.map(row => Number(row[encoding.field])), totals = data.rows.map((row, index) => series.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || Math.abs(values[index]) || 1); const points = [], bases = []; data.rows.forEach((row, index) => { let value = values[index]; if (!Number.isFinite(value)) return; if (mode === 'percent') value /= totals[index]; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = mode ? start + value : value; if (mode) accumulators[index][accumulator] = end; points.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(end), row, index, value }); bases.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(start) }); }); if (spec.type === 'area' && points.length) scene.add(new SceneNode({ id: `area-fill-${seriesIndex}`, type: 'path', geometry: { points: [...bases.slice().reverse(), ...points.map(point => ({ x: point.x, y: point.y }))], closed: true }, style: { fill: `${colors[seriesIndex]}33`, stroke: 'transparent' } })); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points, closed: false }, style: { stroke: colors[seriesIndex], strokeWidth: 2, fill: 'none' } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 5 }, bounds: { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 8, dataRef); }); });
212
212
  addLegend(scene, spec, series.map((encoding, index) => ({ name: encoding.name || encoding.field, color: colors[index % colors.length] })));
213
213
  } else if (spec.type === 'bar' || spec.type === 'column') {
214
214
  const band = state.plot.width / Math.max(1, state.categories.length), bar = band * 0.58;
215
215
  const mode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode, accumulators = data.rows.map(() => ({ positive: 0, negative: 0 }));
216
- series.forEach((encoding, seriesIndex) => { if (encoding.mark === 'line') { const yMap = encoding.axis === 'right' ? state.yRight : state.y, points = data.rows.map((row, index) => ({ x: state.x(row[state.xField]), y: yMap(row[encoding.field]), index, value: Number(row[encoding.field]) })).filter(point => Number.isFinite(point.y)); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points }, style: { fill: 'none', stroke: colors[seriesIndex], strokeWidth: 2 } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 7, y: point.y - 7, width: 14, height: 14 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 7, dataRef); }); return; } data.rows.forEach((row, index) => { let value = Number(row[encoding.field]) || 0; const barSeries = series.filter(item => item.mark !== 'line'), barSeriesIndex = barSeries.indexOf(encoding), total = barSeries.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || 1; if (mode === 'percent') value /= total; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = start + value; if (mode) accumulators[index][accumulator] = end; const yMap = encoding.axis === 'right' ? state.yRight : state.y, base = yMap(start), valueY = yMap(end); const vertical = spec.type === 'column'; const horizontalStart = state.plot.x + ((start - state.min) / (state.max - state.min || 1)) * state.plot.width, horizontalEnd = state.plot.x + ((end - state.min) / (state.max - state.min || 1)) * state.plot.width, rowBand = state.plot.height / Math.max(1, data.rows.length), totalHeight = rowBand * .62, itemHeight = mode ? totalHeight : totalHeight / Math.max(1, barSeries.length); const geometry = vertical ? { x: state.x(row[state.xField]) - bar / 2 + (mode ? 0 : seriesIndex * bar / Math.max(1, series.length)), y: Math.min(base, valueY), width: mode ? bar : bar / Math.max(1, series.length), height: Math.abs(base - valueY) } : { x: Math.min(horizontalStart, horizontalEnd), y: state.plot.y + index * rowBand + (rowBand - totalHeight) / 2 + (mode ? 0 : barSeriesIndex * itemHeight), width: Math.abs(horizontalEnd - horizontalStart), height: itemHeight }; const dataRef = { seriesIndex, dataIndex: index, field: encoding.field, stackStart: start, stackEnd: end }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${index}`, value, vertical ? geometry.x + geometry.width / 2 : geometry.x + geometry.width + 12, vertical ? geometry.y - 5 : geometry.y + geometry.height / 2 + 4, dataRef); }); });
216
+ series.forEach((encoding, seriesIndex) => { if (encoding.mark === 'line') { const yMap = encoding.axis === 'right' ? state.yRight : state.y, points = data.rows.map((row, index) => ({ x: state.x(row[state.xField]), y: yMap(row[encoding.field]), index, value: Number(row[encoding.field]) })).filter(point => Number.isFinite(point.y)); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points, closed: false }, style: { fill: 'none', stroke: colors[seriesIndex], strokeWidth: 2 } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 7, y: point.y - 7, width: 14, height: 14 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 7, dataRef); }); return; } data.rows.forEach((row, index) => { let value = Number(row[encoding.field]) || 0; const barSeries = series.filter(item => item.mark !== 'line'), barSeriesIndex = barSeries.indexOf(encoding), total = barSeries.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || 1; if (mode === 'percent') value /= total; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = start + value; if (mode) accumulators[index][accumulator] = end; const yMap = encoding.axis === 'right' ? state.yRight : state.y, base = yMap(start), valueY = yMap(end); const vertical = spec.type === 'column'; const horizontalStart = state.plot.x + ((start - state.min) / (state.max - state.min || 1)) * state.plot.width, horizontalEnd = state.plot.x + ((end - state.min) / (state.max - state.min || 1)) * state.plot.width, rowBand = state.plot.height / Math.max(1, data.rows.length), totalHeight = rowBand * .62, itemHeight = mode ? totalHeight : totalHeight / Math.max(1, barSeries.length); const geometry = vertical ? { x: state.x(row[state.xField]) - bar / 2 + (mode ? 0 : seriesIndex * bar / Math.max(1, series.length)), y: Math.min(base, valueY), width: mode ? bar : bar / Math.max(1, series.length), height: Math.abs(base - valueY) } : { x: Math.min(horizontalStart, horizontalEnd), y: state.plot.y + index * rowBand + (rowBand - totalHeight) / 2 + (mode ? 0 : barSeriesIndex * itemHeight), width: Math.abs(horizontalEnd - horizontalStart), height: itemHeight }; const dataRef = { seriesIndex, dataIndex: index, field: encoding.field, stackStart: start, stackEnd: end }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${index}`, value, vertical ? geometry.x + geometry.width / 2 : geometry.x + geometry.width + 12, vertical ? geometry.y - 5 : geometry.y + geometry.height / 2 + 4, dataRef); }); });
217
217
  addLegend(scene, spec, series.map((encoding, index) => ({ name: encoding.name || encoding.field, color: colors[index % colors.length] })));
218
218
  } else if (spec.type === 'scatter') {
219
219
  data.rows.forEach((row, index) => { const xValue = Number(row[state.xField]), yValue = Number(row[state.yField]); if (!Number.isFinite(xValue) || !Number.isFinite(yValue)) return; const geometry = { cx: state.x(xValue), cy: state.y(yValue), r: 5 }, dataRef = { seriesIndex: 0, dataIndex: index, field: state.yField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'circle', geometry, bounds: { x: geometry.cx - 8, y: geometry.cy - 8, width: 16, height: 16 }, style: { fill: colors[0] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, yValue, geometry.cx, geometry.cy - 8, dataRef); });
package/src/renderer.mjs CHANGED
@@ -2,6 +2,22 @@
2
2
  * Canvas and SVG renderer implementations for the shared Scene Graph.
3
3
  * Rendering is intentionally separate from chart layout and data semantics.
4
4
  */
5
+ const NO_FILL_TOKENS = new Set(['none', 'transparent', '']);
6
+ function color(style, key, fallback) { return style && style[key] != null ? style[key] : fallback; }
7
+ function hasFill(style) {
8
+ if (!style) return false;
9
+ const v = style.fill;
10
+ if (v == null) return false;
11
+ if (typeof v !== 'string') return true;
12
+ return !NO_FILL_TOKENS.has(v.trim().toLowerCase());
13
+ }
14
+ function hasStroke(style) {
15
+ if (!style) return false;
16
+ const v = style.stroke;
17
+ if (v == null) return false;
18
+ if (typeof v !== 'string') return true;
19
+ return !NO_FILL_TOKENS.has(v.trim().toLowerCase());
20
+ }
5
21
  export class BaseRenderer {
6
22
  constructor(options = {}) { this.options = options; this.container = null; }
7
23
  mount(container) { this.container = typeof container === 'string' ? document.querySelector(container) : container; if (!this.container) throw new Error('Chart container was not found.'); return this; }
@@ -11,13 +27,11 @@ export class BaseRenderer {
11
27
  destroy() { this.clear(); this.container = null; }
12
28
  }
13
29
 
14
- function color(style, key, fallback) { return style && style[key] != null ? style[key] : fallback; }
15
-
16
30
  export class CanvasRenderer extends BaseRenderer {
17
31
  mount(container) { super.mount(container); this.canvas = this.container.tagName === 'CANVAS' ? this.container : document.createElement('canvas'); if (this.canvas !== this.container) { this.container.innerHTML = ''; this.container.appendChild(this.canvas); } this.canvas.style.background = this.options.background || '#ffffff'; this.ctx = this.canvas.getContext('2d'); return this; }
18
32
  resize(width, height, pixelRatio = window.devicePixelRatio || 1) { this.width = width; this.height = height; this.pixelRatio = pixelRatio; this.canvas.width = Math.round(width * pixelRatio); this.canvas.height = Math.round(height * pixelRatio); this.canvas.style.width = `${width}px`; this.canvas.style.height = `${height}px`; this.ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); }
19
33
  clear() { if (this.ctx) this.ctx.clearRect(0, 0, this.width, this.height); }
20
- render(scene) { this.clear(); const ctx = this.ctx; const draw = node => { if (!node.visible) return; const g = node.geometry || {}; const s = node.style || {}; ctx.save(); ctx.globalAlpha = s.opacity == null ? 1 : s.opacity; ctx.fillStyle = color(s, 'fill', node.highlighted || node.selected ? '#1d4ed8' : '#2563eb'); ctx.strokeStyle = color(s, 'stroke', 'transparent'); ctx.lineWidth = s.strokeWidth || 1; if (node.type === 'rect') { ctx.fillRect(g.x, g.y, g.width, g.height); if (s.stroke) ctx.strokeRect(g.x, g.y, g.width, g.height); } else if (node.type === 'line') { ctx.beginPath(); ctx.moveTo(g.x1, g.y1); ctx.lineTo(g.x2, g.y2); ctx.stroke(); } else if (node.type === 'path') { ctx.beginPath(); g.points.forEach((point, index) => index ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y)); if (g.closed) ctx.closePath(); ctx.stroke(); if (s.fill) ctx.fill(); } else if (node.type === 'circle') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, 0, Math.PI * 2); ctx.fill(); if (s.stroke) ctx.stroke(); } else if (node.type === 'arc') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, g.start, g.end); if (g.innerR > 0) { ctx.arc(g.cx, g.cy, g.innerR, g.end, g.start, true); } else ctx.lineTo(g.cx, g.cy); ctx.closePath(); ctx.fill(); if (s.stroke) ctx.stroke(); } else if (node.type === 'text') { ctx.font = s.font || '12px system-ui'; const align = s.textAlign || (s.textAnchor === 'end' ? 'end' : s.textAnchor === 'middle' || s.textAnchor === 'center' ? 'center' : s.textAnchor === 'start' ? 'start' : 'left'); ctx.textAlign = align; ctx.textBaseline = s.textBaseline || s.baseline || 'alphabetic'; ctx.fillText(g.text, g.x, g.y); } node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(draw); ctx.restore(); }; draw(scene.root); }
34
+ render(scene) { this.clear(); const ctx = this.ctx; const draw = node => { if (!node.visible) return; const g = node.geometry || {}; const s = node.style || {}; ctx.save(); ctx.globalAlpha = s.opacity == null ? 1 : s.opacity; ctx.fillStyle = hasFill(s) ? color(s, 'fill', node.highlighted || node.selected ? '#1d4ed8' : '#2563eb') : 'rgba(0,0,0,0)'; ctx.strokeStyle = hasStroke(s) ? color(s, 'stroke', '#0f172a') : 'rgba(0,0,0,0)'; ctx.lineWidth = s.strokeWidth || 1; if (node.type === 'rect') { if (hasFill(s)) ctx.fillRect(g.x, g.y, g.width, g.height); if (hasStroke(s)) ctx.strokeRect(g.x, g.y, g.width, g.height); } else if (node.type === 'line') { ctx.beginPath(); ctx.moveTo(g.x1, g.y1); ctx.lineTo(g.x2, g.y2); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'path') { ctx.beginPath(); g.points.forEach((point, index) => index ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y)); if (g.closed) ctx.closePath(); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'circle') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, 0, Math.PI * 2); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'arc') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, g.start, g.end); if (g.innerR > 0) { ctx.arc(g.cx, g.cy, g.innerR, g.end, g.start, true); } else ctx.lineTo(g.cx, g.cy); ctx.closePath(); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'text') { if (!hasFill(s)) ctx.fillStyle = color(s, 'fill', '#0f172a'); ctx.font = s.font || '12px system-ui'; const align = s.textAlign || (s.textAnchor === 'end' ? 'end' : s.textAnchor === 'middle' || s.textAnchor === 'center' ? 'center' : s.textAnchor === 'start' ? 'start' : 'left'); ctx.textAlign = align; ctx.textBaseline = s.textBaseline || s.baseline || 'alphabetic'; ctx.fillText(g.text, g.x, g.y); } node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(draw); ctx.restore(); }; draw(scene.root); }
21
35
  exportImage(type = 'image/png') { return this.canvas.toDataURL(type); }
22
36
  }
23
37
 
@@ -72,6 +86,6 @@ export class SVGRenderer extends BaseRenderer {
72
86
  this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
73
87
  this.width = w; this.height = h;
74
88
  }
75
- this.clear(); const renderNode = node => { if (!node.visible || node.type === 'root') { node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); return; } const g = node.geometry || {}, s = node.style || {}; const element = document.createElementNS('http://www.w3.org/2000/svg', node.type === 'path' || node.type === 'arc' ? 'path' : node.type === 'text' ? 'text' : node.type); element.setAttribute('id', node.id); element.setAttribute('data-node-id', node.id); if (node.dataRef) element.dataset.dataRef = JSON.stringify(node.dataRef); if (node.type === 'rect') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.setAttribute('width', g.width); element.setAttribute('height', g.height); } else if (node.type === 'line') { ['x1', 'y1', 'x2', 'y2'].forEach(key => element.setAttribute(key, g[key])); } else if (node.type === 'circle') { element.setAttribute('cx', g.cx); element.setAttribute('cy', g.cy); element.setAttribute('r', g.r); } else if (node.type === 'arc') { const large = g.end - g.start > Math.PI ? 1 : 0, outerStart = `${g.cx + g.r * Math.cos(g.start)} ${g.cy + g.r * Math.sin(g.start)}`, outerEnd = `${g.cx + g.r * Math.cos(g.end)} ${g.cy + g.r * Math.sin(g.end)}`; const p = g.innerR > 0 ? [`M ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, `L ${g.cx + g.innerR * Math.cos(g.end)} ${g.cy + g.innerR * Math.sin(g.end)}`, `A ${g.innerR} ${g.innerR} 0 ${large} 0 ${g.cx + g.innerR * Math.cos(g.start)} ${g.cy + g.innerR * Math.sin(g.start)}`, 'Z'] : [`M ${g.cx} ${g.cy}`, `L ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, 'Z']; element.setAttribute('d', p.join(' ')); } else if (node.type === 'path') element.setAttribute('d', `${g.points.map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`); else if (node.type === 'text') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.textContent = g.text; const anchor = s.textAnchor || (s.textAlign === 'end' ? 'end' : s.textAlign === 'center' ? 'middle' : s.textAlign === 'right' ? 'end' : s.textAlign === 'left' ? 'start' : 'start'); if (anchor) element.setAttribute('text-anchor', anchor); const baseline = s.textBaseline || s.baseline || 'alphabetic'; if (baseline === 'top') { element.setAttribute('dominant-baseline', 'text-before-edge'); element.setAttribute('alignment-baseline', 'before-edge'); } else if (baseline === 'middle' || baseline === 'central') { element.setAttribute('dominant-baseline', 'middle'); element.setAttribute('alignment-baseline', 'middle'); } else if (baseline === 'bottom' || baseline === 'hanging') { element.setAttribute('dominant-baseline', 'text-after-edge'); element.setAttribute('alignment-baseline', 'after-edge'); } else { element.setAttribute('dominant-baseline', 'alphabetic'); element.setAttribute('alignment-baseline', 'alphabetic'); } if (s.font) { const raw = String(s.font).trim(); element.setAttribute('font', raw); const sizeMatch = raw.match(/\b(\d+(?:\.\d+)?)\s*(px|em|rem|pt|%)/i); const size = sizeMatch ? `${sizeMatch[1]}${sizeMatch[2].toLowerCase()}` : ''; if (size) element.style.fontSize = size; const weightMatch = raw.match(/^\s*(\d{3}|normal|bold|lighter|bolder)\b/i); if (weightMatch) element.style.fontWeight = weightMatch[1]; const family = raw.replace(/^\s*(?:(?:normal|italic|oblique)(?:\s+[^0-9\s]+)?\s+)?(?:\d{3}|normal|bold|lighter|bolder)\s+/i, '').replace(/\b\d+(?:\.\d+)?\s*(?:px|em|rem|pt|%)(?:\s*\/\s*\S+)?\s*/i, '').trim(); if (family) element.style.fontFamily = family; } } if (node.type === 'path' && !s.fill) element.setAttribute('fill', 'none'); else if (s.fill) element.setAttribute('fill', s.fill); if (s.stroke) element.setAttribute('stroke', s.stroke); if (s.strokeWidth) element.setAttribute('stroke-width', s.strokeWidth); if (s.opacity != null) element.setAttribute('opacity', node.highlighted || node.selected ? 1 : s.opacity); if (node.highlighted || node.selected) element.setAttribute('filter', 'brightness(1.2)'); if (node.interactive) element.style.cursor = 'pointer'; if (s.pointerEvents === 'none') element.style.pointerEvents = 'none'; if (s.ariaHidden === 'true') element.setAttribute('aria-hidden', 'true'); if (s.role === 'presentation') element.setAttribute('role', 'presentation'); if (node.decorative) { element.setAttribute('aria-hidden', 'true'); element.setAttribute('role', 'presentation'); element.style.pointerEvents = 'none'; element.style.userSelect = 'none'; } this.svg.appendChild(element); node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }; scene.root.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }
89
+ this.clear(); const renderNode = node => { if (!node.visible || node.type === 'root') { node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); return; } const g = node.geometry || {}, s = node.style || {}; const element = document.createElementNS('http://www.w3.org/2000/svg', node.type === 'path' || node.type === 'arc' ? 'path' : node.type === 'text' ? 'text' : node.type); element.setAttribute('id', node.id); element.setAttribute('data-node-id', node.id); if (node.dataRef) element.dataset.dataRef = JSON.stringify(node.dataRef); if (node.type === 'rect') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.setAttribute('width', g.width); element.setAttribute('height', g.height); } else if (node.type === 'line') { ['x1', 'y1', 'x2', 'y2'].forEach(key => element.setAttribute(key, g[key])); } else if (node.type === 'circle') { element.setAttribute('cx', g.cx); element.setAttribute('cy', g.cy); element.setAttribute('r', g.r); } else if (node.type === 'arc') { const large = g.end - g.start > Math.PI ? 1 : 0, outerStart = `${g.cx + g.r * Math.cos(g.start)} ${g.cy + g.r * Math.sin(g.start)}`, outerEnd = `${g.cx + g.r * Math.cos(g.end)} ${g.cy + g.r * Math.sin(g.end)}`; const p = g.innerR > 0 ? [`M ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, `L ${g.cx + g.innerR * Math.cos(g.end)} ${g.cy + g.innerR * Math.sin(g.end)}`, `A ${g.innerR} ${g.innerR} 0 ${large} 0 ${g.cx + g.innerR * Math.cos(g.start)} ${g.cy + g.innerR * Math.sin(g.start)}`, 'Z'] : [`M ${g.cx} ${g.cy}`, `L ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, 'Z']; element.setAttribute('d', p.join(' ')); } else if (node.type === 'path') element.setAttribute('d', `${g.points.map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`); else if (node.type === 'text') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.textContent = g.text; const anchor = s.textAnchor || (s.textAlign === 'end' ? 'end' : s.textAlign === 'center' ? 'middle' : s.textAlign === 'right' ? 'end' : s.textAlign === 'left' ? 'start' : 'start'); if (anchor) element.setAttribute('text-anchor', anchor); const baseline = s.textBaseline || s.baseline || 'alphabetic'; if (baseline === 'top') { element.setAttribute('dominant-baseline', 'text-before-edge'); element.setAttribute('alignment-baseline', 'before-edge'); } else if (baseline === 'middle' || baseline === 'central') { element.setAttribute('dominant-baseline', 'middle'); element.setAttribute('alignment-baseline', 'middle'); } else if (baseline === 'bottom' || baseline === 'hanging') { element.setAttribute('dominant-baseline', 'text-after-edge'); element.setAttribute('alignment-baseline', 'after-edge'); } else { element.setAttribute('dominant-baseline', 'alphabetic'); element.setAttribute('alignment-baseline', 'alphabetic'); } if (s.font) { const raw = String(s.font).trim(); element.setAttribute('font', raw); const sizeMatch = raw.match(/\b(\d+(?:\.\d+)?)\s*(px|em|rem|pt|%)/i); const size = sizeMatch ? `${sizeMatch[1]}${sizeMatch[2].toLowerCase()}` : ''; if (size) element.style.fontSize = size; const weightMatch = raw.match(/^\s*(\d{3}|normal|bold|lighter|bolder)\b/i); if (weightMatch) element.style.fontWeight = weightMatch[1]; const family = raw.replace(/^\s*(?:(?:normal|italic|oblique)(?:\s+[^0-9\s]+)?\s+)?(?:\d{3}|normal|bold|lighter|bolder)\s+/i, '').replace(/\b\d+(?:\.\d+)?\s*(?:px|em|rem|pt|%)(?:\s*\/\s*\S+)?\s*/i, '').trim(); if (family) element.style.fontFamily = family; } } if (node.type === 'path' || node.type === 'circle' || node.type === 'rect' || node.type === 'arc') { if (hasFill(s)) element.setAttribute('fill', s.fill); else element.setAttribute('fill', node.type === 'path' ? 'none' : 'black'); } else if (node.type === 'text') { element.setAttribute('fill', hasFill(s) ? s.fill : color(s, 'fill', '#0f172a')); } if (hasStroke(s)) element.setAttribute('stroke', s.stroke); else if (s.stroke != null && typeof s.stroke === 'string' && NO_FILL_TOKENS.has(s.stroke.trim().toLowerCase())) element.setAttribute('stroke', 'none'); if (s.strokeWidth != null) element.setAttribute('stroke-width', s.strokeWidth); if (s.opacity != null) element.setAttribute('opacity', node.highlighted || node.selected ? 1 : s.opacity); if (node.highlighted || node.selected) element.setAttribute('filter', 'brightness(1.2)'); if (node.interactive) element.style.cursor = 'pointer'; if (s.pointerEvents === 'none') element.style.pointerEvents = 'none'; if (s.ariaHidden === 'true') element.setAttribute('aria-hidden', 'true'); if (s.role === 'presentation') element.setAttribute('role', 'presentation'); if (node.decorative) { element.setAttribute('aria-hidden', 'true'); element.setAttribute('role', 'presentation'); element.style.pointerEvents = 'none'; element.style.userSelect = 'none'; } this.svg.appendChild(element); node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }; scene.root.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }
76
90
  exportString() { if (!this.svg) return ''; return new XMLSerializer().serializeToString(this.svg); }
77
91
  }
@@ -1,68 +0,0 @@
1
- # Iteration 8 Acceptance Record
2
-
3
- Date: 2026-09-16
4
-
5
- ## Result
6
-
7
- Iteration 8A–8D is implemented and accepted for the repository runtime, public Agent contracts, maintained Playground pages, responsive browser viewport, lifecycle checks, and repeatable performance fixtures. Chromium, Firefox 144, WebKit 26, and native Safari 26.6.2 checks pass. Physical iOS/Android checks remain a separately recorded host/device follow-up.
8
-
9
- ## Automated Evidence
10
-
11
- - `npm run agent:check`: passed.
12
- - `git diff --check`: passed.
13
- - Core tests: 51 passed, 0 failed.
14
- - Agent documentation check: 16 charts, 24 commands, and 7 schemas.
15
- - Syntax check: every `src/*.mjs` file passed `node --check`.
16
- - Node.js 18.20.8, 20.20.2, and 22.22.2: Agent checks and the executable Agent workflow passed.
17
- - Package-consumer coverage: public ESM imports, capability discovery, planning, validation, explanation, chart creation, lifecycle, and active Playground entry files are covered by the core suite.
18
-
19
- ## Browser Evidence
20
-
21
- - Environments: Codex in-app Chromium, Playwright Firefox 144, and Playwright WebKit 26 against `npm run playground` on `127.0.0.1:3000`.
22
- - Full Gallery: all 16 public chart types rendered; 0 error cards and no uncaught page errors.
23
- - Renderer switch: all Gallery cases rendered with SVG when forced; default mixed SVG/Canvas cases also passed.
24
- - Responsive checks: desktop and a true 390 x 844 viewport completed without body overflow.
25
- - Agent Workbench: `id` remained a stable identifier, `month` was selected as the dimension, validation passed, record IDs were preserved, and the expected missing-value warning remained visible.
26
- - Interaction Lab: zoom in, zoom out to the full window, pan, reset, selection clearing, keyboard behavior, and Canvas switching passed.
27
- - Accessibility Lab: Line, Pie, Heatmap, Radar, Gantt, and Flow exposed role, accessible label, focusability, and family-specific semantic output.
28
- - Regression pages: Foundational Gallery, Project Intelligence, Business Editing, and Diagram Editor loaded with ready states and no uncaught page errors.
29
- - Lifecycle fixture: 25 create/destroy cycles left 0 chart children and 0 tooltips.
30
- - Firefox/WebKit matrix: 22/22 desktop and 390 x 844 touch-viewport page scenarios passed after fixing Project Intelligence grid shrink behavior and redundant ResizeObserver rendering.
31
- - Native Safari 26.6.2: 11/11 desktop and 390 x 844 page scenarios passed without horizontal overflow.
32
-
33
- ## Performance Sample
34
-
35
- These are local acceptance samples, not universal device budgets.
36
-
37
- | Scenario | Renderer | Create | Resize | JSON export |
38
- | --- | --- | ---: | ---: | ---: |
39
- | Line, 100 rows | Canvas | 1.7 ms | 0.6 ms | 0.1 ms |
40
- | Line, 1,000 rows | Canvas | 6.3 ms | 4.9 ms | 0.3 ms |
41
- | Line, 5,000 rows, Firefox | Canvas | 110 ms | 96 ms | 3 ms |
42
- | Line, 5,000 rows, WebKit | Canvas | 81 ms | 75 ms | 2 ms |
43
- | Heatmap, 5,000 rows, Firefox | Canvas | 19 ms | 15 ms | 2 ms |
44
- | Heatmap, 5,000 rows, WebKit | Canvas | 15 ms | 10 ms | 2 ms |
45
-
46
- The Performance Lab also includes repeatable 5,000-row Line, Heatmap, Gantt, and Diagram scenarios for release-host measurements.
47
-
48
- ## Preview
49
-
50
- Start the no-cache static server from the repository root:
51
-
52
- ```bash
53
- npm run playground
54
- ```
55
-
56
- - Home: `http://localhost:3000/playground/index.html`
57
- - Full Gallery: `http://localhost:3000/playground/project-gallery.html`
58
- - Agent Workbench: `http://localhost:3000/playground/agent-workbench.html`
59
- - Interaction Lab: `http://localhost:3000/playground/interaction-lab.html`
60
- - Accessibility Lab: `http://localhost:3000/playground/accessibility-lab.html`
61
- - Performance Lab: `http://localhost:3000/playground/performance-lab.html`
62
-
63
- ## Post-release Host/Device Follow-up
64
-
65
- - Physical-device touch acceptance on iOS and Android.
66
- - Release-host performance budgets across representative low-end and high-end devices.
67
-
68
- These checks remain important integration evidence, but the three-engine browser matrix, native Safari run, responsive touch viewport, and deterministic local performance fixtures are sufficient for the 2.0 source release.