flowsvg 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -1
- package/PATTERNS.md +12 -1
- package/README.md +139 -117
- package/README.zh-CN.md +139 -117
- package/dist/adapters/react.d.ts +48 -0
- package/dist/adapters/react.js +126 -0
- package/dist/adapters/vue.d.ts +132 -0
- package/dist/adapters/vue.js +161 -0
- package/dist/adapters/web-component.d.ts +99 -0
- package/dist/adapters/web-component.js +396 -0
- package/dist/browser/player.d.ts +69 -0
- package/dist/browser/player.js +353 -0
- package/dist/browser.d.ts +4 -69
- package/dist/browser.js +2 -353
- package/dist/core/color.d.ts +3 -0
- package/dist/core/color.js +18 -0
- package/dist/core/diagram.d.ts +38 -0
- package/dist/core/diagram.js +391 -0
- package/dist/core/edge-geometry.d.ts +32 -0
- package/dist/core/edge-geometry.js +382 -0
- package/dist/{core.d.ts → core/model.d.ts} +2 -52
- package/dist/core/model.js +1 -0
- package/dist/{node-content-paint.d.ts → core/node-content-paint.d.ts} +2 -2
- package/dist/{node-content-paint.js → core/node-content-paint.js} +11 -20
- package/dist/{node-content.d.ts → core/node-content.d.ts} +1 -1
- package/dist/core/svg-number.d.ts +5 -0
- package/dist/core/svg-number.js +7 -0
- package/dist/{theme.d.ts → core/theme.d.ts} +6 -0
- package/dist/core/theme.js +17 -0
- package/dist/{timeline.d.ts → core/timeline.d.ts} +1 -3
- package/dist/{timeline.js → core/timeline.js} +1 -17
- package/dist/core/validation.d.ts +11 -0
- package/dist/core/validation.js +389 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -1
- package/dist/react.d.ts +3 -48
- package/dist/react.js +2 -126
- package/dist/vue.d.ts +3 -132
- package/dist/vue.js +2 -161
- package/dist/web-component.d.ts +3 -103
- package/dist/web-component.js +2 -396
- package/docs/images/entity-graph.png +0 -0
- package/docs/images/facts-timeline.png +0 -0
- package/docs/images/facts-timeline.zh-CN.png +0 -0
- package/docs/images/node-content.png +0 -0
- package/docs/images/numbered-list.png +0 -0
- package/docs/images/numbered-list.zh-CN.png +0 -0
- package/docs/images/quick-start.png +0 -0
- package/docs/images/record-tags.png +0 -0
- package/package.json +2 -1
- package/dist/core.js +0 -1149
- package/dist/theme.js +0 -6
- /package/dist/{player-accessibility.d.ts → browser/accessibility.d.ts} +0 -0
- /package/dist/{player-accessibility.js → browser/accessibility.js} +0 -0
- /package/dist/{player-controls.d.ts → browser/controls.d.ts} +0 -0
- /package/dist/{player-controls.js → browser/controls.js} +0 -0
- /package/dist/{player-lifecycle.d.ts → browser/lifecycle.d.ts} +0 -0
- /package/dist/{player-lifecycle.js → browser/lifecycle.js} +0 -0
- /package/dist/{svg-dom.d.ts → browser/svg-dom.d.ts} +0 -0
- /package/dist/{svg-dom.js → browser/svg-dom.js} +0 -0
- /package/dist/{node-content.js → core/node-content.js} +0 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-independent edge geometry. This module owns routing, sampled path
|
|
3
|
+
* distance, label anchors and line hit testing; diagram evaluation owns the
|
|
4
|
+
* surrounding frame state and consumes this internal interface.
|
|
5
|
+
*/
|
|
6
|
+
import { formatNumber } from "./svg-number.js";
|
|
7
|
+
function straightPoints(from, to) {
|
|
8
|
+
const fromCenter = { x: from.x + from.width / 2, y: from.y + from.height / 2 };
|
|
9
|
+
const toCenter = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
|
|
10
|
+
const start = boundaryPoint(from, toCenter);
|
|
11
|
+
const end = boundaryPoint(to, fromCenter);
|
|
12
|
+
return [start, end];
|
|
13
|
+
}
|
|
14
|
+
const ROUTING_GAP = 8;
|
|
15
|
+
const FOCUS_PADDING = 24;
|
|
16
|
+
const HIT_TOLERANCE = 6;
|
|
17
|
+
export function emptyPathGeometry() {
|
|
18
|
+
return createPathGeometry([]);
|
|
19
|
+
}
|
|
20
|
+
function createPathGeometry(points, serializedPath) {
|
|
21
|
+
const compact = simplifyPoints(points);
|
|
22
|
+
const path = serializedPath ?? pointsToPath(compact);
|
|
23
|
+
const segments = compact.slice(1).map((end, index) => ({ start: compact[index], end, length: Math.hypot(end.x - compact[index].x, end.y - compact[index].y) }));
|
|
24
|
+
const length = segments.reduce((total, segment) => total + segment.length, 0);
|
|
25
|
+
const xs = compact.map((point) => point.x);
|
|
26
|
+
const ys = compact.map((point) => point.y);
|
|
27
|
+
const bounds = xs.length === 0
|
|
28
|
+
? { left: 0, top: 0, right: 0, bottom: 0 }
|
|
29
|
+
: { left: Math.min(...xs), top: Math.min(...ys), right: Math.max(...xs), bottom: Math.max(...ys) };
|
|
30
|
+
return {
|
|
31
|
+
path,
|
|
32
|
+
points: compact,
|
|
33
|
+
length: length || 1,
|
|
34
|
+
bounds,
|
|
35
|
+
pointAt(progress) {
|
|
36
|
+
if (compact.length === 0)
|
|
37
|
+
return { x: 0, y: 0 };
|
|
38
|
+
if (compact.length === 1 || length === 0)
|
|
39
|
+
return compact[0];
|
|
40
|
+
const distance = Math.min(1, Math.max(0, progress)) * length;
|
|
41
|
+
let traversed = 0;
|
|
42
|
+
for (const segment of segments) {
|
|
43
|
+
if (distance <= traversed + segment.length) {
|
|
44
|
+
const ratio = segment.length === 0 ? 0 : (distance - traversed) / segment.length;
|
|
45
|
+
return {
|
|
46
|
+
x: segment.start.x + (segment.end.x - segment.start.x) * ratio,
|
|
47
|
+
y: segment.start.y + (segment.end.y - segment.start.y) * ratio
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
traversed += segment.length;
|
|
51
|
+
}
|
|
52
|
+
return compact.at(-1);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Build sampled geometry for a cubic curve while retaining the exact cubic command for SVG output. */
|
|
57
|
+
function createCurveGeometry(start, controlStart, controlEnd, end) {
|
|
58
|
+
const samples = [];
|
|
59
|
+
const sampleCount = 32;
|
|
60
|
+
for (let index = 0; index <= sampleCount; index += 1) {
|
|
61
|
+
const progress = index / sampleCount;
|
|
62
|
+
const inverse = 1 - progress;
|
|
63
|
+
samples.push({
|
|
64
|
+
x: inverse ** 3 * start.x + 3 * inverse ** 2 * progress * controlStart.x + 3 * inverse * progress ** 2 * controlEnd.x + progress ** 3 * end.x,
|
|
65
|
+
y: inverse ** 3 * start.y + 3 * inverse ** 2 * progress * controlStart.y + 3 * inverse * progress ** 2 * controlEnd.y + progress ** 3 * end.y
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const path = `M ${formatNumber(start.x)} ${formatNumber(start.y)} C ${formatNumber(controlStart.x)} ${formatNumber(controlStart.y)} ${formatNumber(controlEnd.x)} ${formatNumber(controlEnd.y)} ${formatNumber(end.x)} ${formatNumber(end.y)}`;
|
|
69
|
+
return createPathGeometry(samples, path);
|
|
70
|
+
}
|
|
71
|
+
/** Build a two-segment cubic route used for opposite relationships; endpoint tangents remain exact port normals. */
|
|
72
|
+
function createSeparatedCurveGeometry(start, controlStart, midpoint, controlMidStart, controlMidEnd, controlEnd, end) {
|
|
73
|
+
const samples = [];
|
|
74
|
+
const sampleSegment = (first, firstControl, secondControl, last) => {
|
|
75
|
+
for (let index = 0; index <= 16; index += 1) {
|
|
76
|
+
if (samples.length > 0 && index === 0)
|
|
77
|
+
continue;
|
|
78
|
+
const progress = index / 16;
|
|
79
|
+
const inverse = 1 - progress;
|
|
80
|
+
samples.push({
|
|
81
|
+
x: inverse ** 3 * first.x + 3 * inverse ** 2 * progress * firstControl.x + 3 * inverse * progress ** 2 * secondControl.x + progress ** 3 * last.x,
|
|
82
|
+
y: inverse ** 3 * first.y + 3 * inverse ** 2 * progress * firstControl.y + 3 * inverse * progress ** 2 * secondControl.y + progress ** 3 * last.y
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
sampleSegment(start, controlStart, controlMidStart, midpoint);
|
|
87
|
+
sampleSegment(midpoint, controlMidEnd, controlEnd, end);
|
|
88
|
+
const path = `M ${formatNumber(start.x)} ${formatNumber(start.y)} C ${formatNumber(controlStart.x)} ${formatNumber(controlStart.y)} ${formatNumber(controlMidStart.x)} ${formatNumber(controlMidStart.y)} ${formatNumber(midpoint.x)} ${formatNumber(midpoint.y)} C ${formatNumber(controlMidEnd.x)} ${formatNumber(controlMidEnd.y)} ${formatNumber(controlEnd.x)} ${formatNumber(controlEnd.y)} ${formatNumber(end.x)} ${formatNumber(end.y)}`;
|
|
89
|
+
return createPathGeometry(samples, path);
|
|
90
|
+
}
|
|
91
|
+
export function isPointNearPath(point, geometry, tolerance) {
|
|
92
|
+
for (let index = 1; index < geometry.points.length; index += 1)
|
|
93
|
+
if (distanceToSegment(point, geometry.points[index - 1], geometry.points[index]) <= tolerance)
|
|
94
|
+
return true;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
function distanceToSegment(point, start, end) {
|
|
98
|
+
const dx = end.x - start.x;
|
|
99
|
+
const dy = end.y - start.y;
|
|
100
|
+
if (dx === 0 && dy === 0)
|
|
101
|
+
return Math.hypot(point.x - start.x, point.y - start.y);
|
|
102
|
+
const factor = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)));
|
|
103
|
+
return Math.hypot(point.x - (start.x + factor * dx), point.y - (start.y + factor * dy));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Automatic orthogonal routing excludes endpoints and group frames from obstacles.
|
|
107
|
+
* Explicit orthogonal ports reserve outward stubs and route around endpoint bodies as well.
|
|
108
|
+
* Routing uses visible separately from paint opacity: reveals block immediately, and hides block until completion.
|
|
109
|
+
*/
|
|
110
|
+
export function routeEdge(edge, from, to, nodes, reverseOffset = 0) {
|
|
111
|
+
if (edge.route === "curved") {
|
|
112
|
+
const automatic = automaticPorts(from, to);
|
|
113
|
+
const fromDirection = edge.fromPort ?? automatic.from;
|
|
114
|
+
const toDirection = edge.toPort ?? automatic.to;
|
|
115
|
+
const start = portPoint(from, fromDirection);
|
|
116
|
+
const end = portPoint(to, toDirection);
|
|
117
|
+
return curvedPath(start, end, fromDirection, toDirection, reverseOffset);
|
|
118
|
+
}
|
|
119
|
+
if (edge.route !== "orthogonal") {
|
|
120
|
+
const points = edge.fromPort || edge.toPort
|
|
121
|
+
? [portPoint(from, edge.fromPort ?? automaticPorts(from, to).from), portPoint(to, edge.toPort ?? automaticPorts(from, to).to)]
|
|
122
|
+
: straightPoints(from, to);
|
|
123
|
+
return createPathGeometry(points);
|
|
124
|
+
}
|
|
125
|
+
const { start, end } = orthogonalAttachments(from, to, edge.fromPort, edge.toPort);
|
|
126
|
+
const obstacles = nodes
|
|
127
|
+
.filter((node) => node.visible && node.kind !== "group" && node.id !== from.id && node.id !== to.id)
|
|
128
|
+
.map(routingBounds);
|
|
129
|
+
if (edge.fromPort || edge.toPort)
|
|
130
|
+
return createPathGeometry(explicitOrthogonalPath(edge, from, to, obstacles));
|
|
131
|
+
return createPathGeometry(orthogonalPath(start, end, obstacles));
|
|
132
|
+
}
|
|
133
|
+
function routingBounds(node) {
|
|
134
|
+
return {
|
|
135
|
+
left: node.x - ROUTING_GAP,
|
|
136
|
+
top: node.y - ROUTING_GAP,
|
|
137
|
+
right: node.x + node.width + ROUTING_GAP,
|
|
138
|
+
bottom: node.y + node.height + ROUTING_GAP
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Reserve endpoint-normal segments before grid search so a backside port cannot route through its own node. */
|
|
142
|
+
function explicitOrthogonalPath(edge, from, to, obstacles) {
|
|
143
|
+
const automatic = automaticPorts(from, to);
|
|
144
|
+
const fromDirection = edge.fromPort ?? automatic.from;
|
|
145
|
+
const toDirection = edge.toPort ?? automatic.to;
|
|
146
|
+
const start = portPoint(from, fromDirection);
|
|
147
|
+
const end = portPoint(to, toDirection);
|
|
148
|
+
const fromNormal = portNormal(fromDirection);
|
|
149
|
+
const toNormal = portNormal(toDirection);
|
|
150
|
+
const startStub = { x: start.x + fromNormal.x * ROUTING_GAP, y: start.y + fromNormal.y * ROUTING_GAP };
|
|
151
|
+
const endStub = { x: end.x + toNormal.x * ROUTING_GAP, y: end.y + toNormal.y * ROUTING_GAP };
|
|
152
|
+
const middle = orthogonalPath(startStub, endStub, [...obstacles, routingBounds(from), routingBounds(to)]);
|
|
153
|
+
return [start, ...middle, end];
|
|
154
|
+
}
|
|
155
|
+
/** Choose rectangular attachment sides along the dominant center-to-center axis for orthogonal connections. */
|
|
156
|
+
function orthogonalAttachments(from, to, fromPort, toPort) {
|
|
157
|
+
const automatic = automaticPorts(from, to);
|
|
158
|
+
return {
|
|
159
|
+
start: portPoint(from, fromPort ?? automatic.from),
|
|
160
|
+
end: portPoint(to, toPort ?? automatic.to)
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Select cardinal sides from the dominant center-to-center axis for automatic routes. */
|
|
164
|
+
function automaticPorts(from, to) {
|
|
165
|
+
const fromCenter = { x: from.x + from.width / 2, y: from.y + from.height / 2 };
|
|
166
|
+
const toCenter = { x: to.x + to.width / 2, y: to.y + to.height / 2 };
|
|
167
|
+
const horizontal = Math.abs(toCenter.x - fromCenter.x) >= Math.abs(toCenter.y - fromCenter.y);
|
|
168
|
+
if (horizontal) {
|
|
169
|
+
const right = toCenter.x >= fromCenter.x;
|
|
170
|
+
return { from: right ? "right" : "left", to: right ? "left" : "right" };
|
|
171
|
+
}
|
|
172
|
+
const bottom = toCenter.y >= fromCenter.y;
|
|
173
|
+
return { from: bottom ? "bottom" : "top", to: bottom ? "top" : "bottom" };
|
|
174
|
+
}
|
|
175
|
+
/** Return the actual outline point for a cardinal side, including ellipse contours. */
|
|
176
|
+
function portPoint(node, direction) {
|
|
177
|
+
const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
|
|
178
|
+
if (direction === "top")
|
|
179
|
+
return { x: center.x, y: node.y };
|
|
180
|
+
if (direction === "right")
|
|
181
|
+
return { x: node.x + node.width, y: center.y };
|
|
182
|
+
if (direction === "bottom")
|
|
183
|
+
return { x: center.x, y: node.y + node.height };
|
|
184
|
+
return { x: node.x, y: center.y };
|
|
185
|
+
}
|
|
186
|
+
/** Generate a smooth cubic route whose endpoint tangents follow port normals. */
|
|
187
|
+
function curvedPath(start, end, fromDirection, toDirection, separation) {
|
|
188
|
+
const distance = Math.hypot(end.x - start.x, end.y - start.y);
|
|
189
|
+
const tangentLength = Math.max(24, distance * 0.32);
|
|
190
|
+
const fromNormal = portNormal(fromDirection);
|
|
191
|
+
const toNormal = portNormal(toDirection);
|
|
192
|
+
const perpendicular = { x: -(end.y - start.y), y: end.x - start.x };
|
|
193
|
+
const perpendicularLength = Math.hypot(perpendicular.x, perpendicular.y) || 1;
|
|
194
|
+
const lane = separation * Math.min(72, Math.max(28, distance * 0.16));
|
|
195
|
+
if (separation !== 0) {
|
|
196
|
+
const midpoint = {
|
|
197
|
+
x: (start.x + end.x) / 2 + perpendicular.x / perpendicularLength * lane,
|
|
198
|
+
y: (start.y + end.y) / 2 + perpendicular.y / perpendicularLength * lane
|
|
199
|
+
};
|
|
200
|
+
const chord = { x: end.x - start.x, y: end.y - start.y };
|
|
201
|
+
const chordLength = Math.hypot(chord.x, chord.y) || 1;
|
|
202
|
+
const joinTangent = { x: chord.x / chordLength, y: chord.y / chordLength };
|
|
203
|
+
return createSeparatedCurveGeometry(start, { x: start.x + fromNormal.x * tangentLength, y: start.y + fromNormal.y * tangentLength }, midpoint, { x: midpoint.x - joinTangent.x * tangentLength * 0.5, y: midpoint.y - joinTangent.y * tangentLength * 0.5 }, { x: midpoint.x + joinTangent.x * tangentLength * 0.5, y: midpoint.y + joinTangent.y * tangentLength * 0.5 }, { x: end.x + toNormal.x * tangentLength, y: end.y + toNormal.y * tangentLength }, end);
|
|
204
|
+
}
|
|
205
|
+
const controlStart = {
|
|
206
|
+
x: start.x + fromNormal.x * tangentLength + perpendicular.x / perpendicularLength * lane,
|
|
207
|
+
y: start.y + fromNormal.y * tangentLength + perpendicular.y / perpendicularLength * lane
|
|
208
|
+
};
|
|
209
|
+
const controlEnd = {
|
|
210
|
+
x: end.x + toNormal.x * tangentLength + perpendicular.x / perpendicularLength * lane,
|
|
211
|
+
y: end.y + toNormal.y * tangentLength + perpendicular.y / perpendicularLength * lane
|
|
212
|
+
};
|
|
213
|
+
return createCurveGeometry(start, controlStart, controlEnd, end);
|
|
214
|
+
}
|
|
215
|
+
function portNormal(direction) {
|
|
216
|
+
if (direction === "top")
|
|
217
|
+
return { x: 0, y: -1 };
|
|
218
|
+
if (direction === "right")
|
|
219
|
+
return { x: 1, y: 0 };
|
|
220
|
+
if (direction === "bottom")
|
|
221
|
+
return { x: 0, y: 1 };
|
|
222
|
+
return { x: -1, y: 0 };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Breadth-first search the grid of endpoints and obstacle boundaries, using fixed neighbor order for determinism.
|
|
226
|
+
* Grid-step search does not guarantee shortest geometric length or fewest bends; fall back to a fixed elbow if blocked.
|
|
227
|
+
*/
|
|
228
|
+
function orthogonalPath(start, end, obstacles) {
|
|
229
|
+
const xs = uniqueSorted([start.x, end.x, ...obstacles.flatMap((rect) => [rect.left, rect.right])]);
|
|
230
|
+
const ys = uniqueSorted([start.y, end.y, ...obstacles.flatMap((rect) => [rect.top, rect.bottom])]);
|
|
231
|
+
const xIndex = new Map(xs.map((value, index) => [value, index]));
|
|
232
|
+
const yIndex = new Map(ys.map((value, index) => [value, index]));
|
|
233
|
+
const startKey = pointKey(start);
|
|
234
|
+
const endKey = pointKey(end);
|
|
235
|
+
const queue = [start];
|
|
236
|
+
const previous = new Map([[startKey, undefined]]);
|
|
237
|
+
const preferredHorizontal = Math.abs(end.x - start.x) >= Math.abs(end.y - start.y);
|
|
238
|
+
const directions = preferredHorizontal
|
|
239
|
+
? [[1, 0], [0, -1], [0, 1], [-1, 0]]
|
|
240
|
+
: [[0, 1], [1, 0], [-1, 0], [0, -1]];
|
|
241
|
+
while (queue.length > 0) {
|
|
242
|
+
const current = queue.shift();
|
|
243
|
+
if (pointKey(current) === endKey)
|
|
244
|
+
break;
|
|
245
|
+
const currentX = xIndex.get(current.x);
|
|
246
|
+
const currentY = yIndex.get(current.y);
|
|
247
|
+
if (currentX === undefined || currentY === undefined)
|
|
248
|
+
continue;
|
|
249
|
+
for (const [dx, dy] of directions) {
|
|
250
|
+
const nextX = xs[currentX + dx];
|
|
251
|
+
const nextY = ys[currentY + dy];
|
|
252
|
+
if (nextX === undefined || nextY === undefined)
|
|
253
|
+
continue;
|
|
254
|
+
const next = { x: nextX, y: nextY };
|
|
255
|
+
const key = pointKey(next);
|
|
256
|
+
if (previous.has(key) || isInsideObstacle(next, obstacles) || blockedSegment(current, next, obstacles))
|
|
257
|
+
continue;
|
|
258
|
+
previous.set(key, current);
|
|
259
|
+
queue.push(next);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const points = [];
|
|
263
|
+
if (previous.has(endKey)) {
|
|
264
|
+
let current = end;
|
|
265
|
+
while (current) {
|
|
266
|
+
points.push(current);
|
|
267
|
+
current = previous.get(pointKey(current));
|
|
268
|
+
}
|
|
269
|
+
points.reverse();
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
const middle = preferredHorizontal ? { x: end.x, y: start.y } : { x: start.x, y: end.y };
|
|
273
|
+
points.push(start, middle, end);
|
|
274
|
+
}
|
|
275
|
+
return removeMicroTurns(points, obstacles);
|
|
276
|
+
}
|
|
277
|
+
/** Replace tiny obstacle-detour turns with a wider elbow when the replacement remains collision-free. */
|
|
278
|
+
function removeMicroTurns(points, obstacles) {
|
|
279
|
+
const result = [...points];
|
|
280
|
+
const minimum = ROUTING_GAP * 2;
|
|
281
|
+
for (let index = 1; index + 2 < result.length; index += 1) {
|
|
282
|
+
const before = result[index - 1];
|
|
283
|
+
const first = result[index];
|
|
284
|
+
const second = result[index + 1];
|
|
285
|
+
const after = result[index + 2];
|
|
286
|
+
const shortLength = Math.abs(second.x - first.x) + Math.abs(second.y - first.y);
|
|
287
|
+
if (shortLength >= minimum)
|
|
288
|
+
continue;
|
|
289
|
+
const candidate = before.x === first.x
|
|
290
|
+
? { x: first.x, y: after.y }
|
|
291
|
+
: { x: after.x, y: first.y };
|
|
292
|
+
if (isInsideObstacle(candidate, obstacles) || blockedSegment(before, candidate, obstacles) || blockedSegment(candidate, after, obstacles))
|
|
293
|
+
continue;
|
|
294
|
+
result.splice(index, 2, candidate);
|
|
295
|
+
index = Math.max(0, index - 2);
|
|
296
|
+
}
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
/** Remove consecutive duplicates and collinear intermediate points to keep SVG paths compact. */
|
|
300
|
+
function pointsToPath(points) {
|
|
301
|
+
return simplifyPoints(points).map((point, index) => `${index === 0 ? "M" : "L"} ${formatNumber(point.x)} ${formatNumber(point.y)}`).join(" ");
|
|
302
|
+
}
|
|
303
|
+
/** Keep collinear reversals: removing an extremum would shorten flow distance and truncate path bounds. */
|
|
304
|
+
function simplifyPoints(points) {
|
|
305
|
+
const compact = [];
|
|
306
|
+
for (const point of points) {
|
|
307
|
+
const previous = compact.at(-1);
|
|
308
|
+
if (!previous || previous.x !== point.x || previous.y !== point.y)
|
|
309
|
+
compact.push(point);
|
|
310
|
+
}
|
|
311
|
+
const simplified = [];
|
|
312
|
+
for (const point of compact) {
|
|
313
|
+
const previous = simplified.at(-1);
|
|
314
|
+
const beforePrevious = simplified.at(-2);
|
|
315
|
+
const continuesDirection = previous && beforePrevious
|
|
316
|
+
&& (previous.x - beforePrevious.x) * (point.x - previous.x) + (previous.y - beforePrevious.y) * (point.y - previous.y) >= 0;
|
|
317
|
+
if (previous && beforePrevious && continuesDirection && (beforePrevious.x === previous.x && previous.x === point.x || beforePrevious.y === previous.y && previous.y === point.y)) {
|
|
318
|
+
simplified[simplified.length - 1] = point;
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
simplified.push(point);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return simplified;
|
|
325
|
+
}
|
|
326
|
+
/** Obstacles already include ROUTING_GAP; allow boundary contact and reject only segments crossing their interiors. */
|
|
327
|
+
function blockedSegment(start, end, obstacles) {
|
|
328
|
+
for (const obstacle of obstacles) {
|
|
329
|
+
if (start.x === end.x) {
|
|
330
|
+
if (start.x > obstacle.left && start.x < obstacle.right && rangesOverlap(start.y, end.y, obstacle.top, obstacle.bottom))
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
else if (start.y === end.y && start.y > obstacle.top && start.y < obstacle.bottom && rangesOverlap(start.x, end.x, obstacle.left, obstacle.right)) {
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
function isInsideObstacle(point, obstacles) {
|
|
340
|
+
return obstacles.some((obstacle) => point.x > obstacle.left && point.x < obstacle.right && point.y > obstacle.top && point.y < obstacle.bottom);
|
|
341
|
+
}
|
|
342
|
+
function rangesOverlap(firstStart, firstEnd, secondStart, secondEnd) {
|
|
343
|
+
return Math.max(Math.min(firstStart, firstEnd), secondStart) < Math.min(Math.max(firstStart, firstEnd), secondEnd);
|
|
344
|
+
}
|
|
345
|
+
function pointKey(point) {
|
|
346
|
+
return `${point.x},${point.y}`;
|
|
347
|
+
}
|
|
348
|
+
function uniqueSorted(values) {
|
|
349
|
+
return [...new Set(values)].sort((left, right) => left - right);
|
|
350
|
+
}
|
|
351
|
+
/** Project straight connections onto the ellipse contour, or the bounding rectangle for other shapes. */
|
|
352
|
+
function boundaryPoint(node, toward) {
|
|
353
|
+
const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
|
|
354
|
+
const dx = toward.x - center.x;
|
|
355
|
+
const dy = toward.y - center.y;
|
|
356
|
+
if (dx === 0 && dy === 0)
|
|
357
|
+
return center;
|
|
358
|
+
const normalizedX = dx / (node.width / 2);
|
|
359
|
+
const normalizedY = dy / (node.height / 2);
|
|
360
|
+
const scale = 1 / (node.kind === "ellipse"
|
|
361
|
+
? Math.hypot(normalizedX, normalizedY)
|
|
362
|
+
: Math.max(Math.abs(normalizedX), Math.abs(normalizedY)));
|
|
363
|
+
return { x: center.x + dx * scale, y: center.y + dy * scale };
|
|
364
|
+
}
|
|
365
|
+
export function pathLabelAnchor(geometry) {
|
|
366
|
+
const points = geometry.points;
|
|
367
|
+
if (points.length < 2)
|
|
368
|
+
return { x: 0, y: -12 };
|
|
369
|
+
let longest = { start: points[0], end: points[1], length: 0 };
|
|
370
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
371
|
+
const start = points[index - 1];
|
|
372
|
+
const end = points[index];
|
|
373
|
+
const length = Math.hypot(end.x - start.x, end.y - start.y);
|
|
374
|
+
if (length > longest.length)
|
|
375
|
+
longest = { start, end, length };
|
|
376
|
+
}
|
|
377
|
+
const horizontal = longest.start.y === longest.end.y;
|
|
378
|
+
return {
|
|
379
|
+
x: (longest.start.x + longest.end.x) / 2 + (horizontal ? 0 : 20),
|
|
380
|
+
y: (longest.start.y + longest.end.y) / 2 + (horizontal ? -12 : 0)
|
|
381
|
+
};
|
|
382
|
+
}
|
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
* The browser consumes these results; this module does not read the DOM, network, system theme or browser clock.
|
|
4
|
-
*/
|
|
5
|
-
import { type NodeContent, type NodeContentLayout } from "./node-content.js";
|
|
6
|
-
export type { NodeContent, ContentBlock, ContentListItem, ContentListStyle, ContentTag, ContentTagStyle, ContentTagBackground, ContentGraphBlock, ContentGraphNode, ContentGraphEdge, ContentGraphLayout, ContentText, ContentTextSpan, ContentSpanStyle, ContentSpan, ContentTextStyle, ContentAlignment, ContentSafeArea, ContentLine, NodeContentLayout } from "./node-content.js";
|
|
1
|
+
/** Shared serializable document and evaluated-frame types; no runtime or platform dependencies. */
|
|
2
|
+
import type { NodeContent, NodeContentLayout } from "./node-content.js";
|
|
7
3
|
/** Supported shapes; a group also supplies the origin and visibility constraints for its children. */
|
|
8
4
|
export type NodeKind = "rect" | "roundedRect" | "ellipse" | "cylinder" | "group";
|
|
9
5
|
/** Route families generated from node geometry; orthogonal routes avoid obstacles, and curved routes use cubic Bézier controls. */
|
|
@@ -258,49 +254,3 @@ export interface DiagramFrameState {
|
|
|
258
254
|
caption?: string;
|
|
259
255
|
warnings: LayoutWarning[];
|
|
260
256
|
}
|
|
261
|
-
/** Compilation error for invalid documents; issues contains all field problems found by validation. */
|
|
262
|
-
export declare class DiagramValidationError extends Error {
|
|
263
|
-
readonly issues: readonly ValidationIssue[];
|
|
264
|
-
constructor(issues: ValidationIssue[]);
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* Validate document structure, values and references; an empty list means validation passed.
|
|
268
|
-
* Accumulate issues across inspectable branches without mutating input or producing a partial Diagram.
|
|
269
|
-
*/
|
|
270
|
-
export declare function validateDocument(input: unknown): ValidationIssue[];
|
|
271
|
-
/**
|
|
272
|
-
* Validate and copy a document, then compile its timeline; later input mutations cannot affect evaluation.
|
|
273
|
-
* @throws {DiagramValidationError} If document structure, values or references are invalid.
|
|
274
|
-
*/
|
|
275
|
-
export declare function createDiagram(input: DiagramDocument): Diagram;
|
|
276
|
-
/** One-shot static SVG export; use createDiagram to reuse compilation across repeated evaluations or exports. */
|
|
277
|
-
export declare function renderSvg(document: DiagramDocument, timeMs?: number, options?: EvaluateOptions): string;
|
|
278
|
-
/** Compiled diagram independent of the DOM, network and clock; each query evaluates from its time and options. */
|
|
279
|
-
export interface Diagram {
|
|
280
|
-
/** Global timeline duration in milliseconds; zero for static diagrams. */
|
|
281
|
-
readonly totalDuration: number;
|
|
282
|
-
/**
|
|
283
|
-
* Evaluate a frame; finite times clamp to [0, totalDuration], and non-finite times become zero.
|
|
284
|
-
* Beat intervals include their start and exclude their end; totalDuration returns the completed final beat.
|
|
285
|
-
*/
|
|
286
|
-
evaluateAt(timeMs: number, options?: EvaluateOptions): DiagramFrameState;
|
|
287
|
-
/** Serialize the same frame as self-contained static SVG with escaped text and resolved colors, without controls. */
|
|
288
|
-
renderSvg(timeMs?: number, options?: EvaluateOptions): string;
|
|
289
|
-
/**
|
|
290
|
-
* Hit-test document coordinates in paint order: ordinary nodes, then edges, then group backgrounds.
|
|
291
|
-
* Objects within each layer are searched in reverse document order.
|
|
292
|
-
* Node hits include their visible label area. Hidden/zero-opacity objects are excluded.
|
|
293
|
-
* Return null for no hit or non-finite coordinates.
|
|
294
|
-
*/
|
|
295
|
-
hitTest(timeMs: number, point: DiagramPoint, options?: EvaluateOptions): HitObject | null;
|
|
296
|
-
}
|
|
297
|
-
interface Typography {
|
|
298
|
-
fontSize: number;
|
|
299
|
-
lineHeight: number;
|
|
300
|
-
charWidth: number;
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* Estimate wrapping with fixed code-point widths, preserving explicit empty lines and preferring spaces as breaks.
|
|
304
|
-
* No font/DOM measurement is used; this handles width only, while frame evaluation reports height overflow.
|
|
305
|
-
*/
|
|
306
|
-
export declare function wrapText(label: string, maxWidth: number, typography?: Typography): string[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { NodeContentLayout } from "./node-content.js";
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Retain theme-specific semantic ink and tag fills; preserve geometry and explicit author colors.
|
|
8
|
+
* Check default ink against the evaluated fill because authored base fills can cross low-contrast transition colors.
|
|
9
9
|
*/
|
|
10
10
|
export declare function paintContentHighlight(layout: NodeContentLayout, amount: number, fill: string): NodeContentLayout;
|
|
@@ -1,42 +1,33 @@
|
|
|
1
|
-
import { mixColor } from "./timeline.js";
|
|
2
|
-
const HIGHLIGHT_CONTENT_COLORS = {
|
|
3
|
-
title: "#172033", body: "#172033", marker: "#172033", auxiliary: "#354158", tag: "#164b9b", status: "#16466b"
|
|
4
|
-
};
|
|
5
|
-
const HIGHLIGHT_TAG_FILL = "#eaf1ff";
|
|
6
1
|
const MIN_CONTENT_CONTRAST = 4.5;
|
|
7
2
|
const CONTRAST_DARK_INK = "#000000";
|
|
8
3
|
const CONTRAST_LIGHT_INK = "#ffffff";
|
|
9
4
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* Retain theme-specific semantic ink and tag fills; preserve geometry and explicit author colors.
|
|
6
|
+
* Check default ink against the evaluated fill because authored base fills can cross low-contrast transition colors.
|
|
12
7
|
*/
|
|
13
8
|
export function paintContentHighlight(layout, amount, fill) {
|
|
14
9
|
if (amount === 0)
|
|
15
10
|
return layout;
|
|
16
|
-
const tags = layout.tags.map((tag) => ({ ...tag, fill: tag.explicitFill ? tag.fill : mixColor(tag.fill, HIGHLIGHT_TAG_FILL, amount) }));
|
|
17
11
|
return {
|
|
18
|
-
...layout,
|
|
12
|
+
...layout,
|
|
19
13
|
...(layout.graphs ? { graphs: layout.graphs.map((graph) => ({ ...graph,
|
|
20
|
-
nodes: graph.nodes.map((node) => ({ ...node, fill: readableDefaultInk(node.fill,
|
|
21
|
-
edges: graph.edges.map((edge) => ({ ...edge, color: readableDefaultInk(edge.color,
|
|
14
|
+
nodes: graph.nodes.map((node) => ({ ...node, fill: readableDefaultInk(node.fill, fill) })),
|
|
15
|
+
edges: graph.edges.map((edge) => ({ ...edge, color: readableDefaultInk(edge.color, fill) }))
|
|
22
16
|
})) } : {}),
|
|
23
17
|
lines: layout.lines.map((line) => {
|
|
24
|
-
const tag = line.role === "tag" ? tags.find((candidate) => candidate.blockIndex === line.blockIndex
|
|
18
|
+
const tag = line.role === "tag" ? layout.tags.find((candidate) => candidate.blockIndex === line.blockIndex
|
|
25
19
|
&& candidate.itemIndex === line.itemIndex && candidate.tagIndex === line.tagIndex) : undefined;
|
|
26
20
|
const background = tag?.fill ?? fill;
|
|
27
|
-
const paint = (color) => readableDefaultInk(color,
|
|
21
|
+
const paint = (color) => readableDefaultInk(color, background);
|
|
28
22
|
const spans = line.spans.map((span) => ({ ...span, color: span.explicitColor ? span.color : paint(span.color) }));
|
|
29
23
|
return { ...line, spans, color: spans[0]?.color ?? paint(line.color) };
|
|
30
24
|
})
|
|
31
25
|
};
|
|
32
26
|
}
|
|
33
|
-
/**
|
|
34
|
-
function readableDefaultInk(source,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (contrastRatio(candidate, background) >= MIN_CONTENT_CONTRAST)
|
|
38
|
-
return candidate;
|
|
39
|
-
}
|
|
27
|
+
/** Keep semantic ink when readable; otherwise select opaque dark/light ink without changing author colors. */
|
|
28
|
+
function readableDefaultInk(source, background) {
|
|
29
|
+
if (contrastRatio(source, background) >= MIN_CONTENT_CONTRAST)
|
|
30
|
+
return source;
|
|
40
31
|
return contrastRatio(CONTRAST_DARK_INK, background) >= contrastRatio(CONTRAST_LIGHT_INK, background)
|
|
41
32
|
? CONTRAST_DARK_INK : CONTRAST_LIGHT_INK;
|
|
42
33
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Deterministic node-content model and layout. The core supplies current absolute node geometry and theme colors.
|
|
3
3
|
* This module does not measure browser fonts or mutate authored dimensions; SVG consumes its evaluated lines and tag backgrounds.
|
|
4
4
|
*/
|
|
5
|
-
import type { NodeKind, ValidationIssue } from "./
|
|
5
|
+
import type { NodeKind, ValidationIssue } from "./model.js";
|
|
6
6
|
import type { ThemePalette } from "./theme.js";
|
|
7
7
|
/** Horizontal placement within the current content safe area. */
|
|
8
8
|
export type ContentAlignment = "left" | "center" | "right";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SVG number serialization. Geometry and SVG serialization use this
|
|
3
|
+
* deterministic format so moving either implementation cannot change output.
|
|
4
|
+
*/
|
|
5
|
+
export function formatNumber(value) {
|
|
6
|
+
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3)));
|
|
7
|
+
}
|
|
@@ -9,7 +9,13 @@ export interface ThemePalette {
|
|
|
9
9
|
tagFill: string;
|
|
10
10
|
accentText: string;
|
|
11
11
|
edge: string;
|
|
12
|
+
/** Border and flow-marker emphasis; node interiors use highlightFill instead. */
|
|
12
13
|
highlight: string;
|
|
14
|
+
highlightFill: string;
|
|
15
|
+
highlightText: string;
|
|
13
16
|
}
|
|
17
|
+
export declare const DEFAULT_NODE_STROKE_WIDTH = 1.2;
|
|
18
|
+
export declare const NODE_CORNER_RADIUS = 8;
|
|
19
|
+
export declare const DIAGRAM_FONT_FAMILY = "system-ui, -apple-system, \"PingFang SC\", \"Microsoft YaHei\", \"Noto Sans CJK SC\", sans-serif";
|
|
14
20
|
/** Select a palette without consulting browser preferences. */
|
|
15
21
|
export declare function resolveThemePalette(theme: "light" | "dark" | undefined): ThemePalette;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const LIGHT_PALETTE = {
|
|
2
|
+
background: "#ffffff", fill: "#ffffff", stroke: "#cfd8e3", text: "#1d2939", mutedText: "#5a6473",
|
|
3
|
+
tagText: "#1d4fa8", tagFill: "#eaf1ff", accentText: "#1d4fa8", edge: "#8892a4",
|
|
4
|
+
highlight: "#2a63c8", highlightFill: "#eaf1ff", highlightText: "#ffffff"
|
|
5
|
+
};
|
|
6
|
+
const DARK_PALETTE = {
|
|
7
|
+
background: "#101828", fill: "#1d2939", stroke: "#475467", text: "#f2f4f7", mutedText: "#b8c2d1",
|
|
8
|
+
tagText: "#caddff", tagFill: "#253b5c", accentText: "#9bbfff", edge: "#98a2b3",
|
|
9
|
+
highlight: "#8ab4f8", highlightFill: "#233b60", highlightText: "#101828"
|
|
10
|
+
};
|
|
11
|
+
export const DEFAULT_NODE_STROKE_WIDTH = 1.2;
|
|
12
|
+
export const NODE_CORNER_RADIUS = 8;
|
|
13
|
+
export const DIAGRAM_FONT_FAMILY = 'system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif';
|
|
14
|
+
/** Select a palette without consulting browser preferences. */
|
|
15
|
+
export function resolveThemePalette(theme) {
|
|
16
|
+
return theme === "dark" ? DARK_PALETTE : LIGHT_PALETTE;
|
|
17
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Deterministic global timeline: compile actions/snapshots and evaluate base styles, visibility, highlights and focus.
|
|
3
3
|
* Spatial layout and SVG output belong to the core; theme defaults are supplied only during evaluation.
|
|
4
4
|
*/
|
|
5
|
-
import type { DiagramDocument, EvaluateOptions, FlowDirection, NodeVisualState, EdgeVisualState } from "./
|
|
5
|
+
import type { DiagramDocument, EvaluateOptions, FlowDirection, NodeVisualState, EdgeVisualState } from "./model.js";
|
|
6
6
|
type VisualValues = NodeVisualState & EdgeVisualState;
|
|
7
7
|
/** Evaluation-time theme defaults; unspecified snapshot colors remain unresolved so compilation does not fix a theme. */
|
|
8
8
|
export interface TimelinePalette {
|
|
@@ -53,6 +53,4 @@ export declare class CompiledTimeline {
|
|
|
53
53
|
/** Select the next beat at endMs, retaining the last beat at total duration; static documents return undefined. */
|
|
54
54
|
private findBeat;
|
|
55
55
|
}
|
|
56
|
-
/** Interpolate validated hexadecimal RGB/RGBA channels; preserve endpoint strings and normalize intermediate colors. */
|
|
57
|
-
export declare function mixColor(source: string, target: string, progress: number): string;
|
|
58
56
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mixColor } from "./color.js";
|
|
1
2
|
const DEFAULT_TRANSITION_MS = 250;
|
|
2
3
|
const NUMERIC_VISUAL_FIELDS = ["x", "y", "width", "height", "opacity", "strokeWidth"];
|
|
3
4
|
const DEFAULT_STROKE_WIDTH = 2;
|
|
@@ -145,20 +146,3 @@ function interpolateValues(source, target, progress, palette) {
|
|
|
145
146
|
stroke: mixColor(source.values.stroke ?? strokeFallback, target.values.stroke ?? strokeFallback, progress)
|
|
146
147
|
};
|
|
147
148
|
}
|
|
148
|
-
/** Interpolate validated hexadecimal RGB/RGBA channels; preserve endpoint strings and normalize intermediate colors. */
|
|
149
|
-
export function mixColor(source, target, progress) {
|
|
150
|
-
if (progress === 0)
|
|
151
|
-
return source;
|
|
152
|
-
if (progress === 1)
|
|
153
|
-
return target;
|
|
154
|
-
const channels = (color) => {
|
|
155
|
-
const hex = color.slice(1);
|
|
156
|
-
const expanded = hex.length <= 4 ? [...hex].map((digit) => digit + digit).join("") : hex;
|
|
157
|
-
return [0, 2, 4, 6].map((offset) => offset < expanded.length ? parseInt(expanded.slice(offset, offset + 2), 16) : 255);
|
|
158
|
-
};
|
|
159
|
-
const from = channels(source);
|
|
160
|
-
const to = channels(target);
|
|
161
|
-
const values = from.map((value, index) => Math.round(value + (to[index] - value) * progress));
|
|
162
|
-
const hex = values.map((value) => value.toString(16).padStart(2, "0"));
|
|
163
|
-
return `#${hex.slice(0, values[3] === 255 ? 3 : 4).join("")}`;
|
|
164
|
-
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ValidationIssue } from "./model.js";
|
|
2
|
+
/** Compilation error for invalid documents; issues contains all field problems found by validation. */
|
|
3
|
+
export declare class DiagramValidationError extends Error {
|
|
4
|
+
readonly issues: readonly ValidationIssue[];
|
|
5
|
+
constructor(issues: ValidationIssue[]);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Validate document structure, values and references; an empty list means validation passed.
|
|
9
|
+
* Accumulate issues across inspectable branches without mutating input or producing a partial Diagram.
|
|
10
|
+
*/
|
|
11
|
+
export declare function validateDocument(input: unknown): ValidationIssue[];
|