@taylorwong/ichartjs 2.0.6 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +2 -2
  3. package/agent-recipes/minimal-specs.json +25 -0
  4. package/docs/agent/README.md +3 -1
  5. package/docs/agent/charting-scenario.md +46 -1
  6. package/docs/agent/coding-agent-integration.md +1 -1
  7. package/docs/agent/development/iteration-12.md +101 -0
  8. package/docs/agent/development/roadmap.md +2 -1
  9. package/docs/agent/development-guide.md +1 -1
  10. package/docs/agent/diagram-scenario.md +39 -11
  11. package/docs/agent/editing-contract.md +5 -0
  12. package/docs/agent/frontend-integration.md +1 -1
  13. package/docs/agent/project-scenario.md +1 -0
  14. package/docs/agent/quickstart.md +30 -0
  15. package/docs/agent/runtime-contract.md +19 -2
  16. package/docs/agent/theme-guide.md +33 -1
  17. package/docs/agent/usage-scenarios.md +3 -1
  18. package/docs/agent/zh-CN/README.md +1 -1
  19. package/docs/agent/zh-CN/charting-scenario.md +31 -1
  20. package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
  21. package/docs/agent/zh-CN/diagram-scenario.md +32 -3
  22. package/docs/agent/zh-CN/editing-contract.md +2 -0
  23. package/docs/agent/zh-CN/frontend-integration.md +1 -1
  24. package/docs/agent/zh-CN/iteration-12.md +98 -0
  25. package/docs/agent/zh-CN/quickstart.md +30 -0
  26. package/docs/agent/zh-CN/runtime-contract.md +7 -1
  27. package/docs/agent/zh-CN/theme-guide.md +33 -1
  28. package/docs/agent/zh-CN/usage-scenarios.md +3 -1
  29. package/docs/manifests/capabilities.json +42 -3
  30. package/docs/manifests/commands.json +10 -9
  31. package/docs/manifests/schemas.json +5 -2
  32. package/examples/agent-workflow.mjs +2 -1
  33. package/package.json +1 -1
  34. package/skills/ichartjs/SKILL.md +19 -9
  35. package/skills/ichartjs/references/agent-contract.md +5 -2
  36. package/skills/ichartjs/references/chart-selection.md +2 -0
  37. package/src/capabilities.mjs +83 -13
  38. package/src/charts.mjs +202 -58
  39. package/src/command.mjs +4 -3
  40. package/src/diagram-interaction.mjs +55 -11
  41. package/src/diagram.mjs +149 -36
  42. package/src/edit-controller.mjs +6 -3
  43. package/src/edit.mjs +17 -7
  44. package/src/index.mjs +44 -17
  45. package/src/layout.mjs +39 -0
  46. package/src/preferences-ui.mjs +73 -24
  47. package/src/project.mjs +64 -24
  48. package/src/renderer.mjs +2 -2
  49. package/src/scene.mjs +32 -1
  50. package/src/schema.mjs +7 -2
  51. package/src/spec.mjs +88 -6
  52. package/src/theme.mjs +1 -1
  53. package/types/index.d.ts +28 -7
@@ -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
- 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);
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 (groupId && !nodeId) chart.selectGroup(groupId, { additive });
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 edges = copyJSON(spec.edges ?? data.edges ?? []);
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
- 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 } };
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
- 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 };
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
- 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]; }
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 padding = Math.max(12, Number(edge.grid) || 8);
102
- const segmentIntersects = (first, second, box) => {
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 (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;
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 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]);
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) {
@@ -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('flow-node'));
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) || operation.op === 'updateEdge');
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: [],