@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 +13 -0
- package/docs/agent/development/iteration-8.md +55 -1
- package/docs/agent/development/release-sop.md +73 -0
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +1 -0
- package/src/charts.mjs +3 -3
- package/src/renderer.mjs +18 -4
- package/docs/agent/development/iteration-8-acceptance.md +0 -68
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.
|
|
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
package/skills/ichartjs/SKILL.md
CHANGED
|
@@ -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', '
|
|
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'
|
|
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.
|