@taylorwong/ichartjs 2.0.6 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +2 -2
  3. package/docs/agent/README.md +3 -1
  4. package/docs/agent/coding-agent-integration.md +1 -1
  5. package/docs/agent/development/iteration-12.md +101 -0
  6. package/docs/agent/development/roadmap.md +2 -1
  7. package/docs/agent/development-guide.md +1 -1
  8. package/docs/agent/diagram-scenario.md +39 -11
  9. package/docs/agent/editing-contract.md +5 -0
  10. package/docs/agent/frontend-integration.md +1 -1
  11. package/docs/agent/runtime-contract.md +11 -1
  12. package/docs/agent/theme-guide.md +33 -1
  13. package/docs/agent/usage-scenarios.md +3 -1
  14. package/docs/agent/zh-CN/README.md +1 -1
  15. package/docs/agent/zh-CN/coding-agent-integration.md +1 -1
  16. package/docs/agent/zh-CN/diagram-scenario.md +32 -3
  17. package/docs/agent/zh-CN/editing-contract.md +2 -0
  18. package/docs/agent/zh-CN/frontend-integration.md +1 -1
  19. package/docs/agent/zh-CN/iteration-12.md +98 -0
  20. package/docs/agent/zh-CN/runtime-contract.md +3 -1
  21. package/docs/agent/zh-CN/theme-guide.md +33 -1
  22. package/docs/agent/zh-CN/usage-scenarios.md +3 -1
  23. package/docs/manifests/capabilities.json +30 -3
  24. package/docs/manifests/commands.json +10 -9
  25. package/docs/manifests/schemas.json +5 -2
  26. package/package.json +1 -1
  27. package/skills/ichartjs/SKILL.md +2 -1
  28. package/src/capabilities.mjs +60 -12
  29. package/src/charts.mjs +127 -44
  30. package/src/command.mjs +4 -3
  31. package/src/diagram-interaction.mjs +55 -11
  32. package/src/diagram.mjs +149 -36
  33. package/src/edit-controller.mjs +6 -3
  34. package/src/edit.mjs +17 -7
  35. package/src/index.mjs +34 -15
  36. package/src/layout.mjs +39 -0
  37. package/src/preferences-ui.mjs +73 -24
  38. package/src/project.mjs +64 -24
  39. package/src/renderer.mjs +2 -2
  40. package/src/scene.mjs +32 -1
  41. package/src/schema.mjs +7 -2
  42. package/src/spec.mjs +10 -4
  43. package/src/theme.mjs +1 -1
  44. package/types/index.d.ts +22 -4
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: [],
package/src/index.mjs CHANGED
@@ -16,11 +16,11 @@ import { commitPreview, getEditCapabilities, previewEdit, validateEdit } from '.
16
16
  import { EditHistory } from './history.mjs';
17
17
  import { validateRecipe } from './recipes.mjs';
18
18
  import { EditController } from './edit-controller.mjs';
19
- import { diagramLayoutModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge } from './diagram.mjs';
19
+ import { diagramLayoutModes, diagramModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge } from './diagram.mjs';
20
20
  import { normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries } from './project-analytics.mjs';
21
21
  import { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId } from './project-linking.mjs';
22
- import { explainChart, getCapabilities as discoverCapabilities, getChartCapability, planChart } from './capabilities.mjs';
23
- import { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, normalizePreferences } from './preferences.mjs';
22
+ import { explainChart, getCapabilities as discoverCapabilities, getChartCapability, getPreferenceCapabilities, planChart } from './capabilities.mjs';
23
+ import { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, normalizePreferences, validatePreferences } from './preferences.mjs';
24
24
  import { mountChartSettings } from './preferences-ui.mjs';
25
25
 
26
26
  import { isDiagram, paintSelection, diagramPointer, diagramKeyboard } from './diagram-interaction.mjs';
@@ -101,10 +101,13 @@ function sceneToSvgString(scene, spec = {}) {
101
101
  }
102
102
  attrs.push(`d="${_svgEscape(d, 'attr')}"`);
