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