@taylorwong/ichartjs 2.0.1

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +201 -0
  3. package/README.md +194 -0
  4. package/agent-recipes/diagrams/agent-orchestration.json +18 -0
  5. package/agent-recipes/diagrams/approval-process.json +16 -0
  6. package/agent-recipes/diagrams/responsibility-mapping.json +13 -0
  7. package/agent-recipes/diagrams/workflow.json +19 -0
  8. package/agent-recipes/foundational-analysis.json +18 -0
  9. package/agent-recipes/project-management.json +47 -0
  10. package/agent-recipes/trend-line.json +21 -0
  11. package/docs/agent/README.md +62 -0
  12. package/docs/agent/charting-scenario.md +82 -0
  13. package/docs/agent/coding-agent-integration.md +88 -0
  14. package/docs/agent/development/2.0-release-readiness.md +65 -0
  15. package/docs/agent/development/iteration-2.md +9 -0
  16. package/docs/agent/development/iteration-3.md +142 -0
  17. package/docs/agent/development/iteration-4.md +261 -0
  18. package/docs/agent/development/iteration-5.md +43 -0
  19. package/docs/agent/development/iteration-6.md +130 -0
  20. package/docs/agent/development/iteration-7.md +117 -0
  21. package/docs/agent/development/iteration-8-acceptance.md +68 -0
  22. package/docs/agent/development/iteration-8.md +116 -0
  23. package/docs/agent/development/iteration-9.md +44 -0
  24. package/docs/agent/development/playground-plan.md +143 -0
  25. package/docs/agent/development/prompt-contract.md +22 -0
  26. package/docs/agent/development/rc-1-acceptance.md +43 -0
  27. package/docs/agent/development/roadmap.md +259 -0
  28. package/docs/agent/development-guide.md +60 -0
  29. package/docs/agent/diagram-scenario.md +82 -0
  30. package/docs/agent/editing-contract.md +71 -0
  31. package/docs/agent/frontend-integration.md +66 -0
  32. package/docs/agent/project-scenario.md +89 -0
  33. package/docs/agent/quickstart.md +205 -0
  34. package/docs/agent/runtime-contract.md +132 -0
  35. package/docs/agent/theme-guide.md +59 -0
  36. package/docs/agent/zh-CN/README.md +26 -0
  37. package/docs/agent/zh-CN/charting-scenario.md +62 -0
  38. package/docs/agent/zh-CN/coding-agent-integration.md +36 -0
  39. package/docs/agent/zh-CN/development-guide.md +31 -0
  40. package/docs/agent/zh-CN/diagram-scenario.md +37 -0
  41. package/docs/agent/zh-CN/editing-contract.md +27 -0
  42. package/docs/agent/zh-CN/frontend-integration.md +31 -0
  43. package/docs/agent/zh-CN/project-scenario.md +40 -0
  44. package/docs/agent/zh-CN/quickstart.md +84 -0
  45. package/docs/agent/zh-CN/runtime-contract.md +85 -0
  46. package/docs/agent/zh-CN/theme-guide.md +50 -0
  47. package/docs/manifests/capabilities.json +79 -0
  48. package/docs/manifests/commands.json +30 -0
  49. package/docs/manifests/schemas.json +13 -0
  50. package/examples/agent-workflow.mjs +94 -0
  51. package/package.json +55 -0
  52. package/skills/ichartjs/SKILL.md +72 -0
  53. package/skills/ichartjs/agents/openai.yaml +4 -0
  54. package/skills/ichartjs/references/agent-contract.md +39 -0
  55. package/skills/ichartjs/references/chart-selection.md +22 -0
  56. package/src/capabilities.mjs +190 -0
  57. package/src/charts.mjs +234 -0
  58. package/src/command.mjs +93 -0
  59. package/src/data.mjs +75 -0
  60. package/src/diagram-interaction.mjs +195 -0
  61. package/src/diagram.mjs +135 -0
  62. package/src/edit-controller.mjs +192 -0
  63. package/src/edit.mjs +369 -0
  64. package/src/format.mjs +19 -0
  65. package/src/history.mjs +14 -0
  66. package/src/index.mjs +562 -0
  67. package/src/plugin.mjs +18 -0
  68. package/src/project-analytics.mjs +357 -0
  69. package/src/project-linking.mjs +72 -0
  70. package/src/project.mjs +370 -0
  71. package/src/recipes.mjs +18 -0
  72. package/src/renderer.mjs +77 -0
  73. package/src/scale.mjs +9 -0
  74. package/src/scene.mjs +18 -0
  75. package/src/schema.mjs +142 -0
  76. package/src/spec.mjs +127 -0
  77. package/src/theme.mjs +232 -0
  78. package/src/transforms.mjs +34 -0
  79. package/src/validation.mjs +106 -0
  80. package/types/index.d.ts +134 -0
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Project-management and process visualization scene builders.
3
+ * Covers Gantt, timeline, milestone, burndown, flow, and swimlane views.
4
+ */
5
+ import { Scene } from './scene.mjs';
6
+ import { normalizeData } from './data.mjs';
7
+ import { layoutDiagram, normalizeDiagramData, routeEdge } from './diagram.mjs';
8
+ import { analyzeBurndownSeries, analyzeSchedule } from './project-analytics.mjs';
9
+ import { createLinkedProjectState, filterProjectRows, linkedRecordId } from './project-linking.mjs';
10
+ import { contrastRatio, resolveTheme } from './theme.mjs';
11
+
12
+ export const projectTypes = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane'];
13
+ const day = 86400000;
14
+ export const timestamp = value => value == null || value === '' ? NaN : new Date(value).getTime();
15
+ const dateLabel = value => new Date(value).toISOString().slice(0, 10);
16
+ const dependencyId = dependency => typeof dependency === 'string' ? dependency : dependency?.id || null;
17
+ const markText = (theme, background) => (contrastRatio(theme.text, background) || 0) >= (contrastRatio(theme.background, background) || 0) ? theme.text : theme.background;
18
+
19
+ function text(scene, id, content, x, y, style = {}) {
20
+ scene.add({ id, type: 'text', geometry: { text: String(content), x, y }, style: { fill: scene.theme?.text || '#334155', font: scene.theme?.typography?.label?.font || '12px system-ui', ...style }, zIndex: 4 });
21
+ }
22
+
23
+ function arrow(scene, id, points, reference, critical = false) {
24
+ const color = critical ? scene.theme?.status?.danger || '#dc2626' : scene.theme?.axis || '#64748b';
25
+ const tip = points.at(-1), previous = points.at(-2);
26
+ const angle = Math.atan2(tip.y - previous.y, tip.x - previous.x), size = 7;
27
+ scene.add({ id, type: 'path', geometry: { points }, style: { fill: 'none', stroke: color, strokeWidth: critical ? 2.5 : 1.5 }, dataRef: reference, zIndex: 1 });
28
+ scene.add({ id: id.startsWith('dependency-') ? id.replace('dependency-', 'dependency-arrow-') : `${id}-arrow`, type: 'path', geometry: { points: [tip, { x: tip.x - size * Math.cos(angle - Math.PI / 6), y: tip.y - size * Math.sin(angle - Math.PI / 6) }, { x: tip.x - size * Math.cos(angle + Math.PI / 6), y: tip.y - size * Math.sin(angle + Math.PI / 6) }, tip] }, style: { fill: color, stroke: color }, zIndex: 3 });
29
+ }
30
+
31
+ function timeAxis(scene, plot, min, max) {
32
+ const map = value => plot.x + (timestamp(value) - min) / (max - min || day) * plot.width;
33
+ const ticks = Math.max(2, Math.min(5, Math.floor(plot.width / 100)));
34
+ for (let index = 0; index < ticks; index += 1) {
35
+ const time = min + (max - min) * index / (ticks - 1), x = map(time);
36
+ scene.add({ id: `project-grid-${index}`, type: 'line', geometry: { x1: x, y1: plot.y, x2: x, y2: plot.y + plot.height }, style: { stroke: scene.theme?.grid || '#e2e8f0' } });
37
+ text(scene, `project-tick-${index}`, dateLabel(time), x, plot.y + plot.height + 22, { fill: scene.theme?.muted, textAnchor: index === 0 ? 'start' : index === ticks - 1 ? 'end' : 'middle', font: scene.theme?.typography?.axis?.font || '11px system-ui' });
38
+ }
39
+ return map;
40
+ }
41
+
42
+ function projectConfig(spec) {
43
+ return spec.project || {};
44
+ }
45
+
46
+ function projectOverlays(spec) {
47
+ const project = projectConfig(spec);
48
+ return {
49
+ criticalPath: project.overlays?.criticalPath !== false,
50
+ slack: Boolean(project.overlays?.slack),
51
+ baseline: Boolean(project.overlays?.baseline),
52
+ actual: Boolean(project.overlays?.actual),
53
+ variance: Boolean(project.overlays?.variance)
54
+ };
55
+ }
56
+
57
+ function projectSelection(spec, state) {
58
+ const linked = state.linked || createLinkedProjectState([], {});
59
+ return new Set(linked.selection);
60
+ }
61
+
62
+ function rowWindow(row) {
63
+ const values = [
64
+ row.start, row.end, row.date,
65
+ row.baselineStart, row.baselineEnd, row.baselineDate, row.baseline,
66
+ row.actualStart, row.actualEnd, row.actualDate
67
+ ].map(timestamp).filter(Number.isFinite);
68
+ return values;
69
+ }
70
+
71
+ function taskDatum(row, analytics, recordId) {
72
+ return {
73
+ ...row,
74
+ ...(analytics ? {
75
+ critical: analytics.critical,
76
+ float: analytics.float,
77
+ slack: analytics.slack,
78
+ adjustedStart: analytics.adjustedStart,
79
+ adjustedEnd: analytics.adjustedEnd,
80
+ baselineVarianceDays: analytics.baselineVarianceDays,
81
+ startVarianceDays: analytics.startVarianceDays,
82
+ endVarianceDays: analytics.endVarianceDays
83
+ } : {}),
84
+ recordId
85
+ };
86
+ }
87
+
88
+ export function criticalSchedule(rows, options = {}) {
89
+ const analysis = analyzeSchedule(rows, options);
90
+ if (analysis.warnings.some(item => item.code === 'CYCLIC_DEPENDENCY')) throw new Error('Cyclic task dependencies');
91
+ return {
92
+ duration: analysis.duration,
93
+ criticalIds: analysis.criticalIds,
94
+ criticalEdges: analysis.criticalEdges,
95
+ tasks: analysis.tasks.map(task => ({
96
+ id: task.id,
97
+ dependencies: task.dependencies.map(dependencyId).filter(Boolean),
98
+ earliestStart: task.earliestStart,
99
+ earliestFinish: task.earliestFinish,
100
+ latestStart: task.latestStart,
101
+ latestFinish: task.latestFinish,
102
+ float: task.float,
103
+ critical: task.critical
104
+ })),
105
+ calendar: analysis.calendar,
106
+ assumptions: analysis.assumptions,
107
+ warnings: analysis.warnings
108
+ };
109
+ }
110
+
111
+ export function analyzeBurndown(rows, options = {}) {
112
+ return analyzeBurndownSeries(rows, options);
113
+ }
114
+
115
+ function tasksScene(scene, spec, rows, state) {
116
+ const plot = state.plot;
117
+ const overlays = projectOverlays(spec);
118
+ const values = rows.flatMap(rowWindow);
119
+ let min = Math.min(...(values.length ? values : [Date.now()])), max = Math.max(...(values.length ? values : [Date.now() + day]));
120
+ if (min === max) { min -= day; max += day; }
121
+ const map = timeAxis(scene, plot, min, max), positions = new Map();
122
+ const analyticsMap = new Map((state.schedule?.tasks || []).map(task => [task.id, task]));
123
+ const highlighted = new Set(spec.criticalPath === false || !overlays.criticalPath ? [] : Array.isArray(spec.criticalPath) ? spec.criticalPath : state.schedule?.criticalIds || []);
124
+ const selected = projectSelection(spec, state);
125
+ const rowHeight = Math.max(32, plot.height / Math.max(1, rows.length));
126
+ rows.forEach((row, index) => {
127
+ const analytics = analyticsMap.get(row.id);
128
+ const recordId = linkedRecordId(row, index);
129
+ const startValue = row.start ?? row.date ?? row.end;
130
+ const endValue = row.end ?? row.date ?? row.start;
131
+ const baselineStart = row.baselineStart || row.baselineDate || row.baseline || null;
132
+ const baselineEnd = row.baselineEnd || row.baselineDate || baselineStart;
133
+ const actualStart = row.actualStart || row.actualDate || null;
134
+ const actualEnd = row.actualEnd || row.actualDate || actualStart;
135
+ const start = map(startValue), end = map(endValue);
136
+ const y = plot.y + (index + 0.5) * rowHeight;
137
+ const milestone = spec.type === 'milestone' || row.milestone || start === end;
138
+ if (overlays.baseline && baselineStart) {
139
+ const baselineStartX = map(baselineStart), baselineEndX = map(baselineEnd || baselineStart);
140
+ if (milestone) scene.add({ id: `project-baseline-${index}`, type: 'circle', geometry: { cx: baselineStartX, cy: y, r: 4 }, bounds: { x: baselineStartX - 4, y: y - 4, width: 8, height: 8 }, style: { fill: spec.theme.background, stroke: spec.theme.axis, strokeWidth: 1.5 }, zIndex: 1 });
141
+ else scene.add({ id: `project-baseline-${index}`, type: 'rect', geometry: { x: baselineStartX, y: y - 14, width: Math.max(2, baselineEndX - baselineStartX), height: 6 }, style: { fill: spec.theme.border }, zIndex: 1 });
142
+ }
143
+ if (overlays.actual && actualStart) {
144
+ const actualStartX = map(actualStart), actualEndX = map(actualEnd || actualStart);
145
+ if (milestone) scene.add({ id: `project-actual-${index}`, type: 'circle', geometry: { cx: actualStartX, cy: y, r: 3 }, bounds: { x: actualStartX - 3, y: y - 3, width: 6, height: 6 }, style: { fill: spec.theme.text }, zIndex: 3 });
146
+ else scene.add({ id: `project-actual-${index}`, type: 'rect', geometry: { x: actualStartX, y: y + 8, width: Math.max(2, actualEndX - actualStartX), height: 4 }, style: { fill: spec.theme.text, opacity: 0.35 }, zIndex: 3 });
147
+ }
148
+ const geometry = milestone ? { cx: start, cy: y, r: 7 } : { x: start, y: y - 10, width: Math.max(2, end - start), height: 20 };
149
+ const bounds = milestone ? { x: start - 8, y: y - 8, width: 16, height: 16 } : { ...geometry };
150
+ const datum = taskDatum(row, analytics, recordId);
151
+ scene.add({
152
+ id: `project-item-${index}`,
153
+ type: milestone ? 'circle' : 'rect',
154
+ geometry,
155
+ bounds,
156
+ style: {
157
+ fill: highlighted.has(row.id) ? spec.theme.status.danger : row.status === 'done' ? spec.theme.status.success : spec.colors[index % spec.colors.length],
158
+ stroke: selected.has(recordId) ? spec.theme.selection : spec.theme.background,
159
+ strokeWidth: selected.has(recordId) ? 2 : 1
160
+ },
161
+ dataRef: { dataIndex: index, taskId: row.id, recordId, datum },
162
+ interactive: true,
163
+ zIndex: 2
164
+ });
165
+ positions.set(row.id || recordId, { start, end, y });
166
+ const label = row.name || row.title || row.label || row.id || `Item ${index + 1}`;
167
+ text(scene, `project-label-${index}`, label.length > 17 ? `${label.slice(0, 16)}…` : label, plot.x - 12, y + 4, { textAnchor: 'end' });
168
+ if (row.progress != null && !milestone) scene.add({ id: `project-progress-${index}`, type: 'rect', geometry: { ...geometry, width: geometry.width * (row.progress > 1 ? row.progress / 100 : row.progress) }, style: { fill: spec.theme.text, opacity: 0.25 }, zIndex: 3 });
169
+ if (overlays.slack && analytics?.latestFinish && !milestone) {
170
+ const latestEnd = map(analytics.latestFinish);
171
+ scene.add({ id: `project-slack-${index}`, type: 'line', geometry: { x1: end, y1: y, x2: latestEnd, y2: y }, style: { stroke: spec.theme.status.warning, strokeWidth: 1.5, opacity: 0.9 } });
172
+ }
173
+ if (overlays.variance) {
174
+ const variance = analytics?.endVarianceDays ?? analytics?.baselineVarianceDays ?? null;
175
+ if (variance != null) text(scene, `project-variance-${index}`, `${variance > 0 ? '+' : ''}${variance}d`, milestone ? start + 12 : end + 8, y - 12, { font: spec.theme.typography.axis.font, fill: variance > 0 ? spec.theme.status.danger : variance < 0 ? spec.theme.status.info : spec.theme.muted });
176
+ }
177
+ });
178
+ rows.forEach((row, index) => (row.dependencies || []).forEach((dependency, dependencyIndex) => {
179
+ const fromId = dependencyId(dependency), toId = row.id;
180
+ const from = positions.get(fromId), to = positions.get(toId);
181
+ if (!from || !to) return;
182
+ const critical = highlighted.has(fromId) && highlighted.has(toId) && state.schedule?.criticalEdges?.some(edge => edge.from === fromId && edge.to === toId);
183
+ const bend = from.end + 12;
184
+ arrow(scene, `dependency-${index}-${dependencyIndex}`, [{ x: from.end, y: from.y }, { x: bend, y: from.y }, { x: bend, y: to.y - 15 }, { x: to.start - 10, y: to.y - 15 }, { x: to.start - 10, y: to.y }, { x: to.start, y: to.y }], { from: fromId, to: toId, critical }, critical);
185
+ }));
186
+ state.timeDomain = [min, max];
187
+ }
188
+
189
+ function burndownScene(scene, spec, rows, state) {
190
+ const analysis = analyzeBurndown(rows, projectConfig(spec).burndown || spec.burndown || {});
191
+ const { samples, forecast } = analysis;
192
+ const plot = state.plot;
193
+ state.burndown = analysis;
194
+ state.projectAnalytics = { ...(state.projectAnalytics || {}), burndown: analysis };
195
+ const min = Math.min(analysis.start, samples[0]?.time || analysis.start || Date.now());
196
+ const max = Math.max(analysis.end, samples.at(-1)?.time || analysis.end || min + day, forecast.time || 0, min + day);
197
+ const map = timeAxis(scene, plot, min, max);
198
+ const maxValue = Math.max(1, ...samples.flatMap(sample => [sample.remaining, sample.ideal, sample.scope]));
199
+ const mapY = value => plot.y + plot.height - value / maxValue * plot.height;
200
+ const selected = projectSelection(spec, state);
201
+ const series = [['remaining', 'actual', spec.colors[0]], ['ideal', 'ideal', spec.theme.axis], ['scope', 'scope-total', spec.theme.status.warning]];
202
+ series.forEach(([field, id, color]) => scene.add({ id: `burndown-${id}`, type: 'path', geometry: { points: samples.map(sample => ({ x: map(sample.time), y: mapY(sample[field]) })) }, style: { fill: 'none', stroke: color, strokeWidth: 2 } }));
203
+ for (let index = 0; index <= 2; index += 1) text(scene, `burndown-y-${index}`, Number((maxValue * index / 2).toFixed(1)), plot.x - 12, mapY(maxValue * index / 2) + 4, { textAnchor: 'end' });
204
+ samples.forEach(sample => {
205
+ const x = map(sample.time), y = mapY(sample.remaining), index = sample.dataIndex;
206
+ const recordId = linkedRecordId(sample, index);
207
+ if (sample.scopeChange) scene.add({ id: `burndown-scope-${index}`, type: 'line', geometry: { x1: x, y1: plot.y, x2: x, y2: plot.y + plot.height }, style: { stroke: spec.theme.status.warning } });
208
+ scene.add({ id: `burndown-item-${index}`, type: 'circle', geometry: { cx: x, cy: y, r: 5 }, bounds: { x: x - 8, y: y - 8, width: 16, height: 16 }, style: { fill: spec.colors[0], stroke: selected.has(recordId) ? spec.theme.selection : spec.theme.background, strokeWidth: selected.has(recordId) ? 2 : 1 }, dataRef: { dataIndex: index, recordId, datum: { ...sample, forecast: forecast.date, forecastReason: forecast.reason } }, interactive: true, zIndex: 2 });
209
+ });
210
+ if (forecast.reason === 'estimated') {
211
+ const last = samples.at(-1);
212
+ scene.add({ id: 'burndown-forecast', type: 'path', geometry: { points: [{ x: map(last.time), y: mapY(last.remaining) }, { x: map(forecast.time), y: mapY(0) }] }, style: { fill: 'none', stroke: spec.theme.status.danger, strokeWidth: 2 } });
213
+ }
214
+ text(scene, 'burndown-projected', forecast.date ? `Estimated finish: ${forecast.date}` : `Forecast unavailable: ${forecast.reason}`, plot.x, plot.y - 16, { font: spec.theme.typography.axis.font });
215
+ }
216
+
217
+ function diagramScene(scene, spec, rows, state) {
218
+ const diagramSpec = normalizeDiagramData(spec);
219
+ const plot = state.plot, edges = diagramSpec.edges, lanes = diagramSpec.lanes, groups = diagramSpec.groups, layout = layoutDiagram(diagramSpec, plot);
220
+ rows = diagramSpec.nodes;
221
+ const collapsedGroups = new Set(groups.filter(group => group.collapsed).map(group => group.id));
222
+ const ranks = new Map(rows.map(row => [row.id, 0]));
223
+ const pending = new Map(rows.map(row => [row.id, edges.filter(edge => edge.to === row.id).length]));
224
+ const queue = rows.filter(row => pending.get(row.id) === 0).map(row => row.id);
225
+ for (let index = 0; index < queue.length; index += 1) edges.filter(edge => edge.from === queue[index]).forEach(edge => {
226
+ ranks.set(edge.to, Math.max(ranks.get(edge.to), ranks.get(edge.from) + 1));
227
+ pending.set(edge.to, pending.get(edge.to) - 1);
228
+ if (!pending.get(edge.to)) queue.push(edge.to);
229
+ });
230
+ rows.filter(row => !queue.includes(row.id)).forEach((row, index) => ranks.set(row.id, queue.length + index));
231
+ const columns = Math.max(1, ...[...ranks.values()].map(rank => rank + 1));
232
+ const gapX = Math.max(144, plot.width / columns), laneHeight = Math.max(80, plot.height / Math.max(1, lanes.length));
233
+ const positions = new Map(), slots = new Map();
234
+ lanes.forEach((lane, index) => {
235
+ scene.add({ id: `lane-${index}`, type: 'rect', geometry: { x: plot.x, y: plot.y + index * laneHeight, width: Math.max(plot.width, columns * gapX), height: laneHeight }, style: { fill: index % 2 ? spec.theme.surface : spec.theme.background, stroke: spec.theme.border }, zIndex: -1 });
236
+ text(scene, `lane-label-${index}`, lane.label || lane.id, plot.x - 12, plot.y + index * laneHeight + 20, { textAnchor: 'end' });
237
+ });
238
+ rows.forEach((row, index) => {
239
+ const rank = ranks.get(row.id), lane = Math.max(0, lanes.findIndex(item => item.id === row.laneId));
240
+ const slotKey = `${lane}:${rank}`, slot = slots.get(slotKey) || 0;
241
+ slots.set(slotKey, slot + 1);
242
+ const sameCell = rows.filter(item => ranks.get(item.id) === rank && (!lanes.length || item.laneId === row.laneId)).length;
243
+ const generated = layout[row.id];
244
+ const geometry = { x: row.position?.x ?? generated?.x ?? plot.x + rank * gapX + 12, y: row.position?.y ?? generated?.y ?? plot.y + (lanes.length ? lane * laneHeight : 0) + (slot + 0.5) * (lanes.length ? laneHeight : Math.max(plot.height, sameCell * 64)) / sameCell - 18, width: row.size?.width || 112, height: row.size?.height || 36 };
245
+ positions.set(row.id, geometry);
246
+ if (collapsedGroups.has(row.groupId)) return;
247
+ scene.add({ id: `node-${row.id}`, type: 'rect', geometry, bounds: { ...geometry }, style: { fill: spec.colors[lane % spec.colors.length] }, dataRef: { nodeId: row.id, dataIndex: index, datum: row, groupId: row.groupId || null }, interactive: true, zIndex: 2 });
248
+ (row.ports || []).forEach(port => {
249
+ const point = port.side === 'left' ? { x: geometry.x, y: geometry.y + geometry.height * (port.offset ?? 0.5) } : port.side === 'top' ? { x: geometry.x + geometry.width * (port.offset ?? 0.5), y: geometry.y } : port.side === 'bottom' ? { x: geometry.x + geometry.width * (port.offset ?? 0.5), y: geometry.y + geometry.height } : { x: geometry.x + geometry.width, y: geometry.y + geometry.height * (port.offset ?? 0.5) };
250
+ scene.add({ id: `port-${row.id}-${port.id}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 6, y: point.y - 6, width: 12, height: 12 }, style: { fill: spec.theme.background, stroke: spec.theme.text, strokeWidth: 1.5 }, dataRef: { nodeId: row.id, portId: port.id, groupId: row.groupId || null }, interactive: true, zIndex: 4 });
251
+ });
252
+ text(scene, `node-label-${row.id}`, row.label || row.id, geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + 5, { fill: markText(spec.theme, spec.colors[lane % spec.colors.length]), textAnchor: 'middle' });
253
+ });
254
+ const groupBoxes = new Map();
255
+ groups.forEach(group => {
256
+ const boxes = rows.filter(row => row.groupId === group.id).map(row => positions.get(row.id)).filter(Boolean);
257
+ if (!boxes.length) return;
258
+ const padding = typeof group.padding === 'number' ? { left: group.padding, right: group.padding, top: group.padding, bottom: group.padding } : { left: group.padding?.left ?? 16, right: group.padding?.right ?? 16, top: group.padding?.top ?? 24, bottom: group.padding?.bottom ?? 16 };
259
+ const left = Math.min(...boxes.map(box => box.x)) - padding.left, top = Math.min(...boxes.map(box => box.y)) - padding.top, right = Math.max(...boxes.map(box => box.x + box.width)) + padding.right, bottom = Math.max(...boxes.map(box => box.y + box.height)) + padding.bottom;
260
+ const geometry = { x: left, y: top, width: right - left, height: bottom - top };
261
+ groupBoxes.set(group.id, geometry);
262
+ scene.add({ id: `group-${group.id}`, type: 'rect', geometry, bounds: { ...geometry }, style: { fill: group.collapsed ? spec.theme.surface : 'none', stroke: group.collapsed ? spec.theme.focus : spec.theme.axis, strokeWidth: group.collapsed ? 2 : 1.5, opacity: 0.9 }, dataRef: { groupId: group.id, collapsed: Boolean(group.collapsed) }, interactive: false, zIndex: 0 });
263
+ text(scene, `group-label-${group.id}`, group.collapsed ? `${group.label || group.id} (${boxes.length})` : group.label || group.id, left + 8, top + 15, { font: spec.theme.typography.legend.font });
264
+ const label = scene.find(`group-label-${group.id}`);
265
+ if (label) { label.bounds = { x: left, y: top, width: right - left, height: 20 }; label.dataRef = { groupId: group.id, collapsed: Boolean(group.collapsed) }; label.interactive = true; }
266
+ });
267
+ edges.forEach((edge, index) => {
268
+ const fromNode = rows.find(row => row.id === edge.from), toNode = rows.find(row => row.id === edge.to);
269
+ if (!fromNode || !toNode) return;
270
+ if (fromNode.groupId && fromNode.groupId === toNode.groupId && collapsedGroups.has(fromNode.groupId)) return;
271
+ const from = fromNode.groupId && collapsedGroups.has(fromNode.groupId) ? groupBoxes.get(fromNode.groupId) : positions.get(edge.from);
272
+ const to = toNode.groupId && collapsedGroups.has(toNode.groupId) ? groupBoxes.get(toNode.groupId) : positions.get(edge.to);
273
+ if (!from || !to) return;
274
+ const obstacles = [...positions.entries()].filter(([id]) => id !== edge.from && id !== edge.to && !collapsedGroups.has(rows.find(row => row.id === id)?.groupId)).map(([, box]) => box);
275
+ const points = routeEdge({ ...edge, grid: spec.diagram?.grid || 8, obstacles, fromPortDefinition: fromNode.groupId && collapsedGroups.has(fromNode.groupId) ? null : fromNode?.ports?.find(port => port.id === edge.fromPort), toPortDefinition: toNode.groupId && collapsedGroups.has(toNode.groupId) ? null : toNode?.ports?.find(port => port.id === edge.toPort) }, from, to, edge.routing || spec.diagram?.routing || 'orthogonal');
276
+ arrow(scene, `edge-${index}`, points, { from: edge.from, to: edge.to, edgeId: edge.id || `edge-${index}`, status: edge.status, routing: edge.routing || spec.diagram?.routing || 'orthogonal', fromGroupId: fromNode.groupId || null, toGroupId: toNode.groupId || null }, edge.critical === true);
277
+ if (edge.label) {
278
+ const middle = points[Math.floor(points.length / 2)];
279
+ text(scene, `edge-label-${index}`, edge.label, middle.x, middle.y - 8, { textAnchor: 'middle', font: spec.theme.typography.axis.font });
280
+ }
281
+ });
282
+ state.nodePositions = Object.fromEntries(positions);
283
+ state.groupBoxes = Object.fromEntries(groupBoxes);
284
+ }
285
+
286
+ export function projectView(spec) {
287
+ const view = spec.view || {};
288
+ return { scale: Math.max(0.25, Math.min(8, Number(view.scale) || 1)), offsetX: Number(view.offsetX) || 0, offsetY: Number(view.offsetY) || 0 };
289
+ }
290
+
291
+ export function buildProjectScene(spec) {
292
+ const theme = spec.theme && typeof spec.theme === 'object' && spec.theme.typography ? spec.theme : resolveTheme(spec.theme, spec);
293
+ spec = { ...spec, theme, colors: spec.colors || theme.colors, background: spec.background || theme.background, padding: spec.padding || theme.layout.padding };
294
+ const diagram = ['flow', 'swimlane'].includes(spec.type);
295
+ const rawRows = diagram ? spec.nodes ?? spec.data.nodes ?? [] : spec.data.values;
296
+ const sourceRows = rawRows.map(row => ({ ...row }));
297
+ const linked = createLinkedProjectState(sourceRows, projectConfig(spec).linked || {});
298
+ const visibleRows = diagram ? sourceRows : filterProjectRows(sourceRows, projectConfig(spec).linked || {});
299
+ const data = { ...normalizeData(visibleRows), rows: visibleRows.map(row => ({ ...row })), sourceRows: sourceRows.map(row => ({ ...row })) };
300
+ const scene = new Scene(spec.width, spec.height);
301
+ scene.theme = spec.theme;
302
+ const left = ['gantt', 'timeline', 'milestone', 'swimlane'].includes(spec.type) ? Math.min(150, spec.width * 0.32) : spec.padding.left;
303
+ const plotTop = Math.max(spec.padding.top, spec.title?.subtitle ? 72 : spec.title?.text ? 56 : spec.padding.top) + (spec.type === 'burndown' ? 16 : 0);
304
+ const state = { plot: { x: left, y: plotTop, width: Math.max(1, spec.width - left - spec.padding.right), height: Math.max(1, spec.height - plotTop - spec.padding.bottom - 16) }, linked, projectAnalytics: { linked } };
305
+ if (spec.type === 'gantt') {
306
+ state.schedule = analyzeSchedule(data.rows, { calendar: projectConfig(spec).calendar || {} });
307
+ state.projectAnalytics.schedule = state.schedule;
308
+ state.projectAnalytics.assumptions = state.schedule.assumptions;
309
+ state.projectAnalytics.warnings = state.schedule.warnings;
310
+ }
311
+ if (!data.rows.length) text(scene, 'empty', sourceRows.length && linked.visibleCount === 0 ? 'No matching data' : spec.emptyText || 'No data', spec.width / 2, spec.height / 2, { textAnchor: 'middle' });
312
+ else if (diagram) diagramScene(scene, spec, data.rows, state);
313
+ else if (spec.type === 'burndown') burndownScene(scene, spec, data.rows, state);
314
+ else tasksScene(scene, spec, data.rows, state);
315
+ const view = projectView(spec), mapX = value => value * view.scale + view.offsetX, mapY = value => value * view.scale + view.offsetY;
316
+ scene.walk(node => {
317
+ const geometry = node.geometry;
318
+ ['x', 'x1', 'x2', 'cx'].forEach(key => { if (geometry[key] != null) geometry[key] = mapX(geometry[key]); });
319
+ ['y', 'y1', 'y2', 'cy'].forEach(key => { if (geometry[key] != null) geometry[key] = mapY(geometry[key]); });
320
+ ['width', 'height', 'r'].forEach(key => { if (geometry[key] != null) geometry[key] *= view.scale; });
321
+ if (geometry.points) geometry.points = geometry.points.map(point => ({ x: mapX(point.x), y: mapY(point.y) }));
322
+ if (node.bounds) node.bounds = { x: mapX(node.bounds.x), y: mapY(node.bounds.y), width: node.bounds.width * view.scale, height: node.bounds.height * view.scale };
323
+ });
324
+ if (spec.title?.text) text(scene, 'title', spec.title.text, spec.width / 2, 22, { fill: spec.theme.text, font: spec.theme.typography.title.font, textAnchor: 'middle' });
325
+ if (spec.title?.subtitle) text(scene, 'subtitle', spec.title.subtitle, spec.width / 2, 40, { fill: spec.theme.muted, font: spec.theme.typography.subtitle.font, textAnchor: 'middle' });
326
+ state.view = view;
327
+ return { scene, data, state };
328
+ }
329
+
330
+ export function projectTooltip(type, row) {
331
+ if (!row) return '';
332
+ if (type === 'burndown') return [
333
+ `Date: ${row.date}`,
334
+ `Remaining: ${row.remaining ?? row.actual ?? row.value}`,
335
+ `Scope change: ${row.scopeChange || 0}`,
336
+ row.scope != null ? `Total scope: ${row.scope}` : null,
337
+ row.completed != null ? `Completed: ${row.completed}` : null,
338
+ row.forecast ? `Estimated finish: ${row.forecast}` : `Forecast: ${row.forecastReason || 'unavailable'}`
339
+ ].filter(Boolean).join('\n');
340
+ const dependencies = (row.dependencies || []).map(dependencyId).filter(Boolean);
341
+ const variance = row.endVarianceDays ?? row.baselineVarianceDays ?? null;
342
+ return [
343
+ row.name || row.title || row.label || row.id,
344
+ row.start ? `${row.start} → ${row.end || row.start}` : row.date,
345
+ row.progress != null ? `Progress: ${row.progress <= 1 ? Math.round(row.progress * 100) : row.progress}%` : null,
346
+ row.status ? `Status: ${row.status}` : null,
347
+ row.owner || row.resource ? `Owner: ${row.owner || row.resource}` : null,
348
+ dependencies.length ? `Depends on: ${dependencies.join(', ')}` : null,
349
+ row.critical != null ? `Critical: ${row.critical ? 'yes' : 'no'} · Float: ${row.float ?? row.slack ?? 0} days` : null,
350
+ variance != null ? `Variance: ${variance > 0 ? '+' : ''}${variance} days` : null,
351
+ row.laneId ? `Lane: ${row.laneId}` : null,
352
+ row.description
353
+ ].filter(value => value != null && value !== '').join('\n');
354
+ }
355
+
356
+ export function rerouteDiagramScene(scene) {
357
+ scene.walk(node => {
358
+ if (!node.id.startsWith('edge-') || !node.dataRef?.from || !node.dataRef?.to) return;
359
+ const source = scene.find(`node-${node.dataRef.from}`), destination = scene.find(`node-${node.dataRef.to}`);
360
+ if (!source || !destination) return;
361
+ const start = { x: source.geometry.x + source.geometry.width, y: source.geometry.y + source.geometry.height / 2 }, end = { x: destination.geometry.x, y: destination.geometry.y + destination.geometry.height / 2 };
362
+ const middle = (start.x + end.x) / 2;
363
+ node.geometry.points = node.dataRef.routing === 'straight' ? [start, end] : node.dataRef.routing === 'curved' ? [start, { x: start.x + Math.max(30, Math.abs(end.x - start.x) * 0.4), y: start.y }, { x: end.x - Math.max(30, Math.abs(end.x - start.x) * 0.4), y: end.y }, end] : [start, { x: middle, y: start.y }, { x: middle, y: end.y }, end];
364
+ const arrowNode = scene.find(`${node.id}-arrow`), beforeTip = node.geometry.points.at(-2);
365
+ if (arrowNode) arrowNode.geometry.points = [end, { x: end.x - 7 * Math.cos(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) - Math.PI / 6), y: end.y - 7 * Math.sin(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) - Math.PI / 6) }, { x: end.x - 7 * Math.cos(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) + Math.PI / 6), y: end.y - 7 * Math.sin(Math.atan2(end.y - beforeTip.y, end.x - beforeTip.x) + Math.PI / 6) }, end];
366
+ const edgeIndex = Number(node.id.slice(5)), label = scene.find(`edge-label-${edgeIndex}`), labelPoint = node.geometry.points[Math.floor(node.geometry.points.length / 2)];
367
+ if (label && labelPoint) { label.geometry.x = labelPoint.x; label.geometry.y = labelPoint.y - 8; }
368
+ });
369
+ return scene;
370
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Agent Recipe validation against runtime capabilities and business schemas.
3
+ * Recipes remain declarative and do not execute external side effects.
4
+ */
5
+ import { copyJSON, validateDataSchema } from './schema.mjs';
6
+ import { validateCommand } from './command.mjs';
7
+
8
+ export function validateRecipe(recipe, capabilities) {
9
+ const errors = [];
10
+ try { recipe = copyJSON(recipe); } catch (error) { return { valid: false, errors: [{ code: 'INVALID_RECIPE', path: 'recipe', message: error.message }] }; }
11
+ if (!recipe || typeof recipe !== 'object') errors.push({ code: 'INVALID_RECIPE', path: 'recipe', message: 'Recipe must be an object.' });
12
+ if (recipe?.spec) {
13
+ if (!capabilities?.chartTypes?.includes(recipe.spec.type) && !capabilities?.projectManagement?.includes(recipe.spec.type) && !capabilities?.diagrams?.includes(recipe.spec.type)) errors.push({ code: 'RECIPE_TYPE', path: 'recipe.spec.type', message: `Recipe type is not declared in capabilities: ${recipe.spec.type}.` });
14
+ }
15
+ if (recipe?.schema) errors.push(...validateDataSchema(recipe.schema).errors);
16
+ if (recipe?.command) errors.push(...validateCommand(recipe.command).errors);
17
+ return { valid: errors.length === 0, errors, recipe };
18
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Canvas and SVG renderer implementations for the shared Scene Graph.
3
+ * Rendering is intentionally separate from chart layout and data semantics.
4
+ */
5
+ export class BaseRenderer {
6
+ constructor(options = {}) { this.options = options; this.container = null; }
7
+ mount(container) { this.container = typeof container === 'string' ? document.querySelector(container) : container; if (!this.container) throw new Error('Chart container was not found.'); return this; }
8
+ resize() {}
9
+ clear() {}
10
+ render() { throw new Error('Renderer.render() must be implemented.'); }
11
+ destroy() { this.clear(); this.container = null; }
12
+ }
13
+
14
+ function color(style, key, fallback) { return style && style[key] != null ? style[key] : fallback; }
15
+
16
+ export class CanvasRenderer extends BaseRenderer {
17
+ 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
+ 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
+ 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); }
21
+ exportImage(type = 'image/png') { return this.canvas.toDataURL(type); }
22
+ }
23
+
24
+ export class SVGRenderer extends BaseRenderer {
25
+ constructor(options = {}) {
26
+ super(options);
27
+ if (typeof document !== 'undefined') {
28
+ const w = Number(options.width ?? 640), h = Number(options.height ?? 360);
29
+ this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
30
+ this.svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
31
+ this.svg.setAttribute('width', w);
32
+ this.svg.setAttribute('height', h);
33
+ this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
34
+ this.width = w; this.height = h;
35
+ } else {
36
+ this.svg = null;
37
+ this.width = Number(options.width ?? 640);
38
+ this.height = Number(options.height ?? 360);
39
+ }
40
+ }
41
+ mount(container) {
42
+ super.mount(container);
43
+ if (!this.svg && typeof document !== 'undefined') {
44
+ this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
45
+ this.svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
46
+ }
47
+ const containerIsSvg = this.container.tagName && this.container.tagName.toLowerCase() === 'svg';
48
+ if (containerIsSvg) {
49
+ this.svg = this.container;
50
+ } else {
51
+ this.container.innerHTML = '';
52
+ this.container.appendChild(this.svg);
53
+ }
54
+ this.svg.style.background = this.options.background || '#ffffff';
55
+ return this;
56
+ }
57
+ resize(width, height) {
58
+ this.width = width; this.height = height;
59
+ if (!this.svg) return;
60
+ this.svg.setAttribute('width', width); this.svg.setAttribute('height', height); this.svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
61
+ }
62
+ clear() { if (!this.svg) return; while (this.svg.firstChild) this.svg.removeChild(this.svg.firstChild); }
63
+ render(scene) {
64
+ if (!this.svg) {
65
+ if (typeof document === 'undefined') return;
66
+ const w = Number(this.options?.width ?? this.width ?? 640);
67
+ const h = Number(this.options?.height ?? this.height ?? 360);
68
+ this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
69
+ this.svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
70
+ this.svg.setAttribute('width', w);
71
+ this.svg.setAttribute('height', h);
72
+ this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
73
+ this.width = w; this.height = h;
74
+ }
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); }
76
+ exportString() { if (!this.svg) return ''; return new XMLSerializer().serializeToString(this.svg); }
77
+ }
package/src/scale.mjs ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Coordinate scale primitives for category, linear, time, and logarithmic axes.
3
+ * Scales map normalized values to plot coordinates and generate ticks.
4
+ */
5
+ export class Scale {
6
+ constructor({ type = 'linear', domain = [0, 1], range = [0, 1], categories = [] } = {}) { this.type = type; this.domain = domain; this.range = range; this.categories = categories; }
7
+ map(value) { if (this.type === 'category') { const index = this.categories.indexOf(String(value)); return this.range[0] + (index + 0.5) * (this.range[1] - this.range[0]) / Math.max(1, this.categories.length); } const logarithmic = this.type === 'log'; const numeric = this.type === 'time' ? new Date(value).getTime() : logarithmic ? Math.log10(Math.max(0.000001, Number(value))) : Number(value); const start = this.type === 'time' ? new Date(this.domain[0]).getTime() : logarithmic ? Math.log10(Math.max(0.000001, Number(this.domain[0]))) : Number(this.domain[0]); const end = this.type === 'time' ? new Date(this.domain[1]).getTime() : logarithmic ? Math.log10(Math.max(0.000001, Number(this.domain[1]))) : Number(this.domain[1]); return this.range[0] + ((numeric - start) / ((end - start) || 1)) * (this.range[1] - this.range[0]); }
8
+ ticks(count = 5) { if (this.type === 'category') return this.categories.slice(); if (this.type === 'log') { const start = Math.ceil(Math.log10(Math.max(0.000001, Number(this.domain[0])))), end = Math.floor(Math.log10(Math.max(0.000001, Number(this.domain[1])))); return Array.from({ length: Math.max(1, end - start + 1) }, (_, index) => 10 ** (start + index)); } const start = Number(this.domain[0]), end = Number(this.domain[1]), step = (end - start) / Math.max(1, count - 1); return Array.from({ length: count }, (_, index) => start + index * step); }
9
+ }
package/src/scene.mjs ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Renderer-independent Scene Graph nodes, traversal, lookup, and hit testing.
3
+ * Scene nodes carry geometry, style, data references, and interaction state.
4
+ */
5
+ export class SceneNode {
6
+ constructor(config = {}) {
7
+ Object.assign(this, { id: '', type: 'group', geometry: {}, style: {}, children: [], dataRef: null, bounds: null, zIndex: 0, visible: true, interactive: false, selected: false, highlighted: false }, config);
8
+ }
9
+ add(node) { this.children.push(node instanceof SceneNode ? node : new SceneNode(node)); return node; }
10
+ }
11
+
12
+ export class Scene {
13
+ constructor(width, height) { this.width = width; this.height = height; this.root = new SceneNode({ id: 'root', type: 'group' }); }
14
+ add(node) { return this.root.add(node); }
15
+ walk(fn, node = this.root) { fn(node); node.children.forEach(child => this.walk(fn, child)); }
16
+ find(id) { let found; this.walk(node => { if (node.id === id) found = node; }); return found; }
17
+ hit(x, y) { const hits = []; this.walk(node => { if (node.visible && node.interactive && node.bounds && x >= node.bounds.x && x <= node.bounds.x + node.bounds.width && y >= node.bounds.y && y <= node.bounds.y + node.bounds.height) hits.push(node); }); return hits.sort((a, b) => b.zIndex - a.zIndex)[0] || null; }
18
+ }