@taylorwong/ichartjs 2.0.5 → 2.0.7
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 +13 -0
- package/README.md +11 -4
- package/docs/agent/README.md +3 -1
- package/docs/agent/coding-agent-integration.md +6 -2
- package/docs/agent/development/iteration-11.md +44 -0
- package/docs/agent/development/iteration-12.md +101 -0
- package/docs/agent/development/roadmap.md +29 -3
- package/docs/agent/development-guide.md +1 -1
- package/docs/agent/diagram-scenario.md +39 -11
- package/docs/agent/editing-contract.md +5 -0
- package/docs/agent/frontend-integration.md +1 -1
- package/docs/agent/runtime-contract.md +11 -1
- package/docs/agent/theme-guide.md +63 -0
- package/docs/agent/usage-scenarios.md +40 -1
- package/docs/agent/zh-CN/README.md +1 -1
- package/docs/agent/zh-CN/coding-agent-integration.md +6 -2
- package/docs/agent/zh-CN/diagram-scenario.md +32 -3
- package/docs/agent/zh-CN/editing-contract.md +2 -0
- package/docs/agent/zh-CN/frontend-integration.md +1 -1
- package/docs/agent/zh-CN/iteration-12.md +98 -0
- package/docs/agent/zh-CN/runtime-contract.md +3 -1
- package/docs/agent/zh-CN/theme-guide.md +63 -0
- package/docs/agent/zh-CN/usage-scenarios.md +40 -1
- package/docs/manifests/capabilities.json +37 -2
- package/docs/manifests/commands.json +10 -9
- package/docs/manifests/schemas.json +5 -2
- package/package.json +1 -1
- package/skills/ichartjs/SKILL.md +11 -2
- package/src/capabilities.mjs +65 -8
- package/src/charts.mjs +127 -44
- package/src/command.mjs +4 -3
- package/src/diagram-interaction.mjs +55 -11
- package/src/diagram.mjs +149 -36
- package/src/edit-controller.mjs +6 -3
- package/src/edit.mjs +17 -7
- package/src/index.mjs +68 -17
- package/src/layout.mjs +39 -0
- package/src/preferences-ui.mjs +135 -0
- package/src/preferences.mjs +194 -0
- package/src/project.mjs +64 -24
- package/src/renderer.mjs +2 -2
- package/src/scene.mjs +32 -1
- package/src/schema.mjs +7 -2
- package/src/spec.mjs +10 -4
- package/src/theme.mjs +1 -1
- package/types/index.d.ts +39 -3
|
@@ -4,18 +4,24 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { buildScene } from './charts.mjs';
|
|
6
6
|
|
|
7
|
-
export const isDiagram = chart => ['flow', 'swimlane'].includes(chart.spec.type);
|
|
7
|
+
export const isDiagram = chart => ['flow', 'swimlane', 'architecture', 'mindmap'].includes(chart.spec.type);
|
|
8
8
|
|
|
9
9
|
function focusableNodes(chart) {
|
|
10
10
|
const items = [];
|
|
11
11
|
chart.model.scene.walk(node => {
|
|
12
12
|
if (!node.interactive) return;
|
|
13
|
-
if (!/^node-|^group-|^port-/.test(node.id)) return;
|
|
13
|
+
if (!/^node-|^group-|^port-|^edge-/.test(node.id) || node.dataRef?.edgeHandle) return;
|
|
14
14
|
items.push(node);
|
|
15
15
|
});
|
|
16
16
|
return items;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function edgeNode(chart, edgeId) {
|
|
20
|
+
let found = null;
|
|
21
|
+
chart.model.scene.walk(node => { if (!found && node.dataRef?.edgeId === edgeId && !node.dataRef?.edgeHandle) found = node; });
|
|
22
|
+
return found;
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
function center(node) {
|
|
20
26
|
if (node.geometry?.cx != null && node.geometry?.cy != null) return { x: node.geometry.cx, y: node.geometry.cy };
|
|
21
27
|
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 };
|
|
@@ -32,15 +38,31 @@ function paintConnectPreview(chart, start, end) {
|
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export function paintSelection(chart) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
const selected = new Map();
|
|
42
|
+
for (const [id, reference] of chart._selected) {
|
|
43
|
+
const node = reference?.edgeId ? edgeNode(chart, reference.edgeId) : chart.model.scene.find(id);
|
|
44
|
+
if (node) { node.selected = true; selected.set(node.id, node.dataRef); }
|
|
39
45
|
}
|
|
46
|
+
chart._selected = selected;
|
|
47
|
+
const selectedEdges = [];
|
|
40
48
|
chart.model.scene.walk(node => {
|
|
41
49
|
if (node.id === chart._diagramFocusTarget) node.highlighted = true;
|
|
42
50
|
if (node.selected && node.id.startsWith('node-')) { node.style.stroke = '#f59e0b'; node.style.strokeWidth = 3; }
|
|
43
51
|
if (node.selected && node.id.startsWith('group-')) { node.style.stroke = '#2563eb'; node.style.strokeWidth = 2; }
|
|
52
|
+
if (node.selected && node.dataRef?.edgeId) { node.style.stroke = chart.spec.theme?.selection || '#2563eb'; node.style.strokeWidth = Math.max(3, Number(node.style.strokeWidth) || 1); selectedEdges.push(node); }
|
|
53
|
+
});
|
|
54
|
+
if (chart.spec.editing?.enabled !== true || chart.spec.interaction?.edgeDrag !== true) return;
|
|
55
|
+
selectedEdges.forEach(edge => {
|
|
56
|
+
if (edge.geometry?.curve === 'cubic') return;
|
|
57
|
+
const points = edge.geometry?.points || [], edgeId = edge.dataRef.edgeId;
|
|
58
|
+
points.slice(1, -1).forEach((point, offset) => chart.model.scene.add({ id: `edge-handle-waypoint-${edge.id}-${offset + 1}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 5 }, bounds: { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }, style: { fill: chart.spec.theme?.background || '#fff', stroke: chart.spec.theme?.selection || '#2563eb', strokeWidth: 2 }, dataRef: { edgeId, edgeHandle: 'waypoint', pointIndex: offset + 1, routePoints: points.map(value => ({ x: value.x, y: value.y })) }, interactive: true, zIndex: 102 }));
|
|
59
|
+
points.forEach((point, index) => {
|
|
60
|
+
if (index === 0 || index >= points.length - 2) return;
|
|
61
|
+
const next = points[index + 1], orthogonal = point.x === next.x || point.y === next.y;
|
|
62
|
+
if (!orthogonal) return;
|
|
63
|
+
const middle = { x: (point.x + next.x) / 2, y: (point.y + next.y) / 2 };
|
|
64
|
+
chart.model.scene.add({ id: `edge-handle-segment-${edge.id}-${index}`, type: 'rect', geometry: { x: middle.x - 5, y: middle.y - 5, width: 10, height: 10 }, bounds: { x: middle.x - 8, y: middle.y - 8, width: 16, height: 16 }, style: { fill: chart.spec.theme?.selection || '#2563eb', stroke: chart.spec.theme?.background || '#fff', strokeWidth: 1 }, dataRef: { edgeId, edgeHandle: 'segment', segmentIndex: index, routePoints: points.map(value => ({ x: value.x, y: value.y })) }, interactive: true, zIndex: 103 });
|
|
65
|
+
});
|
|
44
66
|
});
|
|
45
67
|
}
|
|
46
68
|
|
|
@@ -55,20 +77,21 @@ export function diagramPointer(chart, phase, event, target) {
|
|
|
55
77
|
target.focus?.({ preventScroll: true });
|
|
56
78
|
target.setPointerCapture?.(event.pointerId);
|
|
57
79
|
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 (
|
|
80
|
+
const nodeId = hit?.dataRef?.nodeId, groupId = hit?.dataRef?.groupId, portId = hit?.dataRef?.portId, edgeId = hit?.dataRef?.edgeId, edgeHandle = hit?.dataRef?.edgeHandle;
|
|
81
|
+
if (edgeId) chart.selectEdges([edgeId], { additive: edgeHandle ? true : additive });
|
|
82
|
+
else if (groupId && !nodeId) chart.selectGroup(groupId, { additive });
|
|
60
83
|
else if (nodeId) {
|
|
61
84
|
if (additive && chart.getSelectedNodeIds().includes(nodeId)) chart.selectNodes(chart.getSelectedNodeIds().filter(id => id !== nodeId));
|
|
62
85
|
else if (additive || !chart.getSelectedNodeIds().includes(nodeId)) chart.selectNodes([nodeId], { additive });
|
|
63
86
|
} 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';
|
|
87
|
+
const mode = edgeHandle && chart.spec.editing?.enabled && chart.spec.interaction?.edgeDrag ? 'edge-handle' : portId && chart.spec.editing?.enabled && chart.spec.interaction?.portConnect ? '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
88
|
const view = chart.model.state.view, positions = {};
|
|
66
89
|
chart.getSelectedNodeIds().forEach(id => {
|
|
67
90
|
const geometry = chart.model.scene.find(`node-${id}`)?.geometry;
|
|
68
91
|
if (!geometry) return;
|
|
69
92
|
positions[id] = { x: (geometry.x - view.offsetX) / view.scale, y: (geometry.y - view.offsetY) / view.scale };
|
|
70
93
|
});
|
|
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 };
|
|
94
|
+
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, edgeId, edgeHandle, pointIndex: hit?.dataRef?.pointIndex, segmentIndex: hit?.dataRef?.segmentIndex, routePoints: hit?.dataRef?.routePoints?.map(value => ({ ...value })) };
|
|
72
95
|
if (mode === 'connect' && hit) paintConnectPreview(chart, center(hit), point);
|
|
73
96
|
return true;
|
|
74
97
|
}
|
|
@@ -84,6 +107,20 @@ export function diagramPointer(chart, phase, event, target) {
|
|
|
84
107
|
paintConnectPreview(chart, center(source), point);
|
|
85
108
|
chart.emit('connectionpreview', { chart, source: source.dataRef, coordinate: point });
|
|
86
109
|
}
|
|
110
|
+
} else if (gesture.mode === 'edge-handle') {
|
|
111
|
+
const grid = chart.spec.diagram?.grid ?? 8, snapped = chart.spec.diagram?.snap === false || event.altKey ? point : { x: Math.round(point.x / grid) * grid, y: Math.round(point.y / grid) * grid }, routePoints = gesture.routePoints.map(value => ({ ...value }));
|
|
112
|
+
if (gesture.edgeHandle === 'waypoint') routePoints[gesture.pointIndex] = snapped;
|
|
113
|
+
else {
|
|
114
|
+
const first = routePoints[gesture.segmentIndex], second = routePoints[gesture.segmentIndex + 1];
|
|
115
|
+
if (first.x === second.x) first.x = second.x = snapped.x;
|
|
116
|
+
else first.y = second.y = snapped.y;
|
|
117
|
+
}
|
|
118
|
+
gesture.waypoints = routePoints.slice(1, -1);
|
|
119
|
+
const edges = chart.getDiagramEdges().map((edge, index) => (edge.id || `edge-${index}`) === gesture.edgeId ? { ...edge, waypoints: gesture.waypoints } : edge);
|
|
120
|
+
chart.model = buildScene({ ...chart.spec, edges });
|
|
121
|
+
paintSelection(chart);
|
|
122
|
+
if (chart.renderer.container) chart.renderer.render(chart.model.scene);
|
|
123
|
+
chart.emit('drag', { chart, edgeId: gesture.edgeId, waypoints: gesture.waypoints, coordinate: point });
|
|
87
124
|
} else if (gesture.mode === 'nodes') {
|
|
88
125
|
let delta = { x: (point.x - gesture.point.x) / gesture.view.scale, y: (point.y - gesture.point.y) / gesture.view.scale };
|
|
89
126
|
const anchor = Object.values(gesture.positions)[0], grid = chart.spec.diagram?.grid ?? 8;
|
|
@@ -113,6 +150,10 @@ export function diagramPointer(chart, phase, event, target) {
|
|
|
113
150
|
const preview = chart.previewEdit({ type: 'layout-edit', reason: 'Drag diagram selection', operations: gesture.operations });
|
|
114
151
|
if (preview.requiresConfirmation) chart.emit('editrequest', { chart, preview, source: 'pointer' });
|
|
115
152
|
else chart.applyEdit(preview.command, { preview, source: 'pointer', confirmed: true });
|
|
153
|
+
} else if (!cancelled && gesture.moved && gesture.mode === 'edge-handle' && gesture.waypoints) {
|
|
154
|
+
const preview = chart.previewEdit({ type: 'layout-edit', reason: 'Drag diagram edge handle', operations: [{ op: 'updateEdge', edgeId: gesture.edgeId, changes: { waypoints: gesture.waypoints } }] });
|
|
155
|
+
if (preview.requiresConfirmation) chart.emit('editrequest', { chart, preview, source: 'pointer' });
|
|
156
|
+
else chart.applyEdit(preview.command, { preview, source: 'pointer', confirmed: true });
|
|
116
157
|
} else if (!cancelled && gesture.moved && gesture.mode === 'box') {
|
|
117
158
|
chart.selectBox(gesture.point, point);
|
|
118
159
|
if (gesture.additive) chart.selectNodes(gesture.selected, { additive: true });
|
|
@@ -145,6 +186,7 @@ export function diagramKeyboard(chart, event) {
|
|
|
145
186
|
if (modifier && key === 'v') { chart.pasteSelection({ confirmed: true, source: 'keyboard' }); event.preventDefault(); return true; }
|
|
146
187
|
if (modifier && key === 'd') { chart.duplicateSelection({ confirmed: true, source: 'keyboard' }); event.preventDefault(); return true; }
|
|
147
188
|
if (modifier && ['z', 'y'].includes(key) && chart.spec.editing?.enabled) { chart[key === 'y' || event.shiftKey ? 'redo' : 'undo'](); event.preventDefault(); return true; }
|
|
189
|
+
if (['delete', 'backspace'].includes(key) && chart.spec.editing?.enabled && chart.spec.editing?.allowDelete && chart.getSelectedEdgeIds().length) { chart.deleteSelectedEdges({ confirmed: true, source: 'keyboard' }); event.preventDefault(); return true; }
|
|
148
190
|
if (['tab', 'enter', ' '].includes(key)) {
|
|
149
191
|
const step = event.shiftKey ? -1 : 1;
|
|
150
192
|
if (!nodes.length) return true;
|
|
@@ -154,13 +196,14 @@ export function diagramKeyboard(chart, event) {
|
|
|
154
196
|
if (target?.id?.startsWith('node-')) chart.selectNodes([target.dataRef.nodeId], { additive: key === ' ' && event.shiftKey });
|
|
155
197
|
else if (target?.id?.startsWith('group-')) chart.selectGroup(target.dataRef.groupId, { additive: key === ' ' && event.shiftKey });
|
|
156
198
|
else if (target?.id?.startsWith('port-') && target.dataRef?.nodeId) chart.selectNodes([target.dataRef.nodeId], { additive: key === ' ' && event.shiftKey });
|
|
199
|
+
else if (target?.dataRef?.edgeId) chart.selectEdges([target.dataRef.edgeId], { additive: key === ' ' && event.shiftKey });
|
|
157
200
|
if (chart._keyboardConnection && target) {
|
|
158
201
|
const source = chart.model.scene.find(chart._keyboardConnection.targetId);
|
|
159
202
|
if (source) paintConnectPreview(chart, center(source), center(target));
|
|
160
203
|
chart.emit('connectionpreview', { chart, source: chart._keyboardConnection, target: target.dataRef, sourceType: 'keyboard' });
|
|
161
204
|
}
|
|
162
205
|
if (key !== 'tab') {
|
|
163
|
-
if (target?.id?.startsWith('port-') && chart.spec.editing?.enabled) {
|
|
206
|
+
if (target?.id?.startsWith('port-') && chart.spec.editing?.enabled && chart.spec.interaction?.portConnect) {
|
|
164
207
|
if (!chart._keyboardConnection) {
|
|
165
208
|
chart._keyboardConnection = { nodeId: target.dataRef.nodeId, portId: target.dataRef.portId, targetId: target.id };
|
|
166
209
|
paintConnectPreview(chart, center(target), center(target));
|
|
@@ -189,6 +232,7 @@ export function diagramKeyboard(chart, event) {
|
|
|
189
232
|
chart._diagramFocusTarget = target.id;
|
|
190
233
|
if (target.id.startsWith('node-')) chart.selectNodes([target.dataRef.nodeId]);
|
|
191
234
|
else if (target.id.startsWith('group-')) chart.selectGroup(target.dataRef.groupId);
|
|
235
|
+
else if (target.dataRef?.edgeId) chart.selectEdges([target.dataRef.edgeId]);
|
|
192
236
|
}
|
|
193
237
|
event.preventDefault();
|
|
194
238
|
return true;
|
package/src/diagram.mjs
CHANGED
|
@@ -3,24 +3,39 @@
|
|
|
3
3
|
* Supports nodes, edges, lanes, flat groups, ports, and 2D routing modes.
|
|
4
4
|
*/
|
|
5
5
|
import { copyJSON } from './schema.mjs';
|
|
6
|
+
import { sampleCubicBezier } from './scene.mjs';
|
|
6
7
|
|
|
7
8
|
export const diagramLayoutModes = ['manual', 'layered', 'tree', 'radial'];
|
|
8
9
|
export const edgeRoutingModes = ['straight', 'orthogonal', 'curved'];
|
|
10
|
+
export const diagramModes = ['process', 'architecture', 'mindmap'];
|
|
9
11
|
|
|
10
12
|
const finite = value => typeof value === 'number' && Number.isFinite(value);
|
|
11
13
|
|
|
12
14
|
export function normalizeDiagramSpec(spec = {}) {
|
|
13
15
|
const data = spec.data || {};
|
|
14
16
|
const nodes = copyJSON(spec.nodes ?? data.nodes ?? data.values ?? []);
|
|
15
|
-
const
|
|
17
|
+
const explicitEdges = copyJSON(spec.edges ?? data.edges ?? []);
|
|
18
|
+
const generatedEdges = spec.type === 'mindmap'
|
|
19
|
+
? nodes.filter(node => node?.parentId).map((node, index) => ({ id: `parent-${node.parentId}-${node.id || index}`, from: node.parentId, to: node.id, relation: 'parent-child' }))
|
|
20
|
+
: [];
|
|
21
|
+
const edgeKeys = new Set(explicitEdges.map(edge => `${edge.from}->${edge.to}`));
|
|
22
|
+
const edges = [...explicitEdges, ...generatedEdges.filter(edge => !edgeKeys.has(`${edge.from}->${edge.to}`))];
|
|
16
23
|
const lanes = copyJSON(spec.lanes ?? data.lanes ?? []);
|
|
17
|
-
|
|
24
|
+
const mode = spec.diagram?.mode || (spec.type === 'architecture' ? 'architecture' : spec.type === 'mindmap' ? 'mindmap' : 'process');
|
|
25
|
+
const defaultLayout = mode === 'mindmap' ? 'tree' : 'layered';
|
|
26
|
+
const defaultRouting = mode === 'mindmap' ? 'curved' : 'orthogonal';
|
|
27
|
+
return { ...copyJSON(spec), nodes, edges, lanes, layers: copyJSON(spec.layers ?? data.layers ?? []), boundaries: copyJSON(spec.boundaries ?? data.boundaries ?? []), groups: copyJSON(spec.groups ?? data.groups ?? []), diagram: { layout: spec.diagram?.layout || defaultLayout, routing: spec.diagram?.routing || defaultRouting, curveTension: spec.diagram?.curveTension ?? 0.4, mode, snap: spec.diagram?.snap ?? true, grid: spec.diagram?.grid ?? 8 } };
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
export function validateDiagram(spec = {}) {
|
|
21
|
-
const errors = [], normalized = normalizeDiagramSpec(spec), nodes = normalized.nodes, edges = normalized.edges, lanes = normalized.lanes;
|
|
31
|
+
const errors = [], normalized = normalizeDiagramSpec(spec), nodes = normalized.nodes, edges = normalized.edges, lanes = normalized.lanes, layers = normalized.layers;
|
|
22
32
|
const nodeIds = new Set(), laneIds = new Set(lanes.map(lane => lane.id));
|
|
23
33
|
const groupIds = new Set();
|
|
34
|
+
const layerIds = new Set(layers.map(layer => layer.id));
|
|
35
|
+
if (!diagramModes.includes(normalized.diagram.mode)) errors.push({ code: 'DIAGRAM_MODE', path: 'diagram.mode', message: `Unsupported diagram mode: ${normalized.diagram.mode}.`, suggestion: `Use ${diagramModes.join(', ')}.` });
|
|
36
|
+
layers.forEach((layer, index) => {
|
|
37
|
+
if (typeof layer?.id !== 'string' || !layer.id) errors.push({ code: 'LAYER_ID', path: `layers.${index}.id`, message: 'Architecture layers require stable IDs.' });
|
|
38
|
+
});
|
|
24
39
|
normalized.groups.forEach((group, index) => {
|
|
25
40
|
if (typeof group?.id !== 'string' || !group.id) errors.push({ code: 'GROUP_ID', path: `groups.${index}.id`, message: 'Groups require stable IDs.' });
|
|
26
41
|
else if (groupIds.has(group.id)) errors.push({ code: 'DUPLICATE_GROUP_ID', path: `groups.${index}.id`, message: 'Group IDs must be unique.' });
|
|
@@ -35,6 +50,9 @@ export function validateDiagram(spec = {}) {
|
|
|
35
50
|
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
51
|
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
52
|
if (node.groupId && !groupIds.has(node.groupId)) errors.push({ code: 'MISSING_GROUP', path: `nodes.${index}.groupId`, message: `Unknown group ${node.groupId}.` });
|
|
53
|
+
if (node.layerId && layers.length && !layerIds.has(node.layerId)) errors.push({ code: 'MISSING_LAYER', path: `nodes.${index}.layerId`, message: `Unknown layer ${node.layerId}.` });
|
|
54
|
+
if (node.parentId && !nodes.some(parent => parent.id === node.parentId)) errors.push({ code: 'MISSING_PARENT', path: `nodes.${index}.parentId`, message: `Unknown parent node ${node.parentId}.` });
|
|
55
|
+
if (node.parentId === node.id) errors.push({ code: 'SELF_PARENT', path: `nodes.${index}.parentId`, message: 'Mindmap nodes cannot parent themselves.' });
|
|
38
56
|
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
57
|
const portIds = new Set();
|
|
40
58
|
if (Array.isArray(node.ports)) node.ports.forEach((port, portIndex) => {
|
|
@@ -56,10 +74,17 @@ export function validateDiagram(spec = {}) {
|
|
|
56
74
|
const node = nodes.find(item => item.id === edge[key === 'fromPort' ? 'from' : 'to']);
|
|
57
75
|
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
76
|
});
|
|
77
|
+
if (edge.curveTension !== undefined && (!finite(edge.curveTension) || edge.curveTension < 0.2 || edge.curveTension > 0.8)) errors.push({ code: 'EDGE_CURVE_TENSION', path: `edges.${index}.curveTension`, message: 'Edge curve tension must be a finite number between 0.2 and 0.8.' });
|
|
78
|
+
if (edge.waypoints !== undefined && (!Array.isArray(edge.waypoints) || edge.waypoints.some(point => !point || !finite(point.x) || !finite(point.y)))) errors.push({ code: 'EDGE_WAYPOINTS', path: `edges.${index}.waypoints`, message: 'Edge waypoints must be an array of finite x/y points.' });
|
|
59
79
|
});
|
|
60
80
|
if (!diagramLayoutModes.includes(normalized.diagram.layout)) errors.push({ code: 'LAYOUT_MODE', path: 'diagram.layout', message: `Unsupported layout: ${normalized.diagram.layout}.` });
|
|
61
81
|
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.' });
|
|
82
|
+
if (!finite(normalized.diagram.curveTension) || normalized.diagram.curveTension < 0.2 || normalized.diagram.curveTension > 0.8) errors.push({ code: 'CURVE_TENSION', path: 'diagram.curveTension', message: 'Curve tension must be a finite number between 0.2 and 0.8.' });
|
|
62
83
|
if (!edgeRoutingModes.includes(normalized.diagram.routing)) errors.push({ code: 'ROUTING_MODE', path: 'diagram.routing', message: `Unsupported routing: ${normalized.diagram.routing}.` });
|
|
84
|
+
if (normalized.diagram.mode === 'mindmap') {
|
|
85
|
+
const parents = new Map(nodes.map(node => [node.id, node.parentId]).filter(([, parentId]) => parentId));
|
|
86
|
+
nodes.forEach(node => { const seen = new Set([node.id]); let current = node.parentId; while (current) { if (seen.has(current)) { errors.push({ code: 'MINDMAP_CYCLE', path: `nodes.${node.id}.parentId`, message: 'Mindmap parent relationships must be acyclic.' }); break; } seen.add(current); current = parents.get(current); } });
|
|
87
|
+
}
|
|
63
88
|
return { valid: errors.length === 0, errors, spec: normalized };
|
|
64
89
|
}
|
|
65
90
|
|
|
@@ -73,6 +98,21 @@ export function diagramGraph(spec) {
|
|
|
73
98
|
export function layoutDiagram(spec, bounds = { x: 0, y: 0, width: 640, height: 360 }) {
|
|
74
99
|
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
100
|
if (mode === 'manual') graph.nodes.forEach(node => positions.set(node.id, { x: node.position?.x ?? bounds.x, y: node.position?.y ?? bounds.y }));
|
|
101
|
+
else if (graph.spec.diagram.mode === 'mindmap') {
|
|
102
|
+
const roots = graph.nodes.filter(node => !graph.incoming.get(node.id).length), children = new Map(graph.nodes.map(node => [node.id, []]));
|
|
103
|
+
graph.edges.forEach(edge => { if (children.has(edge.from)) children.get(edge.from).push(edge.to); });
|
|
104
|
+
const ordered = [], visit = (id, depth = 0) => { ordered.push({ id, depth }); (children.get(id) || []).forEach(child => visit(child, depth + 1)); };
|
|
105
|
+
roots.forEach(root => visit(root.id));
|
|
106
|
+
graph.nodes.filter(node => !ordered.some(item => item.id === node.id)).forEach(node => visit(node.id));
|
|
107
|
+
if (mode === 'radial') {
|
|
108
|
+
const center = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }, levels = new Map();
|
|
109
|
+
ordered.forEach(item => { if (!levels.has(item.depth)) levels.set(item.depth, []); levels.get(item.depth).push(item); });
|
|
110
|
+
levels.forEach((items, depth) => items.forEach((item, index) => { const angle = (index / Math.max(1, items.length)) * Math.PI * 2 - Math.PI / 2, radiusX = depth ? bounds.width * 0.38 * Math.min(1, depth / Math.max(1, levels.size - 1)) : 0, radiusY = depth ? bounds.height * 0.36 * Math.min(1, depth / Math.max(1, levels.size - 1)) : 0; positions.set(item.id, { x: center.x + Math.cos(angle) * radiusX, y: center.y + Math.sin(angle) * radiusY }); }));
|
|
111
|
+
} else {
|
|
112
|
+
const levels = new Map(); ordered.forEach(item => { if (!levels.has(item.depth)) levels.set(item.depth, []); levels.get(item.depth).push(item); });
|
|
113
|
+
levels.forEach((items, depth) => items.forEach((item, index) => positions.set(item.id, { x: bounds.x + depth * Math.max(160, bounds.width / Math.max(1, levels.size)), y: bounds.y + (index + 0.5) * bounds.height / Math.max(1, items.length) })));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
76
116
|
else {
|
|
77
117
|
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
118
|
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); });
|
|
@@ -85,48 +125,121 @@ export function layoutDiagram(spec, bounds = { x: 0, y: 0, width: 640, height: 3
|
|
|
85
125
|
return Object.fromEntries(positions);
|
|
86
126
|
}
|
|
87
127
|
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
128
|
+
function preferredSide(source, target, sourcePoint) {
|
|
129
|
+
const sourceCenter = { x: source.x + source.width / 2, y: source.y + source.height / 2 };
|
|
130
|
+
const targetCenter = { x: target.x + target.width / 2, y: target.y + target.height / 2 };
|
|
131
|
+
const deltaX = targetCenter.x - sourceCenter.x, deltaY = targetCenter.y - sourceCenter.y;
|
|
132
|
+
if (Math.abs(deltaX) >= Math.abs(deltaY)) return sourcePoint ? (deltaX >= 0 ? 'right' : 'left') : (deltaX >= 0 ? 'left' : 'right');
|
|
133
|
+
return sourcePoint ? (deltaY >= 0 ? 'bottom' : 'top') : (deltaY >= 0 ? 'top' : 'bottom');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pointFor(box, port, fallbackSide) {
|
|
137
|
+
if (!port) {
|
|
138
|
+
if (fallbackSide === 'left') return { x: box.x, y: box.y + box.height / 2 };
|
|
139
|
+
if (fallbackSide === 'top') return { x: box.x + box.width / 2, y: box.y };
|
|
140
|
+
if (fallbackSide === 'bottom') return { x: box.x + box.width / 2, y: box.y + box.height };
|
|
141
|
+
return { x: box.x + box.width, y: box.y + box.height / 2 };
|
|
142
|
+
}
|
|
91
143
|
const side = port.side || fallbackSide, offset = Math.max(0, Math.min(1, Number(port.offset ?? 0.5)));
|
|
92
144
|
if (side === 'left') return { x: box.x, y: box.y + box.height * offset };
|
|
93
145
|
if (side === 'top') return { x: box.x + box.width * offset, y: box.y };
|
|
94
146
|
if (side === 'bottom') return { x: box.x + box.width * offset, y: box.y + box.height };
|
|
95
147
|
return { x: box.x + box.width, y: box.y + box.height * offset };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function diagramConnectionPoints(from, to, fromPort, toPort) {
|
|
151
|
+
return {
|
|
152
|
+
start: pointFor(from, fromPort, preferredSide(from, to, true)),
|
|
153
|
+
end: pointFor(to, toPort, preferredSide(from, to, false))
|
|
96
154
|
};
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function routeEdgePath(edge, from, to, mode = 'orthogonal') {
|
|
158
|
+
const initial = diagramConnectionPoints(from, to, edge.fromPortDefinition, edge.toPortDefinition), startSide = edge.fromPortDefinition?.side || preferredSide(from, to, true), endSide = edge.toPortDefinition?.side || preferredSide(from, to, false);
|
|
100
159
|
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
|
|
102
|
-
const
|
|
160
|
+
const compact = points => points.filter((point, index) => !index || point.x !== points[index - 1].x || point.y !== points[index - 1].y);
|
|
161
|
+
const pointInside = (point, box) => point.x > box.x && point.x < box.x + box.width && point.y > box.y && point.y < box.y + box.height;
|
|
162
|
+
const orientation = (first, second, third) => Math.sign((second.y - first.y) * (third.x - second.x) - (second.x - first.x) * (third.y - second.y));
|
|
163
|
+
const intersects = (first, second, third, fourth) => orientation(first, second, third) !== orientation(first, second, fourth) && orientation(third, fourth, first) !== orientation(third, fourth, second);
|
|
164
|
+
const segmentIntersectsBox = (first, second, box, padding = 0) => {
|
|
103
165
|
const expanded = { x: box.x - padding, y: box.y - padding, width: box.width + padding * 2, height: box.height + padding * 2 };
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
return
|
|
166
|
+
if (pointInside(first, expanded) || pointInside(second, expanded)) return true;
|
|
167
|
+
const topLeft = { x: expanded.x, y: expanded.y }, topRight = { x: expanded.x + expanded.width, y: expanded.y }, bottomRight = { x: expanded.x + expanded.width, y: expanded.y + expanded.height }, bottomLeft = { x: expanded.x, y: expanded.y + expanded.height };
|
|
168
|
+
return [[topLeft, topRight], [topRight, bottomRight], [bottomRight, bottomLeft], [bottomLeft, topLeft]].some(([start, end]) => intersects(first, second, start, end));
|
|
107
169
|
};
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
[start,
|
|
113
|
-
[
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
170
|
+
const leavesSide = (start, next, side) => side === 'left' ? next.x < start.x : side === 'right' ? next.x > start.x : side === 'top' ? next.y < start.y : next.y > start.y;
|
|
171
|
+
const entersSide = (previous, end, side) => side === 'left' ? previous.x < end.x : side === 'right' ? previous.x > end.x : side === 'top' ? previous.y < end.y : previous.y > end.y;
|
|
172
|
+
const waypoints = Array.isArray(edge.waypoints) ? edge.waypoints.filter(point => Number.isFinite(point?.x) && Number.isFinite(point?.y)).map(point => ({ x: point.x, y: point.y })) : [];
|
|
173
|
+
if (waypoints.length) {
|
|
174
|
+
const manual = compact([initial.start, ...waypoints, initial.end]), padding = Math.max(4, Number(edge.grid) || 8) / 2;
|
|
175
|
+
if (leavesSide(manual[0], manual[1], startSide) && entersSide(manual.at(-2), manual.at(-1), endSide) && manual.every((point, index) => index === 0 || obstacles.every(box => !segmentIntersectsBox(manual[index - 1], point, box, padding)))) return { points: manual, curve: null };
|
|
176
|
+
}
|
|
177
|
+
if (mode === 'straight') return { points: [initial.start, initial.end], curve: null };
|
|
178
|
+
if (mode === 'curved') {
|
|
179
|
+
const tension = Math.max(0.2, Math.min(0.8, Number(edge.curveTension) || 0.4)), distance = Math.hypot(initial.end.x - initial.start.x, initial.end.y - initial.start.y), bend = Math.max(24, distance * tension);
|
|
180
|
+
const direction = side => side === 'left' ? { x: -1, y: 0 } : side === 'right' ? { x: 1, y: 0 } : side === 'top' ? { x: 0, y: -1 } : { x: 0, y: 1 };
|
|
181
|
+
const startDirection = direction(startSide), endDirection = direction(endSide);
|
|
182
|
+
const points = [initial.start, { x: initial.start.x + startDirection.x * bend, y: initial.start.y + startDirection.y * bend }, { x: initial.end.x + endDirection.x * bend, y: initial.end.y + endDirection.y * bend }, initial.end];
|
|
183
|
+
const samples = sampleCubicBezier(points, 24), padding = Math.max(4, Number(edge.grid) || 8) / 2;
|
|
184
|
+
if (samples.every((point, index) => index === 0 || obstacles.every(box => !segmentIntersectsBox(samples[index - 1], point, box, padding)))) return { points, curve: 'cubic' };
|
|
185
|
+
return routeEdgePath({ ...edge, waypoints: [] }, from, to, 'orthogonal');
|
|
186
|
+
}
|
|
187
|
+
const sidePairs = edge.fromPortDefinition || edge.toPortDefinition ? [[startSide, endSide]] : [[startSide, endSide], ...['right', 'left', 'top', 'bottom'].flatMap(fromSide => ['right', 'left', 'top', 'bottom'].map(toSide => [fromSide, toSide]))].filter((pair, index, pairs) => pairs.findIndex(item => item[0] === pair[0] && item[1] === pair[1]) === index);
|
|
188
|
+
const routeForSides = (fromSide, toSide) => {
|
|
189
|
+
const fromPort = edge.fromPortDefinition || { side: fromSide }, toPort = edge.toPortDefinition || { side: toSide }, { start, end } = diagramConnectionPoints(from, to, fromPort, toPort), padding = Math.max(12, Number(edge.grid) || 8);
|
|
190
|
+
const segmentIntersects = (first, second, box) => {
|
|
191
|
+
const expanded = { x: box.x - padding, y: box.y - padding, width: box.width + padding * 2, height: box.height + padding * 2 };
|
|
192
|
+
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;
|
|
193
|
+
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;
|
|
194
|
+
return false;
|
|
195
|
+
};
|
|
196
|
+
const leavesFromSide = points => {
|
|
197
|
+
if (points.length < 2) return false;
|
|
198
|
+
const next = points[1];
|
|
199
|
+
if (fromSide === 'left') return next.x < start.x && next.y === start.y;
|
|
200
|
+
if (fromSide === 'top') return next.y < start.y && next.x === start.x;
|
|
201
|
+
if (fromSide === 'bottom') return next.y > start.y && next.x === start.x;
|
|
202
|
+
return next.x > start.x && next.y === start.y;
|
|
203
|
+
};
|
|
204
|
+
const entersToSide = points => {
|
|
205
|
+
if (points.length < 2) return false;
|
|
206
|
+
const previous = points.at(-2);
|
|
207
|
+
if (toSide === 'left') return previous.x < end.x && previous.y === end.y;
|
|
208
|
+
if (toSide === 'top') return previous.y < end.y && previous.x === end.x;
|
|
209
|
+
if (toSide === 'bottom') return previous.y > end.y && previous.x === end.x;
|
|
210
|
+
return previous.x > end.x && previous.y === end.y;
|
|
211
|
+
};
|
|
212
|
+
const connectionAccess = points => leavesFromSide(points) && entersToSide(points);
|
|
213
|
+
const validPath = points => connectionAccess(points) && points.every((point, index) => index === 0 || obstacles.every(box => !segmentIntersects(points[index - 1], point, box)));
|
|
214
|
+
const snap = value => { const grid = Number(edge.grid) || 8; return Math.round(value / grid) * grid; };
|
|
215
|
+
const middle = snap((start.x + end.x) / 2), middleY = snap((start.y + end.y) / 2);
|
|
216
|
+
const candidates = [
|
|
217
|
+
[start, { x: middle, y: start.y }, { x: middle, y: end.y }, end],
|
|
218
|
+
[start, { x: start.x, y: middleY }, { x: end.x, y: middleY }, end]
|
|
219
|
+
];
|
|
220
|
+
const xChannels = new Set([start.x, end.x, middle]), yChannels = new Set([start.y, end.y, middleY]);
|
|
221
|
+
obstacles.forEach(box => {
|
|
222
|
+
[box.x - padding, box.x + box.width + padding].forEach(x => xChannels.add(snap(x)));
|
|
223
|
+
[box.y - padding, box.y + box.height + padding].forEach(y => yChannels.add(snap(y)));
|
|
224
|
+
});
|
|
225
|
+
xChannels.forEach(x => candidates.push([start, { x, y: start.y }, { x, y: end.y }, end]));
|
|
226
|
+
yChannels.forEach(y => candidates.push([start, { x: start.x, y }, { x: end.x, y }, end]));
|
|
227
|
+
xChannels.forEach(x => yChannels.forEach(y => {
|
|
228
|
+
candidates.push([start, { x, y: start.y }, { x, y }, { x, y: end.y }, end]);
|
|
229
|
+
candidates.push([start, { x: start.x, y }, { x, y }, { x: end.x, y }, end]);
|
|
230
|
+
}));
|
|
231
|
+
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);
|
|
232
|
+
return candidates.map(compact).filter(validPath).sort((left, right) => length(left) - length(right) || left.length - right.length || JSON.stringify(left).localeCompare(JSON.stringify(right)))[0] || null;
|
|
233
|
+
};
|
|
234
|
+
for (const [fromSide, toSide] of sidePairs) {
|
|
235
|
+
const route = routeForSides(fromSide, toSide);
|
|
236
|
+
if (route) return { points: route, curve: null };
|
|
237
|
+
}
|
|
238
|
+
return { points: [initial.start, initial.end], curve: null };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function routeEdge(edge, from, to, mode = 'orthogonal') {
|
|
242
|
+
return routeEdgePath(edge, from, to, mode).points;
|
|
130
243
|
}
|
|
131
244
|
|
|
132
245
|
export function normalizeDiagramData(spec) {
|
package/src/edit-controller.mjs
CHANGED
|
@@ -27,11 +27,11 @@ export class EditController {
|
|
|
27
27
|
|
|
28
28
|
targetEntries(command) {
|
|
29
29
|
const spec = this.chart.spec;
|
|
30
|
-
const diagram = ['flow', 'swimlane'].includes(spec.type);
|
|
30
|
+
const diagram = ['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type);
|
|
31
31
|
if (!diagram) return [{ field: 'values', root: false }];
|
|
32
32
|
const operations = command.operations || [];
|
|
33
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');
|
|
34
|
+
const wantsEdges = operations.some(operation => ['updateEdge', 'removeEdge', 'addEdge', 'duplicateSelection', 'pasteSelection', 'duplicateGroup'].includes(operation?.op) || operation?.op === 'deleteGroup' && operation.policy === 'delete-members');
|
|
35
35
|
const wantsNodes = operations.some(operation => ['moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'duplicateSelection', 'pasteSelection'].includes(operation?.op));
|
|
36
36
|
const entries = [];
|
|
37
37
|
if (wantsNodes || !wantsEdges && !wantsGroups) entries.push({ field: 'nodes', root: spec.nodes !== undefined });
|
|
@@ -42,7 +42,7 @@ export class EditController {
|
|
|
42
42
|
|
|
43
43
|
context(command) {
|
|
44
44
|
const spec = this.chart.spec;
|
|
45
|
-
const diagram = ['flow', 'swimlane'].includes(spec.type);
|
|
45
|
+
const diagram = ['flow', 'swimlane', 'architecture', 'mindmap'].includes(spec.type);
|
|
46
46
|
const entries = this.targetEntries(command);
|
|
47
47
|
const primary = entries[0];
|
|
48
48
|
const nodes = spec.nodes ?? spec.data.nodes ?? [];
|
|
@@ -58,6 +58,9 @@ export class EditController {
|
|
|
58
58
|
nodeSchema: spec.data.schema ?? spec.schema,
|
|
59
59
|
schema: primary?.field === 'edges' ? spec.data.edgeSchema : spec.data.schema ?? spec.schema,
|
|
60
60
|
edgeSchema: spec.data.edgeSchema,
|
|
61
|
+
type: spec.type,
|
|
62
|
+
nodeModel: spec.type === 'architecture' ? 'architecture-node' : spec.type === 'mindmap' ? 'mindmap-node' : 'flow-node',
|
|
63
|
+
edgeModel: spec.type === 'architecture' ? 'architecture-edge' : 'flow-edge',
|
|
61
64
|
validationOptions: { ...spec.validationOptions, references: { ...spec.validationOptions?.references, ...(diagram ? { 'flow-node': nodes.map(row => row.id), swimlane: lanes.map(row => row.id) } : {}) } },
|
|
62
65
|
requireConfirmation: spec.editing?.requireConfirmation,
|
|
63
66
|
grid: spec.diagram?.grid || 8,
|
package/src/edit.mjs
CHANGED
|
@@ -11,11 +11,11 @@ const day = 86400000;
|
|
|
11
11
|
const plusDays = (value, days) => { const date = new Date(`${value}T00:00:00.000Z`); date.setUTCDate(date.getUTCDate() + days); return date.toISOString().slice(0, 10); };
|
|
12
12
|
const equality = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
13
13
|
const recordId = operation => operation.recordId ?? operation.taskId ?? operation.nodeId ?? operation.edgeId;
|
|
14
|
-
const diagramOperationTypes = new Set(['moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'toggleGroupCollapse', 'addEdge', 'duplicateSelection', 'pasteSelection']);
|
|
15
|
-
const structureOperationTypes = new Set(['assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'toggleGroupCollapse', 'addEdge', 'duplicateSelection', 'pasteSelection']);
|
|
14
|
+
const diagramOperationTypes = new Set(['moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'toggleGroupCollapse', 'addEdge', 'duplicateSelection', 'pasteSelection']);
|
|
15
|
+
const structureOperationTypes = new Set(['assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'removeEdge', 'toggleGroupCollapse', 'addEdge', 'duplicateSelection', 'pasteSelection']);
|
|
16
16
|
|
|
17
17
|
export function businessModelForType(type) {
|
|
18
|
-
return { gantt: 'project-task', timeline: 'timeline-event', milestone: 'milestone', burndown: 'burndown-sample', flow: 'flow-node', swimlane: 'flow-node' }[type] || type;
|
|
18
|
+
return { gantt: 'project-task', timeline: 'timeline-event', milestone: 'milestone', burndown: 'burndown-sample', flow: 'flow-node', swimlane: 'flow-node', architecture: 'architecture-node', mindmap: 'mindmap-node' }[type] || type;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function jsonValue(value) { return value === undefined ? null : copyJSON(value); }
|
|
@@ -25,8 +25,8 @@ function uniqueId(existing, preferred) { let index = 1, value = preferred; while
|
|
|
25
25
|
function duplicateEdge(edges, edge) { return edges.some(item => item.from === edge.from && item.to === edge.to && (item.fromPort || null) === (edge.fromPort || null) && (item.toPort || null) === (edge.toPort || null)); }
|
|
26
26
|
|
|
27
27
|
function previewDiagramEdit(command, options = {}) {
|
|
28
|
-
const nodeSchemaResult = validateDataSchema(options.nodeSchema || options.schema || getBusinessSchema(
|
|
29
|
-
const edgeSchemaResult = validateDataSchema(options.edgeSchema || getBusinessSchema('flow-edge'));
|
|
28
|
+
const nodeSchemaResult = validateDataSchema(options.nodeSchema || options.schema || getBusinessSchema(options.nodeModel || businessModelForType(options.type)));
|
|
29
|
+
const edgeSchemaResult = validateDataSchema(options.edgeSchema || getBusinessSchema(options.edgeModel || (options.type === 'architecture' ? 'architecture-edge' : 'flow-edge')));
|
|
30
30
|
const errors = [...nodeSchemaResult.errors, ...edgeSchemaResult.errors], warnings = [], changes = [], patches = [], affected = new Set();
|
|
31
31
|
const beforeNodes = copyJSON(options.nodes || options.values || []), beforeEdges = copyJSON(options.edges || []), beforeGroups = copyJSON(options.groups || []), lanes = copyJSON(options.lanes || []);
|
|
32
32
|
if (!nodeSchemaResult.valid || !edgeSchemaResult.valid) return result({ valid: false, errors, command, changes, patches, affectedRecords: [], warnings, requiresConfirmation: false });
|
|
@@ -181,6 +181,16 @@ function previewDiagramEdit(command, options = {}) {
|
|
|
181
181
|
return;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
if (operation.op === 'removeEdge') {
|
|
185
|
+
const hit = edgeIndex().get(recordId(operation));
|
|
186
|
+
if (!hit) { errors.push(issue('RECORD_NOT_FOUND', `command.operations.${opIndex}`, `Record ${recordId(operation)} was not found.`)); return; }
|
|
187
|
+
const removed = edges.splice(hit.rowIndex, 1)[0];
|
|
188
|
+
affected.add(removed.id || `edge-${hit.rowIndex}`);
|
|
189
|
+
changes.push(change(removed, undefined, `edges.${hit.rowIndex}`, operation));
|
|
190
|
+
patches.push({ op: 'remove', path: `/edges/${hit.rowIndex}` });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
184
194
|
if (operation.op === 'addEdge') {
|
|
185
195
|
const edge = {
|
|
186
196
|
id: operation.id || nextId(edges, 'edge'),
|
|
@@ -265,7 +275,7 @@ function previewDiagramEdit(command, options = {}) {
|
|
|
265
275
|
const edgeValidation = validateData(edges, edgeSchemaResult.schema, { ...options.validationOptions, references: { ...options.validationOptions?.references, 'flow-node': nodeValidation.rows.map(node => node.id) } });
|
|
266
276
|
errors.push(...nodeValidation.errors, ...edgeValidation.errors);
|
|
267
277
|
warnings.push(...nodeValidation.warnings, ...edgeValidation.warnings);
|
|
268
|
-
const diagramSpec = { type: 'flow', nodes: nodeValidation.rows, edges: edgeValidation.rows, groups, lanes, ...(options.diagram ? { diagram: options.diagram } : {}) };
|
|
278
|
+
const diagramSpec = { type: options.type || 'flow', nodes: nodeValidation.rows, edges: edgeValidation.rows, groups, lanes, ...(options.diagram ? { diagram: options.diagram } : {}) };
|
|
269
279
|
const diagramValidation = validateDiagram(diagramSpec);
|
|
270
280
|
errors.push(...diagramValidation.errors.map(error => issue(error.code, error.path, error.message, error.suggestion)));
|
|
271
281
|
|
|
@@ -274,7 +284,7 @@ function previewDiagramEdit(command, options = {}) {
|
|
|
274
284
|
if (!equality(beforeEdges, edgeValidation.rows)) targets.edges = edgeValidation.rows;
|
|
275
285
|
if (!equality(beforeGroups, groups)) targets.groups = groups;
|
|
276
286
|
if (errors.length) return result({ valid: false, errors, command, changes: [], patches: [], affectedRecords: [...affected], warnings, requiresConfirmation: false, before: { nodes: beforeNodes, edges: beforeEdges, groups: beforeGroups }, after: { nodes: beforeNodes, edges: beforeEdges, groups: beforeGroups }, targets: {} });
|
|
277
|
-
const requiresConfirmation = options.requireConfirmation !== false || command.operations.some(operation => structureOperationTypes.has(operation.op)
|
|
287
|
+
const requiresConfirmation = options.requireConfirmation !== false || command.operations.some(operation => structureOperationTypes.has(operation.op));
|
|
278
288
|
return result({
|
|
279
289
|
valid: true,
|
|
280
290
|
errors: [],
|