@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/data.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data inspection, normalization, and immutable transformation pipeline.
|
|
3
|
+
* Provides the common row model consumed by chart and Agent APIs.
|
|
4
|
+
*/
|
|
5
|
+
import { binData } from './transforms.mjs';
|
|
6
|
+
export { binData, applyTransforms } from './transforms.mjs';
|
|
7
|
+
function typeOf(values) {
|
|
8
|
+
const usable = values.filter(value => value !== null && value !== undefined && value !== '');
|
|
9
|
+
if (!usable.length) return 'unknown';
|
|
10
|
+
if (usable.every(value => typeof value === 'number' || (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))))) return 'quantitative';
|
|
11
|
+
if (usable.every(value => !Number.isNaN(Date.parse(value)) && typeof value === 'string')) return 'temporal';
|
|
12
|
+
return 'category';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function inferUnit(name, values) {
|
|
16
|
+
const key = String(name).toLowerCase();
|
|
17
|
+
if (/(percent|percentage|rate|ratio|progress|utilization)/.test(key)) return 'percentage';
|
|
18
|
+
if (/(date|time|day|month|year|created|updated|start|end)/.test(key) && values.some(value => typeof value === 'string' && !Number.isNaN(Date.parse(value)))) return 'date';
|
|
19
|
+
if (/(price|cost|revenue|amount|sales)/.test(key)) return 'currency-unknown';
|
|
20
|
+
if (/(duration|days|hours|minutes)/.test(key)) return 'duration-unknown';
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeData(input) {
|
|
25
|
+
let values = [];
|
|
26
|
+
if (Array.isArray(input)) {
|
|
27
|
+
if (input.every(row => Array.isArray(row))) values = input.map(row => ({ name: row[0], value: row[1] }));
|
|
28
|
+
else values = input.map(row => ({ ...row }));
|
|
29
|
+
} else if (input && Array.isArray(input.values)) {
|
|
30
|
+
values = input.values.map(row => ({ ...row }));
|
|
31
|
+
} else if (input && Array.isArray(input.categories) && Array.isArray(input.series)) {
|
|
32
|
+
input.series.forEach(series => input.categories.forEach((category, index) => values.push({ name: category, series: series.name, value: series.data[index] })));
|
|
33
|
+
}
|
|
34
|
+
const fields = [];
|
|
35
|
+
values.forEach(row => Object.keys(row).forEach(field => { if (!fields.includes(field)) fields.push(field); }));
|
|
36
|
+
const warnings = [];
|
|
37
|
+
const normalized = values.map((row, rowIndex) => {
|
|
38
|
+
const copy = { ...row };
|
|
39
|
+
fields.forEach(field => {
|
|
40
|
+
if (copy[field] !== null && copy[field] !== undefined && copy[field] !== '' && typeof copy[field] === 'string' && Number.isFinite(Number(copy[field]))) copy[field] = Number(copy[field]);
|
|
41
|
+
});
|
|
42
|
+
if (Object.values(copy).some(value => value === null || value === undefined || value === '')) warnings.push({ code: 'MISSING_VALUE', row: rowIndex, message: 'Row contains a missing value.' });
|
|
43
|
+
return copy;
|
|
44
|
+
});
|
|
45
|
+
const fieldInfo = fields.map(name => {
|
|
46
|
+
const valuesForField = normalized.map(row => row[name]);
|
|
47
|
+
const numbers = valuesForField.filter(value => typeof value === 'number' && Number.isFinite(value));
|
|
48
|
+
const usable = valuesForField.filter(value => value !== null && value !== undefined && value !== '');
|
|
49
|
+
const type = typeOf(valuesForField), dates = type === 'temporal' ? usable.map(value => Date.parse(value)).filter(Number.isFinite) : [], cardinality = new Set(usable.map(value => String(value))).size, identifier = /(^id$|[_-]id$|^key$|[_-]key$)/i.test(name);
|
|
50
|
+
return { name, type, role: identifier ? 'identifier' : type === 'quantitative' ? 'measure' : type === 'temporal' ? 'temporal-dimension' : 'dimension', unit: inferUnit(name, usable), cardinality, validCount: usable.length, nullCount: valuesForField.length - usable.length, min: numbers.length ? Math.min(...numbers) : undefined, max: numbers.length ? Math.max(...numbers) : undefined, temporalMin: dates.length ? new Date(Math.min(...dates)).toISOString() : undefined, temporalMax: dates.length ? new Date(Math.max(...dates)).toISOString() : undefined };
|
|
51
|
+
});
|
|
52
|
+
return { rows: normalized, fields: fieldInfo, warnings };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function inspectData(input) {
|
|
56
|
+
const data = normalizeData(input);
|
|
57
|
+
const dimensions = data.fields.filter(field => field.role === 'dimension' || field.role === 'temporal-dimension').map(field => field.name);
|
|
58
|
+
const measures = data.fields.filter(field => field.type === 'quantitative').map(field => field.name);
|
|
59
|
+
return { version: '1.0', rows: data.rows.length, fields: data.fields, dimensions, measures, temporalFields: data.fields.filter(field => field.type === 'temporal').map(field => field.name), missingValueCount: data.fields.reduce((sum, field) => sum + field.nullCount, 0), warnings: data.warnings };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class DataPipeline {
|
|
63
|
+
constructor(input) { this._rows = normalizeData(input).rows; }
|
|
64
|
+
filter(predicate) { this._rows = this._rows.filter(predicate); return this; }
|
|
65
|
+
sortBy(field, direction = 'asc') { this._rows = [...this._rows].sort((a, b) => { const left = a[field], right = b[field]; const result = left === right ? 0 : left > right ? 1 : -1; return direction === 'desc' ? -result : result; }); return this; }
|
|
66
|
+
groupBy(field) { this._groupField = field; return this; }
|
|
67
|
+
sum(field, output = field) { if (!this._groupField) return this; const groups = new Map(); this._rows.forEach(row => { const key = row[this._groupField]; const item = groups.get(key) || { [this._groupField]: key, [output]: 0 }; item[output] += Number(row[field]) || 0; groups.set(key, item); }); this._rows = [...groups.values()]; return this; }
|
|
68
|
+
average(field, output = field) { if (!this._groupField) return this; const groups = new Map(); this._rows.forEach(row => { const key = row[this._groupField]; const item = groups.get(key) || { [this._groupField]: key, [output]: 0, __count: 0 }; item[output] += Number(row[field]) || 0; item.__count += 1; groups.set(key, item); }); this._rows = [...groups.values()].map(row => { row[output] /= row.__count; delete row.__count; return row; }); return this; }
|
|
69
|
+
topN(field, count) { this._rows = [...this._rows].sort((a, b) => (Number(b[field]) || 0) - (Number(a[field]) || 0)).slice(0, count); return this; }
|
|
70
|
+
percentage(field, output = field) { const total = this._rows.reduce((sum, row) => sum + (Number(row[field]) || 0), 0) || 1; this._rows = this._rows.map(row => ({ ...row, [output]: (Number(row[field]) || 0) / total })); return this; }
|
|
71
|
+
bin(field, options = {}) { this._rows = binData(this._rows, { ...options, field }).rows; return this; }
|
|
72
|
+
toArray() { return this._rows.map(row => ({ ...row })); }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function data(input) { return new DataPipeline(input); }
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagram pointer, selection, keyboard, and editing interaction helpers.
|
|
3
|
+
* Converts user gestures into validated layout and structure edits.
|
|
4
|
+
*/
|
|
5
|
+
import { buildScene } from './charts.mjs';
|
|
6
|
+
|
|
7
|
+
export const isDiagram = chart => ['flow', 'swimlane'].includes(chart.spec.type);
|
|
8
|
+
|
|
9
|
+
function focusableNodes(chart) {
|
|
10
|
+
const items = [];
|
|
11
|
+
chart.model.scene.walk(node => {
|
|
12
|
+
if (!node.interactive) return;
|
|
13
|
+
if (!/^node-|^group-|^port-/.test(node.id)) return;
|
|
14
|
+
items.push(node);
|
|
15
|
+
});
|
|
16
|
+
return items;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function center(node) {
|
|
20
|
+
if (node.geometry?.cx != null && node.geometry?.cy != null) return { x: node.geometry.cx, y: node.geometry.cy };
|
|
21
|
+
return { x: node.bounds?.x + node.bounds?.width / 2 || node.geometry?.x || 0, y: node.bounds?.y + node.bounds?.height / 2 || node.geometry?.y || 0 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clearConnectPreview(chart) {
|
|
25
|
+
chart.model.scene.root.children = chart.model.scene.root.children.filter(node => node.id !== 'diagram-connect-preview');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function paintConnectPreview(chart, start, end) {
|
|
29
|
+
clearConnectPreview(chart);
|
|
30
|
+
chart.model.scene.add({ id: 'diagram-connect-preview', type: 'path', geometry: { points: [start, end] }, style: { fill: 'none', stroke: '#2563eb', strokeWidth: 2, opacity: 0.65 }, zIndex: 101 });
|
|
31
|
+
if (chart.renderer.container) chart.renderer.render(chart.model.scene);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function paintSelection(chart) {
|
|
35
|
+
for (const [id] of chart._selected) {
|
|
36
|
+
const node = chart.model.scene.find(id);
|
|
37
|
+
if (node) { node.selected = true; chart._selected.set(id, node.dataRef); }
|
|
38
|
+
else chart._selected.delete(id);
|
|
39
|
+
}
|
|
40
|
+
chart.model.scene.walk(node => {
|
|
41
|
+
if (node.id === chart._diagramFocusTarget) node.highlighted = true;
|
|
42
|
+
if (node.selected && node.id.startsWith('node-')) { node.style.stroke = '#f59e0b'; node.style.strokeWidth = 3; }
|
|
43
|
+
if (node.selected && node.id.startsWith('group-')) { node.style.stroke = '#2563eb'; node.style.strokeWidth = 2; }
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function diagramPointer(chart, phase, event, target) {
|
|
48
|
+
if (!isDiagram(chart)) return false;
|
|
49
|
+
if (event.type === 'pointerleave') return true;
|
|
50
|
+
const point = chart._eventPoint(event, target);
|
|
51
|
+
if (phase === 'start') {
|
|
52
|
+
if (event.button != null && event.button !== 0) return true;
|
|
53
|
+
if (chart._diagramGesture) return true;
|
|
54
|
+
chart._suppressDiagramClick = false;
|
|
55
|
+
target.focus?.({ preventScroll: true });
|
|
56
|
+
target.setPointerCapture?.(event.pointerId);
|
|
57
|
+
const hit = chart.model.scene.hit(point.x, point.y), additive = Boolean(event.shiftKey || event.ctrlKey || event.metaKey);
|
|
58
|
+
const nodeId = hit?.dataRef?.nodeId, groupId = hit?.dataRef?.groupId, portId = hit?.dataRef?.portId;
|
|
59
|
+
if (groupId && !nodeId) chart.selectGroup(groupId, { additive });
|
|
60
|
+
else if (nodeId) {
|
|
61
|
+
if (additive && chart.getSelectedNodeIds().includes(nodeId)) chart.selectNodes(chart.getSelectedNodeIds().filter(id => id !== nodeId));
|
|
62
|
+
else if (additive || !chart.getSelectedNodeIds().includes(nodeId)) chart.selectNodes([nodeId], { additive });
|
|
63
|
+
} else if (!additive) chart.clearSelection();
|
|
64
|
+
const mode = portId && chart.spec.editing?.enabled ? 'connect' : nodeId && chart.spec.interaction?.drag && chart.spec.editing?.enabled ? 'nodes' : !hit && (additive || chart.spec.interaction?.brush) ? 'box' : !hit && chart.spec.interaction?.pan ? 'pan' : groupId ? 'group' : 'select';
|
|
65
|
+
const view = chart.model.state.view, positions = {};
|
|
66
|
+
chart.getSelectedNodeIds().forEach(id => {
|
|
67
|
+
const geometry = chart.model.scene.find(`node-${id}`)?.geometry;
|
|
68
|
+
if (!geometry) return;
|
|
69
|
+
positions[id] = { x: (geometry.x - view.offsetX) / view.scale, y: (geometry.y - view.offsetY) / view.scale };
|
|
70
|
+
});
|
|
71
|
+
chart._diagramGesture = { mode, point, previous: point, positions, view: { ...view }, pointerId: event.pointerId, revision: chart._revision, additive, selected: chart.getSelectedNodeIds(), moved: false, hitId: hit?.id, groupId, nodeId, portId };
|
|
72
|
+
if (mode === 'connect' && hit) paintConnectPreview(chart, center(hit), point);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
const gesture = chart._diagramGesture;
|
|
76
|
+
if (!gesture || gesture.pointerId !== event.pointerId) return true;
|
|
77
|
+
if (phase === 'move') {
|
|
78
|
+
if (chart._revision !== gesture.revision) { chart._diagramGesture = null; chart.render(); return true; }
|
|
79
|
+
if (Math.hypot(point.x - gesture.point.x, point.y - gesture.point.y) < 3 && !gesture.moved) return true;
|
|
80
|
+
gesture.moved = true;
|
|
81
|
+
if (gesture.mode === 'connect') {
|
|
82
|
+
const source = chart.model.scene.find(gesture.hitId);
|
|
83
|
+
if (source) {
|
|
84
|
+
paintConnectPreview(chart, center(source), point);
|
|
85
|
+
chart.emit('connectionpreview', { chart, source: source.dataRef, coordinate: point });
|
|
86
|
+
}
|
|
87
|
+
} else if (gesture.mode === 'nodes') {
|
|
88
|
+
let delta = { x: (point.x - gesture.point.x) / gesture.view.scale, y: (point.y - gesture.point.y) / gesture.view.scale };
|
|
89
|
+
const anchor = Object.values(gesture.positions)[0], grid = chart.spec.diagram?.grid ?? 8;
|
|
90
|
+
if (anchor && chart.spec.diagram?.snap !== false && !event.altKey) delta = { x: Math.round((anchor.x + delta.x) / grid) * grid - anchor.x, y: Math.round((anchor.y + delta.y) / grid) * grid - anchor.y };
|
|
91
|
+
gesture.operations = Object.entries(gesture.positions).map(([nodeId, position]) => ({ op: 'moveNode', nodeId, position: { x: position.x + delta.x, y: position.y + delta.y } }));
|
|
92
|
+
const positions = new Map(gesture.operations.map(operation => [operation.nodeId, operation.position]));
|
|
93
|
+
const nodes = (chart.spec.nodes ?? chart.spec.data.nodes).map(node => positions.has(node.id) ? { ...node, position: positions.get(node.id) } : node);
|
|
94
|
+
chart.model = buildScene({ ...chart.spec, nodes });
|
|
95
|
+
paintSelection(chart);
|
|
96
|
+
if (chart.renderer.container) chart.renderer.render(chart.model.scene);
|
|
97
|
+
chart.emit('drag', { chart, nodeIds: gesture.selected, coordinate: point });
|
|
98
|
+
} else if (gesture.mode === 'box') {
|
|
99
|
+
chart.model.scene.root.children = chart.model.scene.root.children.filter(node => node.id !== 'diagram-selection-box');
|
|
100
|
+
chart.model.scene.add({ id: 'diagram-selection-box', type: 'rect', geometry: { x: Math.min(point.x, gesture.point.x), y: Math.min(point.y, gesture.point.y), width: Math.abs(point.x - gesture.point.x), height: Math.abs(point.y - gesture.point.y) }, style: { fill: '#93c5fd', stroke: '#2563eb', opacity: 0.25 }, zIndex: 100 });
|
|
101
|
+
if (chart.renderer.container) chart.renderer.render(chart.model.scene);
|
|
102
|
+
} else if (gesture.mode === 'pan') chart.panBy({ x: point.x - gesture.previous.x, y: point.y - gesture.previous.y });
|
|
103
|
+
gesture.previous = point;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
chart._diagramGesture = null;
|
|
107
|
+
chart._suppressDiagramClick = gesture.moved;
|
|
108
|
+
if (target.hasPointerCapture?.(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
|
109
|
+
const cancelled = event.type === 'pointercancel' || chart._revision !== gesture.revision;
|
|
110
|
+
if (gesture.mode === 'connect') clearConnectPreview(chart);
|
|
111
|
+
chart.render();
|
|
112
|
+
if (!cancelled && gesture.moved && gesture.mode === 'nodes' && gesture.operations?.length) {
|
|
113
|
+
const preview = chart.previewEdit({ type: 'layout-edit', reason: 'Drag diagram selection', operations: gesture.operations });
|
|
114
|
+
if (preview.requiresConfirmation) chart.emit('editrequest', { chart, preview, source: 'pointer' });
|
|
115
|
+
else chart.applyEdit(preview.command, { preview, source: 'pointer', confirmed: true });
|
|
116
|
+
} else if (!cancelled && gesture.moved && gesture.mode === 'box') {
|
|
117
|
+
chart.selectBox(gesture.point, point);
|
|
118
|
+
if (gesture.additive) chart.selectNodes(gesture.selected, { additive: true });
|
|
119
|
+
} else if (!cancelled && gesture.mode === 'connect') {
|
|
120
|
+
const hit = chart.model.scene.hit(point.x, point.y), targetNodeId = hit?.dataRef?.nodeId, targetPortId = hit?.dataRef?.portId;
|
|
121
|
+
if (targetNodeId && targetNodeId !== gesture.nodeId) chart.connectNodes({ from: gesture.nodeId, to: targetNodeId, ...(gesture.portId ? { fromPort: gesture.portId } : {}), ...(targetPortId ? { toPort: targetPortId } : {}) }, { confirmed: true, source: 'pointer' });
|
|
122
|
+
else chart.emit('connectioncancel', { chart, sourceNodeId: gesture.nodeId, sourcePortId: gesture.portId });
|
|
123
|
+
} else if (!cancelled && !gesture.moved && gesture.mode === 'group' && gesture.groupId) chart.toggleGroupCollapse(gesture.groupId, { confirmed: true, source: 'pointer' });
|
|
124
|
+
chart.emit('dragend', { chart, cancelled, nodeIds: gesture.selected });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function diagramKeyboard(chart, event) {
|
|
129
|
+
if (!isDiagram(chart)) return false;
|
|
130
|
+
const modifier = event.ctrlKey || event.metaKey, key = event.key.toLowerCase(), nodes = focusableNodes(chart);
|
|
131
|
+
if (key === 'escape') {
|
|
132
|
+
chart._diagramGesture = null;
|
|
133
|
+
if (chart._keyboardConnection) {
|
|
134
|
+
const source = chart._keyboardConnection;
|
|
135
|
+
chart._keyboardConnection = null;
|
|
136
|
+
clearConnectPreview(chart);
|
|
137
|
+
chart.render();
|
|
138
|
+
chart.emit('connectioncancel', { chart, sourceNodeId: source.nodeId, sourcePortId: source.portId, source: 'keyboard' });
|
|
139
|
+
} else chart.clearSelection();
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
if (modifier && key === 'a') { chart.selectNodes(chart.model.data.rows.map(row => row.id)); event.preventDefault(); return true; }
|
|
144
|
+
if (modifier && key === 'c') { chart.copySelection(); event.preventDefault(); return true; }
|
|
145
|
+
if (modifier && key === 'v') { chart.pasteSelection({ confirmed: true, source: 'keyboard' }); event.preventDefault(); return true; }
|
|
146
|
+
if (modifier && key === 'd') { chart.duplicateSelection({ confirmed: true, source: 'keyboard' }); event.preventDefault(); return true; }
|
|
147
|
+
if (modifier && ['z', 'y'].includes(key) && chart.spec.editing?.enabled) { chart[key === 'y' || event.shiftKey ? 'redo' : 'undo'](); event.preventDefault(); return true; }
|
|
148
|
+
if (['tab', 'enter', ' '].includes(key)) {
|
|
149
|
+
const step = event.shiftKey ? -1 : 1;
|
|
150
|
+
if (!nodes.length) return true;
|
|
151
|
+
if (key === 'tab') chart._diagramFocus = ((chart._diagramFocus ?? (step > 0 ? -1 : 0)) + step + nodes.length) % nodes.length;
|
|
152
|
+
const target = nodes[chart._diagramFocus ?? 0];
|
|
153
|
+
chart._diagramFocusTarget = target?.id;
|
|
154
|
+
if (target?.id?.startsWith('node-')) chart.selectNodes([target.dataRef.nodeId], { additive: key === ' ' && event.shiftKey });
|
|
155
|
+
else if (target?.id?.startsWith('group-')) chart.selectGroup(target.dataRef.groupId, { additive: key === ' ' && event.shiftKey });
|
|
156
|
+
else if (target?.id?.startsWith('port-') && target.dataRef?.nodeId) chart.selectNodes([target.dataRef.nodeId], { additive: key === ' ' && event.shiftKey });
|
|
157
|
+
if (chart._keyboardConnection && target) {
|
|
158
|
+
const source = chart.model.scene.find(chart._keyboardConnection.targetId);
|
|
159
|
+
if (source) paintConnectPreview(chart, center(source), center(target));
|
|
160
|
+
chart.emit('connectionpreview', { chart, source: chart._keyboardConnection, target: target.dataRef, sourceType: 'keyboard' });
|
|
161
|
+
}
|
|
162
|
+
if (key !== 'tab') {
|
|
163
|
+
if (target?.id?.startsWith('port-') && chart.spec.editing?.enabled) {
|
|
164
|
+
if (!chart._keyboardConnection) {
|
|
165
|
+
chart._keyboardConnection = { nodeId: target.dataRef.nodeId, portId: target.dataRef.portId, targetId: target.id };
|
|
166
|
+
paintConnectPreview(chart, center(target), center(target));
|
|
167
|
+
chart.emit('connectionpreview', { chart, source: chart._keyboardConnection, target: null, sourceType: 'keyboard' });
|
|
168
|
+
} else if (target.dataRef.nodeId !== chart._keyboardConnection.nodeId) {
|
|
169
|
+
const source = chart._keyboardConnection;
|
|
170
|
+
chart._keyboardConnection = null;
|
|
171
|
+
clearConnectPreview(chart);
|
|
172
|
+
chart.connectNodes({ from: source.nodeId, to: target.dataRef.nodeId, fromPort: source.portId, toPort: target.dataRef.portId }, { confirmed: true, source: 'keyboard' });
|
|
173
|
+
}
|
|
174
|
+
} else if (target?.id?.startsWith('group-') && chart.spec.editing?.enabled && !chart._keyboardConnection) chart.toggleGroupCollapse(target.dataRef.groupId, { confirmed: true, source: 'keyboard' });
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
if (!['arrowright', 'arrowleft', 'arrowup', 'arrowdown'].includes(key)) return false;
|
|
180
|
+
if (chart.spec.editing?.enabled && chart.getSelectedNodeIds().length) {
|
|
181
|
+
const step = event.shiftKey ? 10 : 1, delta = { x: key === 'arrowright' ? step : key === 'arrowleft' ? -step : 0, y: key === 'arrowdown' ? step : key === 'arrowup' ? -step : 0 };
|
|
182
|
+
const preview = chart.previewEdit({ type: 'layout-edit', reason: 'Keyboard move', operations: [{ op: 'moveNodes', nodeIds: chart.getSelectedNodeIds(), delta }] });
|
|
183
|
+
if (preview.requiresConfirmation) chart.emit('editrequest', { chart, preview, source: 'keyboard' });
|
|
184
|
+
else chart.applyEdit(preview.command, { preview, source: 'keyboard', confirmed: true });
|
|
185
|
+
} else if (nodes.length) {
|
|
186
|
+
const step = ['arrowright', 'arrowdown'].includes(key) ? 1 : -1;
|
|
187
|
+
chart._diagramFocus = ((chart._diagramFocus ?? -1) + step + nodes.length) % nodes.length;
|
|
188
|
+
const target = nodes[chart._diagramFocus];
|
|
189
|
+
chart._diagramFocusTarget = target.id;
|
|
190
|
+
if (target.id.startsWith('node-')) chart.selectNodes([target.dataRef.nodeId]);
|
|
191
|
+
else if (target.id.startsWith('group-')) chart.selectGroup(target.dataRef.groupId);
|
|
192
|
+
}
|
|
193
|
+
event.preventDefault();
|
|
194
|
+
return true;
|
|
195
|
+
}
|
package/src/diagram.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagram data model, validation, deterministic layout, and edge routing.
|
|
3
|
+
* Supports nodes, edges, lanes, flat groups, ports, and 2D routing modes.
|
|
4
|
+
*/
|
|
5
|
+
import { copyJSON } from './schema.mjs';
|
|
6
|
+
|
|
7
|
+
export const diagramLayoutModes = ['manual', 'layered', 'tree', 'radial'];
|
|
8
|
+
export const edgeRoutingModes = ['straight', 'orthogonal', 'curved'];
|
|
9
|
+
|
|
10
|
+
const finite = value => typeof value === 'number' && Number.isFinite(value);
|
|
11
|
+
|
|
12
|
+
export function normalizeDiagramSpec(spec = {}) {
|
|
13
|
+
const data = spec.data || {};
|
|
14
|
+
const nodes = copyJSON(spec.nodes ?? data.nodes ?? data.values ?? []);
|
|
15
|
+
const edges = copyJSON(spec.edges ?? data.edges ?? []);
|
|
16
|
+
const lanes = copyJSON(spec.lanes ?? data.lanes ?? []);
|
|
17
|
+
return { ...copyJSON(spec), nodes, edges, lanes, groups: copyJSON(spec.groups ?? data.groups ?? []), diagram: { layout: spec.diagram?.layout || 'layered', routing: spec.diagram?.routing || 'orthogonal', snap: spec.diagram?.snap ?? true, grid: spec.diagram?.grid ?? 8 } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function validateDiagram(spec = {}) {
|
|
21
|
+
const errors = [], normalized = normalizeDiagramSpec(spec), nodes = normalized.nodes, edges = normalized.edges, lanes = normalized.lanes;
|
|
22
|
+
const nodeIds = new Set(), laneIds = new Set(lanes.map(lane => lane.id));
|
|
23
|
+
const groupIds = new Set();
|
|
24
|
+
normalized.groups.forEach((group, index) => {
|
|
25
|
+
if (typeof group?.id !== 'string' || !group.id) errors.push({ code: 'GROUP_ID', path: `groups.${index}.id`, message: 'Groups require stable IDs.' });
|
|
26
|
+
else if (groupIds.has(group.id)) errors.push({ code: 'DUPLICATE_GROUP_ID', path: `groups.${index}.id`, message: 'Group IDs must be unique.' });
|
|
27
|
+
else groupIds.add(group.id);
|
|
28
|
+
if (group?.parentId) errors.push({ code: 'NESTED_GROUP_UNSUPPORTED', path: `groups.${index}.parentId`, message: '5B supports flat groups, not nested groups.' });
|
|
29
|
+
});
|
|
30
|
+
nodes.forEach((node, index) => {
|
|
31
|
+
if (typeof node.id !== 'string' || !node.id) errors.push({ code: 'NODE_ID', path: `nodes.${index}.id`, message: 'Diagram nodes require stable string IDs.' });
|
|
32
|
+
else if (nodeIds.has(node.id)) errors.push({ code: 'DUPLICATE_NODE_ID', path: `nodes.${index}.id`, message: `Duplicate node ID: ${node.id}.` });
|
|
33
|
+
else nodeIds.add(node.id);
|
|
34
|
+
if (node.position && (!finite(node.position.x) || !finite(node.position.y))) errors.push({ code: 'NODE_POSITION', path: `nodes.${index}.position`, message: 'Node positions must contain finite x and y.' });
|
|
35
|
+
if (node.laneId && lanes.length && !laneIds.has(node.laneId)) errors.push({ code: 'MISSING_LANE', path: `nodes.${index}.laneId`, message: `Unknown lane ID: ${node.laneId}.` });
|
|
36
|
+
if (node.size && (!finite(node.size.width) || !finite(node.size.height) || node.size.width <= 0 || node.size.height <= 0)) errors.push({ code: 'NODE_SIZE', path: `nodes.${index}.size`, message: 'Node sizes must be finite and positive.' });
|
|
37
|
+
if (node.groupId && !groupIds.has(node.groupId)) errors.push({ code: 'MISSING_GROUP', path: `nodes.${index}.groupId`, message: `Unknown group ${node.groupId}.` });
|
|
38
|
+
if (node.ports && (!Array.isArray(node.ports) || node.ports.some(port => !port || typeof port.id !== 'string' || !port.id))) errors.push({ code: 'NODE_PORTS', path: `nodes.${index}.ports`, message: 'Ports must be an array with stable string IDs.' });
|
|
39
|
+
const portIds = new Set();
|
|
40
|
+
if (Array.isArray(node.ports)) node.ports.forEach((port, portIndex) => {
|
|
41
|
+
if (!port) return;
|
|
42
|
+
const path = `nodes.${index}.ports.${portIndex}`;
|
|
43
|
+
if (portIds.has(port.id)) errors.push({ code: 'DUPLICATE_PORT_ID', path, message: 'Port IDs must be unique within a node.' });
|
|
44
|
+
portIds.add(port.id);
|
|
45
|
+
if (port.side !== undefined && !['left', 'right', 'top', 'bottom'].includes(port.side)) errors.push({ code: 'PORT_SIDE', path, message: 'Use left, right, top, or bottom.' });
|
|
46
|
+
if (port.offset !== undefined && (!finite(port.offset) || port.offset < 0 || port.offset > 1)) errors.push({ code: 'PORT_OFFSET', path, message: 'Port offset must be within [0, 1].' });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
const edgeIds = new Set();
|
|
50
|
+
edges.forEach((edge, index) => {
|
|
51
|
+
if (typeof edge.id === 'string' && edgeIds.has(edge.id)) errors.push({ code: 'DUPLICATE_EDGE_ID', path: `edges.${index}.id`, message: `Duplicate edge ID: ${edge.id}.` });
|
|
52
|
+
if (edge.id) edgeIds.add(edge.id);
|
|
53
|
+
if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) errors.push({ code: 'EDGE_ENDPOINT', path: `edges.${index}`, message: 'Edges must reference existing node IDs.' });
|
|
54
|
+
if (edge.from === edge.to) errors.push({ code: 'SELF_EDGE', path: `edges.${index}`, message: 'Self-referencing edges are not allowed by default.' });
|
|
55
|
+
['fromPort', 'toPort'].forEach(key => {
|
|
56
|
+
const node = nodes.find(item => item.id === edge[key === 'fromPort' ? 'from' : 'to']);
|
|
57
|
+
if (edge[key] && (!Array.isArray(node?.ports) || !node.ports.some(port => port?.id === edge[key]))) errors.push({ code: 'MISSING_PORT', path: `edges.${index}.${key}`, message: `Unknown port ${edge[key]}.` });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
if (!diagramLayoutModes.includes(normalized.diagram.layout)) errors.push({ code: 'LAYOUT_MODE', path: 'diagram.layout', message: `Unsupported layout: ${normalized.diagram.layout}.` });
|
|
61
|
+
if (!finite(normalized.diagram.grid) || normalized.diagram.grid <= 0) errors.push({ code: 'DIAGRAM_GRID', path: 'diagram.grid', message: 'Grid must be a positive finite number.' });
|
|
62
|
+
if (!edgeRoutingModes.includes(normalized.diagram.routing)) errors.push({ code: 'ROUTING_MODE', path: 'diagram.routing', message: `Unsupported routing: ${normalized.diagram.routing}.` });
|
|
63
|
+
return { valid: errors.length === 0, errors, spec: normalized };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function diagramGraph(spec) {
|
|
67
|
+
const normalized = normalizeDiagramSpec(spec), nodes = normalized.nodes, edges = normalized.edges;
|
|
68
|
+
const incoming = new Map(nodes.map(node => [node.id, []])), outgoing = new Map(nodes.map(node => [node.id, []]));
|
|
69
|
+
edges.forEach(edge => { if (incoming.has(edge.to) && outgoing.has(edge.from)) { incoming.get(edge.to).push(edge); outgoing.get(edge.from).push(edge); } });
|
|
70
|
+
return { spec: normalized, nodes, edges, incoming, outgoing };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function layoutDiagram(spec, bounds = { x: 0, y: 0, width: 640, height: 360 }) {
|
|
74
|
+
const graph = diagramGraph(spec), mode = graph.spec.diagram.layout, positions = new Map(), gapX = Math.max(150, bounds.width / Math.max(1, graph.nodes.length)), gapY = 72;
|
|
75
|
+
if (mode === 'manual') graph.nodes.forEach(node => positions.set(node.id, { x: node.position?.x ?? bounds.x, y: node.position?.y ?? bounds.y }));
|
|
76
|
+
else {
|
|
77
|
+
const rank = new Map(graph.nodes.map(node => [node.id, 0])), queue = graph.nodes.filter(node => graph.incoming.get(node.id).length === 0).map(node => node.id);
|
|
78
|
+
for (let index = 0; index < queue.length; index += 1) graph.outgoing.get(queue[index]).forEach(edge => { rank.set(edge.to, Math.max(rank.get(edge.to) || 0, (rank.get(edge.from) || 0) + 1)); if (!queue.includes(edge.to) && graph.incoming.get(edge.to).every(item => queue.includes(item.from))) queue.push(edge.to); });
|
|
79
|
+
graph.nodes.filter(node => !queue.includes(node.id)).forEach(node => queue.push(node.id));
|
|
80
|
+
const buckets = new Map(); queue.forEach(id => { const key = mode === 'radial' ? 0 : rank.get(id) || 0; if (!buckets.has(key)) buckets.set(key, []); buckets.get(key).push(id); });
|
|
81
|
+
[...buckets.entries()].forEach(([key, ids]) => ids.forEach((id, index) => { if (mode === 'radial') { const angle = index / Math.max(1, ids.length) * Math.PI * 2; positions.set(id, { x: bounds.x + bounds.width / 2 + Math.cos(angle) * bounds.width * 0.32, y: bounds.y + bounds.height / 2 + Math.sin(angle) * bounds.height * 0.32 }); } else positions.set(id, { x: bounds.x + Number(key) * gapX, y: bounds.y + index * gapY }); }));
|
|
82
|
+
}
|
|
83
|
+
const grid = Number(graph.spec.diagram.grid) || 0, snap = value => grid > 0 ? Math.round(value / grid) * grid : value;
|
|
84
|
+
positions.forEach(position => { position.x = snap(position.x); position.y = snap(position.y); });
|
|
85
|
+
return Object.fromEntries(positions);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function routeEdge(edge, from, to, mode = 'orthogonal') {
|
|
89
|
+
const pointFor = (box, port, fallbackSide) => {
|
|
90
|
+
if (!port) return fallbackSide === 'left' ? { x: box.x, y: box.y + box.height / 2 } : { x: box.x + box.width, y: box.y + box.height / 2 };
|
|
91
|
+
const side = port.side || fallbackSide, offset = Math.max(0, Math.min(1, Number(port.offset ?? 0.5)));
|
|
92
|
+
if (side === 'left') return { x: box.x, y: box.y + box.height * offset };
|
|
93
|
+
if (side === 'top') return { x: box.x + box.width * offset, y: box.y };
|
|
94
|
+
if (side === 'bottom') return { x: box.x + box.width * offset, y: box.y + box.height };
|
|
95
|
+
return { x: box.x + box.width, y: box.y + box.height * offset };
|
|
96
|
+
};
|
|
97
|
+
const start = pointFor(from, edge.fromPortDefinition, 'right'), end = pointFor(to, edge.toPortDefinition, 'left');
|
|
98
|
+
if (mode === 'straight') return [start, end];
|
|
99
|
+
if (mode === 'curved') { const bend = Math.max(30, Math.abs(end.x - start.x) * 0.4); return [start, { x: start.x + bend, y: start.y }, { x: end.x - bend, y: end.y }, end]; }
|
|
100
|
+
const obstacles = (edge.obstacles || []).filter(box => box && Number.isFinite(box.x) && Number.isFinite(box.y) && Number.isFinite(box.width) && Number.isFinite(box.height));
|
|
101
|
+
const padding = Math.max(12, Number(edge.grid) || 8);
|
|
102
|
+
const segmentIntersects = (first, second, box) => {
|
|
103
|
+
const expanded = { x: box.x - padding, y: box.y - padding, width: box.width + padding * 2, height: box.height + padding * 2 };
|
|
104
|
+
if (first.x === second.x) return first.x >= expanded.x && first.x <= expanded.x + expanded.width && Math.max(first.y, second.y) >= expanded.y && Math.min(first.y, second.y) <= expanded.y + expanded.height;
|
|
105
|
+
if (first.y === second.y) return first.y >= expanded.y && first.y <= expanded.y + expanded.height && Math.max(first.x, second.x) >= expanded.x && Math.min(first.x, second.x) <= expanded.x + expanded.width;
|
|
106
|
+
return false;
|
|
107
|
+
};
|
|
108
|
+
const validPath = points => points.every((point, index) => index === 0 || obstacles.every(box => !segmentIntersects(points[index - 1], point, box)));
|
|
109
|
+
const snap = value => { const grid = Number(edge.grid) || 8; return Math.round(value / grid) * grid; };
|
|
110
|
+
const middle = snap((start.x + end.x) / 2), middleY = snap((start.y + end.y) / 2);
|
|
111
|
+
const candidates = [
|
|
112
|
+
[start, { x: middle, y: start.y }, { x: middle, y: end.y }, end],
|
|
113
|
+
[start, { x: start.x, y: middleY }, { x: end.x, y: middleY }, end]
|
|
114
|
+
];
|
|
115
|
+
const xChannels = new Set([start.x, end.x, middle]), yChannels = new Set([start.y, end.y, middleY]);
|
|
116
|
+
obstacles.forEach(box => {
|
|
117
|
+
[box.x - padding, box.x + box.width + padding].forEach(x => xChannels.add(snap(x)));
|
|
118
|
+
[box.y - padding, box.y + box.height + padding].forEach(y => yChannels.add(snap(y)));
|
|
119
|
+
});
|
|
120
|
+
xChannels.forEach(x => candidates.push([start, { x, y: start.y }, { x, y: end.y }, end]));
|
|
121
|
+
yChannels.forEach(y => candidates.push([start, { x: start.x, y }, { x: end.x, y }, end]));
|
|
122
|
+
xChannels.forEach(x => yChannels.forEach(y => {
|
|
123
|
+
candidates.push([start, { x, y: start.y }, { x, y }, { x: end.x, y }, end]);
|
|
124
|
+
candidates.push([start, { x: start.x, y }, { x, y }, { x, y: end.y }, end]);
|
|
125
|
+
}));
|
|
126
|
+
const compact = points => points.filter((point, index) => !index || point.x !== points[index - 1].x || point.y !== points[index - 1].y);
|
|
127
|
+
const length = points => points.slice(1).reduce((sum, point, index) => sum + Math.abs(point.x - points[index].x) + Math.abs(point.y - points[index].y), 0);
|
|
128
|
+
const valid = candidates.map(compact).filter(validPath).sort((left, right) => length(left) - length(right) || left.length - right.length || JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
129
|
+
return valid[0] || compact(candidates[0]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function normalizeDiagramData(spec) {
|
|
133
|
+
const normalized = normalizeDiagramSpec(spec);
|
|
134
|
+
return { ...normalized, nodes: normalized.nodes.map(node => ({ ...node })) };
|
|
135
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart-level edit transaction coordinator.
|
|
3
|
+
* Connects preview, confirmation, commit, revision checks, audit, and history.
|
|
4
|
+
*/
|
|
5
|
+
import { copyJSON, issue } from './schema.mjs';
|
|
6
|
+
import { normalizeCommand } from './command.mjs';
|
|
7
|
+
import { previewEdit, commitPreview } from './edit.mjs';
|
|
8
|
+
|
|
9
|
+
let chartSequence = 0;
|
|
10
|
+
|
|
11
|
+
export class EditController {
|
|
12
|
+
constructor(chart) {
|
|
13
|
+
this.chart = chart;
|
|
14
|
+
this.id = ++chartSequence;
|
|
15
|
+
this.sequence = 0;
|
|
16
|
+
this.pending = new Map();
|
|
17
|
+
this.busy = false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
invalidate() {
|
|
21
|
+
if (this.busy) throw new Error('Cannot replace data during an edit transaction.');
|
|
22
|
+
this.chart._revision += 1;
|
|
23
|
+
this.chart._history.clear();
|
|
24
|
+
this.chart._lastChangeSet = null;
|
|
25
|
+
this.pending.clear();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
targetEntries(command) {
|
|
29
|
+
const spec = this.chart.spec;
|
|
30
|
+
const diagram = ['flow', 'swimlane'].includes(spec.type);
|
|
31
|
+
if (!diagram) return [{ field: 'values', root: false }];
|
|
32
|
+
const operations = command.operations || [];
|
|
33
|
+
const wantsGroups = operations.some(operation => ['toggleGroupCollapse', 'duplicateGroup', 'deleteGroup'].includes(operation?.op));
|
|
34
|
+
const wantsEdges = operations.some(operation => ['updateEdge', 'addEdge', 'duplicateSelection', 'pasteSelection', 'duplicateGroup'].includes(operation?.op) || operation?.op === 'deleteGroup' && operation.policy === 'delete-members');
|
|
35
|
+
const wantsNodes = operations.some(operation => ['moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'duplicateSelection', 'pasteSelection'].includes(operation?.op));
|
|
36
|
+
const entries = [];
|
|
37
|
+
if (wantsNodes || !wantsEdges && !wantsGroups) entries.push({ field: 'nodes', root: spec.nodes !== undefined });
|
|
38
|
+
if (wantsEdges) entries.push({ field: 'edges', root: spec.edges !== undefined });
|
|
39
|
+
if (wantsGroups) entries.push({ field: 'groups', root: spec.groups !== undefined });
|
|
40
|
+
return entries;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
context(command) {
|
|
44
|
+
const spec = this.chart.spec;
|
|
45
|
+
const diagram = ['flow', 'swimlane'].includes(spec.type);
|
|
46
|
+
const entries = this.targetEntries(command);
|
|
47
|
+
const primary = entries[0];
|
|
48
|
+
const nodes = spec.nodes ?? spec.data.nodes ?? [];
|
|
49
|
+
const edges = spec.edges ?? spec.data.edges ?? [];
|
|
50
|
+
const groups = spec.groups ?? spec.data.groups ?? [];
|
|
51
|
+
const lanes = spec.lanes ?? spec.data.lanes ?? [];
|
|
52
|
+
const options = {
|
|
53
|
+
values: primary ? (primary.root ? spec[primary.field] : spec.data[primary.field]) ?? [] : [],
|
|
54
|
+
nodes,
|
|
55
|
+
edges,
|
|
56
|
+
groups,
|
|
57
|
+
lanes,
|
|
58
|
+
nodeSchema: spec.data.schema ?? spec.schema,
|
|
59
|
+
schema: primary?.field === 'edges' ? spec.data.edgeSchema : spec.data.schema ?? spec.schema,
|
|
60
|
+
edgeSchema: spec.data.edgeSchema,
|
|
61
|
+
validationOptions: { ...spec.validationOptions, references: { ...spec.validationOptions?.references, ...(diagram ? { 'flow-node': nodes.map(row => row.id), swimlane: lanes.map(row => row.id) } : {}) } },
|
|
62
|
+
requireConfirmation: spec.editing?.requireConfirmation,
|
|
63
|
+
grid: spec.diagram?.grid || 8,
|
|
64
|
+
diagram: spec.diagram,
|
|
65
|
+
allowStructuralChanges: spec.editing?.allowStructuralChanges === true
|
|
66
|
+
};
|
|
67
|
+
return { options, entries, signature: JSON.stringify([options, spec.editing, spec.type]) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
preview(input) {
|
|
71
|
+
let command;
|
|
72
|
+
try { command = normalizeCommand(input); } catch { return previewEdit(input); }
|
|
73
|
+
const context = this.context(command);
|
|
74
|
+
const preview = previewEdit(command, context.options);
|
|
75
|
+
preview.revision = this.chart._revision;
|
|
76
|
+
if (preview.valid) {
|
|
77
|
+
preview.id = `chart-${this.id}-preview-${++this.sequence}`;
|
|
78
|
+
this.pending.set(preview.id, { command: copyJSON(command), revision: preview.revision, signature: context.signature });
|
|
79
|
+
if (this.pending.size > 50) this.pending.delete(this.pending.keys().next().value);
|
|
80
|
+
}
|
|
81
|
+
return preview;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
failure(code, message) {
|
|
85
|
+
const result = { valid: false, errors: [issue(code, 'editing', message)], warnings: [] };
|
|
86
|
+
this.notify('editerror', { chart: this.chart, result });
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
notify(type, event) {
|
|
91
|
+
try { this.chart.emit(type, event); } catch (error) { return issue('EDIT_LISTENER', type, error.message); }
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
apply(input, options = {}) {
|
|
96
|
+
const chart = this.chart;
|
|
97
|
+
if (this.busy) return { valid: false, errors: [issue('EDIT_IN_PROGRESS', 'editing', 'An edit transaction is already running.')], warnings: [] };
|
|
98
|
+
if (chart.spec.editing?.enabled !== true) return this.failure('EDITING_DISABLED', 'Business editing is disabled for this chart.');
|
|
99
|
+
let command;
|
|
100
|
+
try { command = normalizeCommand(input); } catch (error) { return this.failure('INVALID_COMMAND', error.message); }
|
|
101
|
+
const context = this.context(command);
|
|
102
|
+
const preview = previewEdit(command, context.options);
|
|
103
|
+
preview.revision = chart._revision;
|
|
104
|
+
if (!preview.valid) { this.notify('editerror', { chart, result: preview }); return preview; }
|
|
105
|
+
if (preview.requiresConfirmation && options.confirmed !== true) return this.failure('CONFIRMATION_REQUIRED', 'Host confirmation is required; an Agent flag is not authorization.');
|
|
106
|
+
const previewId = options.previewId ?? options.preview?.id;
|
|
107
|
+
const expectedRevision = options.expectedRevision ?? options.preview?.revision;
|
|
108
|
+
if (expectedRevision !== undefined && expectedRevision !== chart._revision) return this.failure('STALE_PREVIEW', 'The data revision changed after preview.');
|
|
109
|
+
if (preview.requiresConfirmation && !previewId) return this.failure('PREVIEW_REQUIRED', 'Confirm a preview issued by this chart before committing.');
|
|
110
|
+
if (preview.requiresConfirmation && !options.preview) return this.failure('PREVIEW_REQUIRED', 'Pass the exact preview returned by chart.previewEdit() before committing.');
|
|
111
|
+
if (previewId) {
|
|
112
|
+
const stored = this.pending.get(previewId);
|
|
113
|
+
if (!stored || stored.revision !== chart._revision || stored.signature !== context.signature) return this.failure('STALE_PREVIEW', 'The preview expired or its source data or policy changed.');
|
|
114
|
+
if (JSON.stringify(stored.command) !== JSON.stringify(command)) return this.failure('PREVIEW_MISMATCH', 'The command does not match the confirmed preview.');
|
|
115
|
+
preview.id = previewId;
|
|
116
|
+
}
|
|
117
|
+
const committed = commitPreview(preview, { ...options, expectedRevision: chart._revision, previewId: preview.id });
|
|
118
|
+
if (!committed.valid) return committed;
|
|
119
|
+
this.busy = true;
|
|
120
|
+
try {
|
|
121
|
+
let cancelled = false;
|
|
122
|
+
const listenerError = this.notify('beforeedit', { chart, command: copyJSON(command), preview: copyJSON(preview), preventDefault: () => { cancelled = true; } });
|
|
123
|
+
if (cancelled || listenerError) return this.failure('EDIT_CANCELLED', listenerError?.message || 'The host cancelled this edit.');
|
|
124
|
+
if (this.context(command).signature !== context.signature) return this.failure('STALE_PREVIEW', 'Source data changed during confirmation.');
|
|
125
|
+
const snapshots = context.entries.map(entry => ({ ...entry, before: copyJSON(entry.root ? chart.spec[entry.field] : chart.spec.data[entry.field]) }));
|
|
126
|
+
const error = this.publish(committed.targets || {}, context.entries);
|
|
127
|
+
if (error) return this.failure('EDIT_RENDER_FAILED', error.message);
|
|
128
|
+
chart._revision += 1;
|
|
129
|
+
committed.revision = chart._revision;
|
|
130
|
+
committed.target = context.entries.map(entry => entry.root ? entry.field : `data.${entry.field}`).join(',');
|
|
131
|
+
committed.audit.operationId = `chart-${this.id}-operation-${chart._revision}`;
|
|
132
|
+
committed.audit.affectedRecords = [...committed.affectedRecords];
|
|
133
|
+
chart._history.push({
|
|
134
|
+
targets: context.entries.map(entry => ({
|
|
135
|
+
field: entry.field,
|
|
136
|
+
root: entry.root,
|
|
137
|
+
before: snapshots.find(snapshot => snapshot.field === entry.field && snapshot.root === entry.root)?.before,
|
|
138
|
+
after: copyJSON(entry.root ? chart.spec[entry.field] : chart.spec.data[entry.field])
|
|
139
|
+
})),
|
|
140
|
+
changeSet: committed
|
|
141
|
+
});
|
|
142
|
+
chart._lastChangeSet = copyJSON(committed);
|
|
143
|
+
this.pending.clear();
|
|
144
|
+
this.notify('edit', { chart, changeSet: copyJSON(committed) });
|
|
145
|
+
return copyJSON(committed);
|
|
146
|
+
} finally { this.busy = false; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
publish(targets, entries) {
|
|
150
|
+
const chart = this.chart, previousSpec = chart.spec, previousModel = chart.model;
|
|
151
|
+
let nextSpec = chart.spec;
|
|
152
|
+
entries.forEach(entry => {
|
|
153
|
+
const rows = targets[entry.field];
|
|
154
|
+
if (!rows) return;
|
|
155
|
+
nextSpec = entry.root ? { ...nextSpec, [entry.field]: copyJSON(rows) } : { ...nextSpec, data: { ...nextSpec.data, [entry.field]: copyJSON(rows) } };
|
|
156
|
+
});
|
|
157
|
+
chart.spec = nextSpec;
|
|
158
|
+
try { chart.render(); return null; } catch (error) {
|
|
159
|
+
chart.spec = previousSpec;
|
|
160
|
+
chart.model = previousModel;
|
|
161
|
+
try { if (chart.renderer.container) chart.renderer.render(previousModel.scene); } catch {}
|
|
162
|
+
return error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
restore(direction, options = {}) {
|
|
167
|
+
const chart = this.chart;
|
|
168
|
+
if (this.busy) return { valid: false, errors: [issue('EDIT_IN_PROGRESS', 'editing', 'An edit transaction is already running.')], warnings: [] };
|
|
169
|
+
if (chart.spec.editing?.enabled !== true) return this.failure('EDITING_DISABLED', 'Business editing is disabled for this chart.');
|
|
170
|
+
if (options.expectedRevision !== undefined && options.expectedRevision !== chart._revision) return this.failure('STALE_PREVIEW', 'The history revision changed.');
|
|
171
|
+
const entry = chart._history[`${direction}Stack`].at(-1);
|
|
172
|
+
if (!entry) return this.failure(`NO_${direction.toUpperCase()}`, `There is no edit to ${direction}.`);
|
|
173
|
+
const stale = entry.targets.some(target => {
|
|
174
|
+
const current = target.root ? chart.spec[target.field] : chart.spec.data[target.field];
|
|
175
|
+
return JSON.stringify(current) !== JSON.stringify(direction === 'undo' ? target.after : target.before);
|
|
176
|
+
});
|
|
177
|
+
if (stale) return this.failure('STALE_HISTORY', 'Data changed outside the edit history.');
|
|
178
|
+
this.busy = true;
|
|
179
|
+
try {
|
|
180
|
+
const targets = Object.fromEntries(entry.targets.map(target => [target.field, direction === 'undo' ? target.before : target.after]));
|
|
181
|
+
const error = this.publish(targets, entry.targets);
|
|
182
|
+
if (error) return this.failure('EDIT_RENDER_FAILED', error.message);
|
|
183
|
+
chart._history[direction]();
|
|
184
|
+
chart._revision += 1;
|
|
185
|
+
const result = { valid: true, direction, revision: chart._revision, rows: copyJSON(targets[entry.targets[0]?.field]), targets: copyJSON(targets), target: entry.changeSet.target, affectedRecords: entry.changeSet.affectedRecords, audit: { operationId: `chart-${this.id}-operation-${chart._revision}`, originalOperationId: entry.changeSet.audit.operationId, actor: options.actor || 'host', source: 'history', timestamp: new Date().toISOString(), reason: direction, affectedRecords: entry.changeSet.affectedRecords } };
|
|
186
|
+
chart._lastChangeSet = copyJSON(result);
|
|
187
|
+
this.pending.clear();
|
|
188
|
+
this.notify('undoredo', { chart, ...copyJSON(result) });
|
|
189
|
+
return copyJSON(result);
|
|
190
|
+
} finally { this.busy = false; }
|
|
191
|
+
}
|
|
192
|
+
}
|