@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.
- package/CHANGELOG.md +69 -0
- package/LICENSE +201 -0
- package/README.md +194 -0
- package/agent-recipes/diagrams/agent-orchestration.json +18 -0
- package/agent-recipes/diagrams/approval-process.json +16 -0
- package/agent-recipes/diagrams/responsibility-mapping.json +13 -0
- package/agent-recipes/diagrams/workflow.json +19 -0
- package/agent-recipes/foundational-analysis.json +18 -0
- package/agent-recipes/project-management.json +47 -0
- package/agent-recipes/trend-line.json +21 -0
- package/docs/agent/README.md +62 -0
- package/docs/agent/charting-scenario.md +82 -0
- package/docs/agent/coding-agent-integration.md +88 -0
- package/docs/agent/development/2.0-release-readiness.md +65 -0
- package/docs/agent/development/iteration-2.md +9 -0
- package/docs/agent/development/iteration-3.md +142 -0
- package/docs/agent/development/iteration-4.md +261 -0
- package/docs/agent/development/iteration-5.md +43 -0
- package/docs/agent/development/iteration-6.md +130 -0
- package/docs/agent/development/iteration-7.md +117 -0
- package/docs/agent/development/iteration-8-acceptance.md +68 -0
- package/docs/agent/development/iteration-8.md +116 -0
- package/docs/agent/development/iteration-9.md +44 -0
- package/docs/agent/development/playground-plan.md +143 -0
- package/docs/agent/development/prompt-contract.md +22 -0
- package/docs/agent/development/rc-1-acceptance.md +43 -0
- package/docs/agent/development/roadmap.md +259 -0
- package/docs/agent/development-guide.md +60 -0
- package/docs/agent/diagram-scenario.md +82 -0
- package/docs/agent/editing-contract.md +71 -0
- package/docs/agent/frontend-integration.md +66 -0
- package/docs/agent/project-scenario.md +89 -0
- package/docs/agent/quickstart.md +205 -0
- package/docs/agent/runtime-contract.md +132 -0
- package/docs/agent/theme-guide.md +59 -0
- package/docs/agent/zh-CN/README.md +26 -0
- package/docs/agent/zh-CN/charting-scenario.md +62 -0
- package/docs/agent/zh-CN/coding-agent-integration.md +36 -0
- package/docs/agent/zh-CN/development-guide.md +31 -0
- package/docs/agent/zh-CN/diagram-scenario.md +37 -0
- package/docs/agent/zh-CN/editing-contract.md +27 -0
- package/docs/agent/zh-CN/frontend-integration.md +31 -0
- package/docs/agent/zh-CN/project-scenario.md +40 -0
- package/docs/agent/zh-CN/quickstart.md +84 -0
- package/docs/agent/zh-CN/runtime-contract.md +85 -0
- package/docs/agent/zh-CN/theme-guide.md +50 -0
- package/docs/manifests/capabilities.json +79 -0
- package/docs/manifests/commands.json +30 -0
- package/docs/manifests/schemas.json +13 -0
- package/examples/agent-workflow.mjs +94 -0
- package/package.json +55 -0
- package/skills/ichartjs/SKILL.md +72 -0
- package/skills/ichartjs/agents/openai.yaml +4 -0
- package/skills/ichartjs/references/agent-contract.md +39 -0
- package/skills/ichartjs/references/chart-selection.md +22 -0
- package/src/capabilities.mjs +190 -0
- package/src/charts.mjs +234 -0
- package/src/command.mjs +93 -0
- package/src/data.mjs +75 -0
- package/src/diagram-interaction.mjs +195 -0
- package/src/diagram.mjs +135 -0
- package/src/edit-controller.mjs +192 -0
- package/src/edit.mjs +369 -0
- package/src/format.mjs +19 -0
- package/src/history.mjs +14 -0
- package/src/index.mjs +562 -0
- package/src/plugin.mjs +18 -0
- package/src/project-analytics.mjs +357 -0
- package/src/project-linking.mjs +72 -0
- package/src/project.mjs +370 -0
- package/src/recipes.mjs +18 -0
- package/src/renderer.mjs +77 -0
- package/src/scale.mjs +9 -0
- package/src/scene.mjs +18 -0
- package/src/schema.mjs +142 -0
- package/src/spec.mjs +127 -0
- package/src/theme.mjs +232 -0
- package/src/transforms.mjs +34 -0
- package/src/validation.mjs +106 -0
- package/types/index.d.ts +134 -0
package/src/project.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/recipes.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/renderer.mjs
ADDED
|
@@ -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
|
+
}
|