103
103
  } else if (node.type === 'path') {
104
- const d = `${(g.points || []).map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`;
104
+ const d = g.curve === 'cubic' && g.points?.length === 4
105
+ ? `M ${g.points[0].x} ${g.points[0].y} C ${g.points[1].x} ${g.points[1].y} ${g.points[2].x} ${g.points[2].y} ${g.points[3].x} ${g.points[3].y}`
106
+ : `${(g.points || []).map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`;
105
107
  attrs.push(`d="${_svgEscape(d, 'attr')}"`);
106
108
  } else if (node.type === 'text') {
107
109
  attrs.push(`x="${g.x}" y="${g.y}"`);
110
+ if (Number.isFinite(Number(s.rotation)) && Number(s.rotation) !== 0) attrs.push(`transform="rotate(${Number(s.rotation)} ${g.x} ${g.y})"`);
108
111
  const anchor = s.textAnchor || (s.textAlign === 'end' ? 'end' : s.textAlign === 'center' ? 'middle' : s.textAlign === 'right' ? 'end' : s.textAlign === 'left' ? 'start' : 'start');
109
112
  if (anchor) attrs.push(`text-anchor="${anchor}"`);
110
113
  const baseline = s.textBaseline || s.baseline || 'alphabetic';
@@ -195,7 +198,7 @@ export class Chart {
195
198
  delete result.spec.preferencesStore;
196
199
  delete result.spec.chartId;
197
200
  this._themeInput = input.theme ?? 'auto';
198
- this._styleOverrides = { colors: input.colors !== undefined, background: input.background !== undefined, padding: input.padding !== undefined };
201
+ this._styleOverrides = { colors: input.colors !== undefined, background: input.background !== undefined, padding: input.padding !== undefined, legend: false, labels: false, grid: false, branding: false };
199
202
  this.spec = result.spec;
200
203
  this._preferenceBase = Object.fromEntries(['legend', 'labels', 'grid', 'branding', 'padding'].map(key => [key, clone(this.spec[key])]));
201
204
  this._resolveStyle();
@@ -232,6 +235,7 @@ export class Chart {
232
235
  if (!this._styleOverrides.background) this.spec.background = this.spec.theme.background;
233
236
  if (!this._styleOverrides.padding) this.spec.padding = clone(this.spec.theme.layout.padding);
234
237
  this.spec = applyPreferencesToSpec(this.spec, preferences);
238
+ ['legend', 'labels', 'grid', 'branding'].forEach(key => { if (this._styleOverrides[key]) this.spec[key] = clone(this._preferenceBase[key]); });
235
239
  }
236
240
  _observeColorScheme() {
237
241
  if (typeof matchMedia !== 'function') return;
@@ -265,9 +269,13 @@ export class Chart {
265
269
  element.setAttribute('role', 'group');
266
270
  element.setAttribute('aria-label', `Group ${dataRef.groupId}${dataRef.collapsed ? ', collapsed' : ', expanded'}`);
267
271
  element.setAttribute('aria-expanded', String(!dataRef.collapsed));
272
+ } else if (dataRef.edgeHandle) {
273
+ element.setAttribute('role', 'button');
274
+ element.setAttribute('aria-label', `${dataRef.edgeHandle === 'segment' ? 'Segment' : 'Waypoint'} handle for edge ${dataRef.edgeId}`);
268
275
  } else if (dataRef.edgeId || dataRef.from && dataRef.to) {
269
276
  element.setAttribute('role', 'img');
270
277
  element.setAttribute('aria-label', `Edge from ${dataRef.from} to ${dataRef.to}`);
278
+ element.setAttribute('aria-selected', String(this.getSelectedEdgeIds().includes(dataRef.edgeId)));
271
279
  if (dataRef.from) element.setAttribute('aria-describedby', `node-${dataRef.from}`);
272
280
  } else if (dataRef.nodeId && !dataRef.portId) {
273
281
  element.setAttribute('role', 'button');
@@ -288,7 +296,7 @@ export class Chart {
288
296
  const ids = [];
289
297
  this.model.scene.walk(node => { if (node.interactive && node.dataRef) ids.push(node.id); });
290
298
  if (!ids.length) return;
291
- if (['flow', 'swimlane'].includes(this.spec.type) && this.spec.editing?.enabled && this._selected.size) {
299
+ if (isDiagram(this) && this.spec.editing?.enabled && this._selected.size) {
292
300
  const delta = { x: ['ArrowRight', 'ArrowLeft'].includes(event.key) ? (event.key === 'ArrowRight' ? 8 : -8) : 0, y: ['ArrowDown', 'ArrowUp'].includes(event.key) ? (event.key === 'ArrowDown' ? 8 : -8) : 0 };
293
301
  const result = this.moveSelectedBy(delta, { confirmed: true, source: 'keyboard' });
294
302
  if (result.valid) event.preventDefault();
@@ -318,16 +326,16 @@ export class Chart {
318
326
  const touchEnd = () => { this._touchStart = null; this._pinchStart = null; };
319
327
  const wheel = event => { if (!this.spec.interaction.zoom) return; event.preventDefault(); if (isDiagram(this)) { this.zoomTo({ scale: Math.max(0.25, Math.min(8, (this.spec.view?.scale || 1) * (event.deltaY > 0 ? 0.9 : 1.1))) }); return; } const count = normalizeData(this.spec.data).rows.length, current = this.spec.view || { start: 0, end: count }; this.zoomTo(resolveZoomWindow(count, current, event.deltaY > 0 ? 1.25 : 0.8)); };
320
328
  const leave = event => { this._hideTooltip(); if (!this._dragStart && !this._nodeDrag) end(event); };
321
- if (isDiagram(this)) target.style.touchAction = 'none'; target.addEventListener('mousemove', handler); target.addEventListener('click', handler); target.addEventListener('pointerdown', start); target.addEventListener('pointermove', move); target.addEventListener('pointerup', end); target.addEventListener('pointercancel', end); target.addEventListener('pointerleave', leave); target.addEventListener('touchstart', touchStart, { passive: false }); target.addEventListener('touchmove', touchMove, { passive: false }); target.addEventListener('touchend', touchEnd); target.addEventListener('wheel', wheel, { passive: false }); this._eventsBound = { target, handler, start, move, end, leave, touchStart, touchMove, touchEnd, wheel }; }
329
+ if (isDiagram(this)) target.style.touchAction = ['zoom', 'pan', 'brush', 'drag', 'edgeDrag', 'portConnect'].some(name => this.spec.interaction?.[name]) ? 'none' : 'auto'; target.addEventListener('mousemove', handler); target.addEventListener('click', handler); target.addEventListener('pointerdown', start); target.addEventListener('pointermove', move); target.addEventListener('pointerup', end); target.addEventListener('pointercancel', end); target.addEventListener('pointerleave', leave); target.addEventListener('touchstart', touchStart, { passive: false }); target.addEventListener('touchmove', touchMove, { passive: false }); target.addEventListener('touchend', touchEnd); target.addEventListener('wheel', wheel, { passive: false }); this._eventsBound = { target, handler, start, move, end, leave, touchStart, touchMove, touchEnd, wheel }; }
322
330
  _touchDistance(event) { if (!event.touches || event.touches.length < 2) return null; const [first, second] = event.touches; return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY); }
323
331
  _eventPoint(event, target) { const rect = target.getBoundingClientRect(); return { x: (event.clientX - rect.left) * this.spec.width / (rect.width || this.spec.width), y: (event.clientY - rect.top) * this.spec.height / (rect.height || this.spec.height) }; }
324
332
  _updateCrosshair(x, y) { const vertical = this.model.scene.find('crosshair-x'), horizontal = this.model.scene.find('crosshair-y'); if (!vertical || !horizontal) return; vertical.geometry.x1 = vertical.geometry.x2 = x; vertical.style.opacity = 0.7; horizontal.geometry.y1 = horizontal.geometry.y2 = y; horizontal.style.opacity = 0.7; this.renderer.render(this.model.scene); }
325
- _showTooltip(payload) { if (typeof document === 'undefined' || !this.container || !payload.datum) return; let tip = this._tooltip; if (!tip) { tip = this._tooltip = document.createElement('div'); tip.className = 'ichart-v2-tooltip'; Object.assign(tip.style, { position: 'fixed', pointerEvents: 'none', zIndex: 9999, padding: '8px 10px', borderRadius: '6px', maxWidth: 'min(320px, calc(100vw - 24px))', whiteSpace: 'pre-line' }); document.body.appendChild(tip); } Object.assign(tip.style, { background: this.spec.theme.surface, color: this.spec.theme.text, border: `1px solid ${this.spec.theme.border}`, font: this.spec.theme.typography.tooltip.font, boxShadow: `0 4px 12px ${this.spec.theme.border}88` }); const text = ['gantt', 'timeline', 'milestone', 'burndown'].includes(this.spec.type) || ['flow', 'swimlane'].includes(this.spec.type) ? projectTooltip(this.spec.type, payload.datum) : Object.entries(payload.datum).map(([key, value]) => `${key}: ${value}`).join(' · '); tip.textContent = text; const left = Math.min((payload.nativeEvent.clientX || 0) + 12, window.innerWidth - tip.offsetWidth - 12); const top = Math.min((payload.nativeEvent.clientY || 0) + 12, window.innerHeight - tip.offsetHeight - 12); tip.style.left = `${Math.max(12, left)}px`; tip.style.top = `${Math.max(12, top)}px`; }
333
+ _showTooltip(payload) { if (typeof document === 'undefined' || !this.container || !payload.datum) return; let tip = this._tooltip; if (!tip) { tip = this._tooltip = document.createElement('div'); tip.className = 'ichart-v2-tooltip'; Object.assign(tip.style, { position: 'fixed', pointerEvents: 'none', zIndex: 9999, padding: '8px 10px', borderRadius: '6px', maxWidth: 'min(320px, calc(100vw - 24px))', whiteSpace: 'pre-line' }); document.body.appendChild(tip); } Object.assign(tip.style, { background: this.spec.theme.surface, color: this.spec.theme.text, border: `1px solid ${this.spec.theme.border}`, font: this.spec.theme.typography.tooltip.font, boxShadow: `0 4px 12px ${this.spec.theme.border}88` }); const text = ['gantt', 'timeline', 'milestone', 'burndown', 'flow', 'swimlane', 'architecture', 'mindmap'].includes(this.spec.type) ? projectTooltip(this.spec.type, payload.datum) : Object.entries(payload.datum).map(([key, value]) => `${key}: ${value}`).join(' · '); tip.textContent = text; const left = Math.min((payload.nativeEvent.clientX || 0) + 12, window.innerWidth - tip.offsetWidth - 12); const top = Math.min((payload.nativeEvent.clientY || 0) + 12, window.innerHeight - tip.offsetHeight - 12); tip.style.left = `${Math.max(12, left)}px`; tip.style.top = `${Math.max(12, top)}px`; }
326
334
  _hideTooltip() { if (this._tooltip) this._tooltip.style.left = '-10000px'; }
327
335
  on(type, listener) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(listener); return this; }
328
336
  off(type, listener) { this.listeners.get(type)?.delete(listener); return this; }
329
337
  emit(type, event) { this.listeners.get(type)?.forEach(listener => listener(event)); }
330
- update(next = {}) { this._editor.invalidate(); const { preferences, preferencesStore, chartId, ...specUpdate } = next || {}; if (preferences !== undefined) this.setPreferences(preferences); if (specUpdate.theme !== undefined) this._themeInput = specUpdate.theme; ['colors', 'background', 'padding'].forEach(key => { if (specUpdate[key] !== undefined) this._styleOverrides[key] = true; }); this.spec = normalizeSpec({ ...this.spec, ...specUpdate, theme: this._themeInput, data: specUpdate.data === undefined ? this.spec.data : specUpdate.data }); ['legend', 'labels', 'grid', 'branding', 'padding'].forEach(key => { if (specUpdate[key] !== undefined) this._preferenceBase[key] = clone(this.spec[key]); }); this._resolveStyle(); return this.render(); }
338
+ update(next = {}) { this._editor.invalidate(); const { preferences, preferencesStore, chartId, ...specUpdate } = next || {}; if (preferences !== undefined) this.setPreferences(preferences); if (specUpdate.theme !== undefined) this._themeInput = specUpdate.theme; ['colors', 'background', 'padding', 'legend', 'labels', 'grid', 'branding'].forEach(key => { if (specUpdate[key] !== undefined) this._styleOverrides[key] = true; }); this.spec = normalizeSpec({ ...this.spec, ...specUpdate, theme: this._themeInput, data: specUpdate.data === undefined ? this.spec.data : specUpdate.data }); ['legend', 'labels', 'grid', 'branding', 'padding'].forEach(key => { if (specUpdate[key] !== undefined) this._preferenceBase[key] = clone(this.spec[key]); }); this._resolveStyle(); return this.render(); }
331
339
  setData(data) { this._editor.invalidate(); this.spec = normalizeSpec({ ...this.spec, theme: this._themeInput, data: { values: data } }); this._resolveStyle(); return this.render(); }
332
340
  setTheme(theme = 'auto') { this._themeInput = theme; this._resolveStyle(); this.render(); this.emit('themechange', { chart: this, theme: this.getTheme(), source: 'user' }); return this; }
333
341
  getTheme() { return clone(this.spec.theme); }
@@ -356,8 +364,10 @@ export class Chart {
356
364
  highlight(target) { if (target?.id) target.highlighted = true; this.render(); return this; }
357
365
  select(target, options = {}) { if (!options.additive) { this._selected.clear(); this.model?.scene.walk(node => { node.selected = false; }); } if (target?.id) { target.selected = true; this._selected.set(target.id, target.dataRef); } this.emit('selectionchange', { chart: this, target, selectedData: this.getSelectedData() }); return this; }
358
366
  selectNodes(nodeIds = [], options = {}) { if (!options.additive) { this._selected.clear(); this.model.scene.walk(node => { node.selected = false; }); } nodeIds.forEach(id => { const node = this.model.scene.find(`node-${id}`); if (node) { node.selected = true; this._selected.set(node.id, node.dataRef); } }); this.emit('selectionchange', { chart: this, target: null, selectedData: this.getSelectedData() }); this.render(); return this; }
367
+ selectEdges(edgeIds = [], options = {}) { if (!options.additive) { this._selected.clear(); this.model.scene.walk(node => { node.selected = false; }); } const selected = new Set(edgeIds); this.model.scene.walk(node => { if (node.dataRef?.edgeId && !node.dataRef?.edgeHandle && selected.has(node.dataRef.edgeId)) { node.selected = true; this._selected.set(node.id, node.dataRef); } }); this.emit('selectionchange', { chart: this, target: null, selectedData: this.getSelectedData() }); this.render(); return this; }
359
368
  selectGroup(groupId, options = {}) { return this.selectNodes(this.model.data.rows.filter(row => row.groupId === groupId).map(row => row.id), options); }
360
369
  getSelectedNodeIds() { return [...new Set([...this._selected.values()].map(ref => ref?.nodeId).filter(Boolean))]; }
370
+ getSelectedEdgeIds() { return [...new Set([...this._selected.values()].map(ref => ref?.edgeId).filter(Boolean))]; }
361
371
  getDiagramNodes() { return (this.spec.nodes || this.spec.data?.nodes || []).map(node => ({ ...node })); }
362
372
  getDiagramEdges() { return (this.spec.edges || this.spec.data?.edges || []).map(edge => ({ ...edge })); }
363
373
  getDiagramGroups() { return (this.spec.groups || this.spec.data?.groups || []).map(group => ({ ...group })); }
@@ -400,6 +410,15 @@ export class Chart {
400
410
  const preview = this.previewEdit({ type: 'layout-edit', reason: 'Connect diagram nodes', operations: [operation] });
401
411
  return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
402
412
  }
413
+ deleteSelectedEdges(options = {}) {
414
+ const edgeIds = this.getSelectedEdgeIds();
415
+ if (!edgeIds.length) return { valid: false, errors: [{ code: 'NO_SELECTION', message: 'Select one or more diagram edges first.' }] };
416
+ if (this.spec.editing?.allowDelete !== true) return { valid: false, errors: [{ code: 'DELETE_DISABLED', message: 'Edge deletion requires editing.allowDelete.' }] };
417
+ const preview = this.previewEdit({ type: 'layout-edit', reason: 'Delete selected diagram edges', operations: edgeIds.map(edgeId => ({ op: 'removeEdge', edgeId })) });
418
+ const result = this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
419
+ if (result.valid) this.clearSelection();
420
+ return result;
421
+ }
403
422
  toggleGroupCollapse(groupId, options = {}) {
404
423
  const preview = this.previewEdit({ type: 'layout-edit', reason: 'Toggle group collapse', operations: [{ op: 'toggleGroupCollapse', groupId, ...(options.collapsed !== undefined ? { collapsed: options.collapsed } : {}) }] });
405
424
  const result = this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed });
@@ -414,7 +433,7 @@ export class Chart {
414
433
  moveSelectedBy(delta, options = {}) { const nodeIds = this.getSelectedNodeIds(); if (!nodeIds.length) return { valid: false, errors: [{ code: 'NO_SELECTION', message: 'Select one or more diagram nodes first.' }] }; const preview = this.previewEdit({ type: 'layout-edit', reason: options.reason || 'Move selected nodes', operations: [{ op: 'moveNodes', nodeIds, delta }] }); return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); }
415
434
  alignSelected(alignment, options = {}) { const nodeIds = this.getSelectedNodeIds(); const preview = this.previewEdit({ type: 'layout-edit', reason: `Align selected nodes ${alignment}`, operations: [{ op: 'alignNodes', nodeIds, alignment }] }); return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); }
416
435
  snapSelected(options = {}) { const nodeIds = this.getSelectedNodeIds(); const preview = this.previewEdit({ type: 'layout-edit', reason: 'Snap selected nodes', operations: [{ op: 'snapNodes', nodeIds }] }); return this.applyEdit(preview.command, { ...options, preview, confirmed: options.confirmed }); }
417
- getSelectedData() { const diagram = ['flow', 'swimlane'].includes(this.spec.type), source = diagram ? (this.spec.nodes || this.spec.data?.nodes || []) : normalizeData(this.spec.data).rows, offset = diagram ? 0 : (this.model.data.viewOffset || 0); return [...this._selected.values()].map(ref => diagram ? source.find(row => row.id === ref?.nodeId) : (ref?.dataIndex == null ? ref?.datum : source[ref.dataIndex + offset])).filter(Boolean); }
436
+ getSelectedData() { const diagram = isDiagram(this), source = diagram ? (this.spec.nodes || this.spec.data?.nodes || []) : normalizeData(this.spec.data).rows, edges = diagram ? (this.spec.edges || this.spec.data?.edges || []) : [], offset = diagram ? 0 : (this.model.data.viewOffset || 0); return [...this._selected.values()].map(ref => diagram ? ref?.edgeId ? edges.find((row, index) => (row.id || `edge-${index}`) === ref.edgeId) : source.find(row => row.id === ref?.nodeId) : (ref?.dataIndex == null ? ref?.datum : source[ref.dataIndex + offset])).filter(Boolean); }
418
437
  clearSelection() { this._selected.clear(); this.render(); this.emit('selectionchange', { chart: this, target: null, selectedData: [] }); return this; }
419
438
  getElementAt(x, y) { return this.model.scene.hit(x, y); }
420
439
  selectBox(start, end) { const left = Math.min(start.x, end.x), right = Math.max(start.x, end.x), top = Math.min(start.y, end.y), bottom = Math.max(start.y, end.y); this._selected.clear(); this.model.scene.walk(node => { node.selected = false; if (!node.dataRef || !node.bounds || !node.interactive || (isDiagram(this) && !node.id.startsWith('node-'))) return; const intersects = node.bounds.x <= right && node.bounds.x + node.bounds.width >= left && node.bounds.y <= bottom && node.bounds.y + node.bounds.height >= top; if (intersects) { node.selected = true; this._selected.set(node.id, node.dataRef); } }); this.emit('selectionchange', { chart: this, selectedData: this.getSelectedData(), box: { start, end } }); this.render(); return this; }
@@ -602,13 +621,13 @@ export class Chart {
602
621
  }
603
622
 
604
623
  export function createChart(spec) { return new Chart(spec); }
605
- export function getCapabilities() { return { ...discoverCapabilities(), diagram: { layoutModes: diagramLayoutModes, routingModes: edgeRoutingModes, entities: ['node', 'edge', 'lane', 'group', 'port'], operations: ['moveNode', 'moveNodes', 'resizeNode', 'alignNodes', 'snapNodes', 'moveNodeToLane', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], validation: ['duplicate-ids', 'missing-endpoints', 'missing-ports', 'missing-lanes'] }, editing: { modes: ['preview', 'commit', 'undo', 'redo'], operations: ['updateField', 'updateRecord', 'updateTask', 'shiftTask', 'updateProgress', 'addDependency', 'removeDependency', 'updateMilestone', 'moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], confirmationRequiredByDefault: true, commit: 'local-runtime-host-persistence-required' } }; }
624
+ export function getCapabilities() { return { ...discoverCapabilities(), interactionDefaults: { zoom: false, pan: false, brush: false, drag: false, edgeDrag: false, portConnect: false, editing: false }, diagram: { layoutModes: diagramLayoutModes, routingModes: edgeRoutingModes, entities: ['node', 'edge', 'lane', 'group', 'port', 'waypoint'], operations: ['moveNode', 'moveNodes', 'resizeNode', 'alignNodes', 'snapNodes', 'moveNodeToLane', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], curvedEdges: { renderers: ['canvas', 'svg'], type: 'cubic-bezier', tension: { minimum: 0.2, maximum: 0.8, default: 0.4 }, mindmapDefault: true, obstacleFallback: 'orthogonal', controlPointEditing: false }, edgeEditing: { renderers: ['canvas', 'svg'], selection: true, waypointDrag: true, segmentDrag: true, persistentWaypoints: true, enabledByDefault: false, activation: ['editing.enabled', 'interaction.edgeDrag'] }, validation: ['duplicate-ids', 'missing-endpoints', 'missing-ports', 'missing-lanes', 'invalid-waypoints', 'invalid-curve-tension'] }, editing: { modes: ['preview', 'commit', 'undo', 'redo'], operations: ['updateField', 'updateRecord', 'updateTask', 'shiftTask', 'updateProgress', 'addDependency', 'removeDependency', 'updateMilestone', 'moveNode', 'moveNodes', 'moveNodeToLane', 'resizeNode', 'alignNodes', 'snapNodes', 'moveGroup', 'resizeGroup', 'assignNodesToGroup', 'duplicateGroup', 'deleteGroup', 'updateEdge', 'removeEdge', 'addEdge', 'toggleGroupCollapse', 'duplicateSelection', 'pasteSelection'], confirmationRequiredByDefault: true, commit: 'local-runtime-host-persistence-required' } }; }
606
625
  export function recommend(input, options = {}) { const plan = planChart(input, options); return { primary: plan.primary, alternatives: plan.alternatives, reason: plan.reasons.join(' '), reasons: plan.reasons, confidence: plan.confidence, requiredFields: plan.requiredFields, assumptions: plan.assumptions, warnings: plan.warnings, nextActions: plan.nextActions }; }
607
- export { normalizeSpec, validateSpec, normalizeData, inspectData, binData, applyTransforms, data, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, getChartCapability, planChart, explainChart };
608
- export { diagramLayoutModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge };
626
+ export { normalizeSpec, validateSpec, normalizeData, inspectData, binData, applyTransforms, data, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, getChartCapability, getPreferenceCapabilities, planChart, explainChart };
627
+ export { diagramLayoutModes, diagramModes, edgeRoutingModes, normalizeDiagramSpec, validateDiagram, layoutDiagram, routeEdge };
609
628
  export { normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries };
610
629
  export { normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
611
630
 
612
631
  export { contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin };
613
- export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences };
614
- export const iChart = { version: '2.0.6', createChart, inspectData, normalizeData, binData, applyTransforms, normalizeSpec, validateSpec, data, getCapabilities, getChartCapability, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, createPreferencesStore, defaultPreferences, normalizePreferences, mergePreferences, applyPreferencesToSpec, mountChartSettings, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries, normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
632
+ export { applyPreferencesToSpec, createPreferencesStore, defaultPreferences, mergePreferences, mergeThemePreference, mountChartSettings, normalizePreferences, validatePreferences };
633
+ export const iChart = { version: '2.0.7', createChart, inspectData, normalizeData, binData, applyTransforms, normalizeSpec, validateSpec, data, getCapabilities, getChartCapability, getPreferenceCapabilities, planChart, recommend, explainChart, contrastRatio, planStyle, resolveTheme, styleCapabilities, themeModes, themePalettes, themePresets, validateThemeContrast, createPreferencesStore, defaultPreferences, normalizePreferences, mergePreferences, validatePreferences, applyPreferencesToSpec, mountChartSettings, annotationPlugin, dataZoomPlugin, dataLabelsPlugin, accessibilityPlugin, getBusinessSchema, inspectDataSchema, validateData, getEditCapabilities, validateEdit, previewEdit, commitPreview, validateRecipe, normalizeProjectCalendar, applyWorkingCalendar, normalizeDependencies, analyzeSchedule, analyzeBurndownSeries, analyzeCapacity, buildCapacityView, buildCumulativeFlowSeries, buildVelocitySeries, buildReleaseForecast, buildRiskMatrixSeries, buildIssueAgingSeries, normalizeLinkedFilters, normalizeLinkedSelection, filterProjectRows, createLinkedProjectState, linkedRecordId };
package/src/layout.mjs ADDED
@@ -0,0 +1,39 @@
1
+ /** Shared chart chrome layout for titles and font-aware spacing. */
2
+ export function fontSize(spec, role, fallback) {
3
+ const configured = Number(spec.theme?.typography?.[role]?.size);
4
+ if (Number.isFinite(configured) && configured > 0) return configured;
5
+ const raw = spec.theme?.typography?.[role]?.font || `${fallback}px`;
6
+ const match = String(raw).match(/(?:^|\s)(\d+(?:\.\d+)?)px(?:\s|$)/i);
7
+ return match ? Number(match[1]) : fallback;
8
+ }
9
+
10
+ export function titleLayout(spec) {
11
+ const hasTitle = Boolean(spec.title?.text), hasSubtitle = Boolean(spec.title?.subtitle);
12
+ if (!hasTitle && !hasSubtitle) return { titleY: null, subtitleY: null, bottom: 0 };
13
+ const titleSize = fontSize(spec, 'title', 16), subtitleSize = fontSize(spec, 'subtitle', 13);
14
+ const top = Math.max(12, Math.min(24, Number(spec.padding?.top || 0) / 2));
15
+ const titleY = hasTitle ? top + titleSize / 2 : null;
16
+ const titleBottom = hasTitle ? top + titleSize : top;
17
+ const subtitleY = hasSubtitle ? titleBottom + 6 + subtitleSize / 2 : null;
18
+ const bottom = hasSubtitle ? subtitleY + subtitleSize / 2 : titleBottom;
19
+ return { titleY, subtitleY, bottom };
20
+ }
21
+
22
+ export function estimateTextWidth(text, size = 12) {
23
+ return Math.max(size, String(text ?? '').length * size * 0.58);
24
+ }
25
+
26
+ export function axisLabelLayout(spec, labels = [], availableWidth = 0) {
27
+ const size = fontSize(spec, 'axis', 12), count = Math.max(1, labels.length);
28
+ const slot = Math.max(1, availableWidth / count), maxWidth = Math.max(size, ...labels.map(label => estimateTextWidth(label, size)));
29
+ const rotation = maxWidth > slot * 1.45 ? -45 : maxWidth > slot * 0.92 ? -30 : 0;
30
+ const radians = Math.abs(rotation) * Math.PI / 180;
31
+ const projectedWidth = rotation ? maxWidth * Math.cos(radians) + size * Math.sin(radians) : maxWidth;
32
+ const projectedHeight = rotation ? maxWidth * Math.sin(radians) + size * Math.cos(radians) : size;
33
+ const step = Math.max(1, Math.ceil(projectedWidth / Math.max(1, slot * 0.9)));
34
+ return { rotation, step, size, maxWidth, projectedHeight };
35
+ }
36
+
37
+ export function boxesOverlap(left, right, gap = 0) {
38
+ return left.x < right.x + right.width + gap && left.x + left.width + gap > right.x && left.y < right.y + right.height + gap && left.y + left.height + gap > right.y;
39
+ }