@world-engines/authoring-ui 0.1.0-alpha.0
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/LICENSE +46 -0
- package/dist/DesktopAuthoringShell.d.ts +178 -0
- package/dist/DesktopAuthoringShell.js +235 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +12 -0
- package/dist/ladybug/BrowserGraphCanvasAdapter.d.ts +12 -0
- package/dist/ladybug/BrowserGraphCanvasAdapter.js +8 -0
- package/dist/ladybug/GraphCanvas.d.ts +121 -0
- package/dist/ladybug/GraphCanvas.js +1439 -0
- package/dist/ladybug/GraphEdge.d.ts +26 -0
- package/dist/ladybug/GraphEdge.js +41 -0
- package/dist/ladybug/LadybugExplorer.d.ts +58 -0
- package/dist/ladybug/LadybugExplorer.js +83 -0
- package/dist/ladybug/LadybugSurfaces.d.ts +25 -0
- package/dist/ladybug/LadybugSurfaces.js +9 -0
- package/dist/ladybug/LadybugTable.d.ts +15 -0
- package/dist/ladybug/LadybugTable.js +26 -0
- package/dist/ladybug/entity-props.d.ts +98 -0
- package/dist/ladybug/entity-props.js +363 -0
- package/dist/ladybug/graph-edge-geometry.d.ts +19 -0
- package/dist/ladybug/graph-edge-geometry.js +78 -0
- package/dist/ladybug/graph-edge-mask.d.ts +21 -0
- package/dist/ladybug/graph-edge-mask.js +52 -0
- package/dist/ladybug/graph-groups.d.ts +47 -0
- package/dist/ladybug/graph-groups.js +245 -0
- package/dist/ladybug/graph-layout-contract.d.ts +34 -0
- package/dist/ladybug/graph-layout-contract.js +55 -0
- package/dist/ladybug/graph-layout-solver.d.ts +16 -0
- package/dist/ladybug/graph-layout-solver.js +96 -0
- package/dist/ladybug/graph-layout-wasm.d.ts +9 -0
- package/dist/ladybug/graph-layout-wasm.js +88 -0
- package/dist/ladybug/graph-layout-worker-protocol.d.ts +18 -0
- package/dist/ladybug/graph-layout-worker-protocol.js +1 -0
- package/dist/ladybug/graph-layout-worker.d.ts +1 -0
- package/dist/ladybug/graph-layout-worker.js +66 -0
- package/dist/ladybug/graph-layout.d.ts +2 -0
- package/dist/ladybug/graph-layout.js +1 -0
- package/dist/ladybug/graph-layout.wasm +0 -0
- package/dist/ladybug/graph-node-metrics.d.ts +33 -0
- package/dist/ladybug/graph-node-metrics.js +173 -0
- package/dist/ladybug/graph-physics.d.ts +49 -0
- package/dist/ladybug/graph-physics.js +266 -0
- package/dist/ladybug/graph-placement.d.ts +33 -0
- package/dist/ladybug/graph-placement.js +253 -0
- package/dist/ladybug/graph.css +530 -0
- package/dist/ladybug/graph.d.ts +18 -0
- package/dist/ladybug/graph.js +9 -0
- package/dist/ladybug/score-tags.d.ts +11 -0
- package/dist/ladybug/score-tags.js +1 -0
- package/dist/map/MapWorkspace.d.ts +14 -0
- package/dist/map/MapWorkspace.js +9 -0
- package/dist/score/ScoreRadar.d.ts +10 -0
- package/dist/score/ScoreRadar.js +66 -0
- package/dist/score/index.d.ts +3 -0
- package/dist/score/index.js +3 -0
- package/dist/score/radar-index.d.ts +3 -0
- package/dist/score/radar-index.js +2 -0
- package/dist/score/scenario-scoring-features.d.ts +90 -0
- package/dist/score/scenario-scoring-features.js +380 -0
- package/dist/score/scenario-structural-score.d.ts +76 -0
- package/dist/score/scenario-structural-score.js +211 -0
- package/dist/score/score-presentation.d.ts +51 -0
- package/dist/score/score-presentation.js +44 -0
- package/dist/score/score-radar.css +1 -0
- package/dist/sync/DownloadPanel.d.ts +42 -0
- package/dist/sync/DownloadPanel.js +22 -0
- package/dist/sync/SyncSidebar.d.ts +47 -0
- package/dist/sync/SyncSidebar.js +31 -0
- package/dist/sync/UploadPanel.d.ts +64 -0
- package/dist/sync/UploadPanel.js +36 -0
- package/dist/trigger/TriggerBlueprint.d.ts +37 -0
- package/dist/trigger/TriggerBlueprint.js +26 -0
- package/dist/trigger/tmw/TmwBlueprint.d.ts +71 -0
- package/dist/trigger/tmw/TmwBlueprint.js +206 -0
- package/dist/trigger/tmw/TmwFlowLane.d.ts +22 -0
- package/dist/trigger/tmw/TmwFlowLane.js +44 -0
- package/dist/trigger/tmw/TmwSourceLane.d.ts +17 -0
- package/dist/trigger/tmw/TmwSourceLane.js +38 -0
- package/dist/trigger/tmw/TmwTabs.d.ts +20 -0
- package/dist/trigger/tmw/TmwTabs.js +8 -0
- package/dist/trigger/tmw/TmwTraceLane.d.ts +9 -0
- package/dist/trigger/tmw/TmwTraceLane.js +6 -0
- package/dist/trigger/tmw/TriggerDebugTab.d.ts +9 -0
- package/dist/trigger/tmw/TriggerDebugTab.js +25 -0
- package/dist/trigger/tmw/translate.d.ts +3 -0
- package/dist/trigger/tmw/translate.js +2 -0
- package/dist/view/ViewWorkspace.d.ts +19 -0
- package/dist/view/ViewWorkspace.js +8 -0
- package/package.json +89 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { PHYSICS_GRID_SIZE } from "./graph-physics.js";
|
|
2
|
+
export const GRAPH_GROUP_MIN_SIZE = 96;
|
|
3
|
+
export const GRAPH_GROUP_LABEL_MAX_LENGTH = 40;
|
|
4
|
+
/** 不持久化第二份颜色 authority;相同 group id 在 npm/editor 中得到相同实色。 */
|
|
5
|
+
export const graphGroupColor = (id) => {
|
|
6
|
+
let hash = 2166136261;
|
|
7
|
+
for (let index = 0; index < id.length; index += 1) {
|
|
8
|
+
hash ^= id.charCodeAt(index);
|
|
9
|
+
hash = Math.imul(hash, 16777619);
|
|
10
|
+
}
|
|
11
|
+
return `hsl(${(hash >>> 0) % 360} 64% 54%)`;
|
|
12
|
+
};
|
|
13
|
+
export const normalizeGraphGroupLabel = (value) => {
|
|
14
|
+
const normalized = value.trim().slice(0, GRAPH_GROUP_LABEL_MAX_LENGTH);
|
|
15
|
+
return normalized.length === 0 ? null : normalized;
|
|
16
|
+
};
|
|
17
|
+
const isFiniteNumber = (value) => Number.isFinite(value);
|
|
18
|
+
export const isGraphGroupBoundsFinite = (bounds) => isFiniteNumber(bounds.x)
|
|
19
|
+
&& isFiniteNumber(bounds.y)
|
|
20
|
+
&& isFiniteNumber(bounds.width)
|
|
21
|
+
&& isFiniteNumber(bounds.height)
|
|
22
|
+
&& bounds.width >= 0
|
|
23
|
+
&& bounds.height >= 0;
|
|
24
|
+
const assertBounds = (bounds, label) => {
|
|
25
|
+
if (!isGraphGroupBoundsFinite(bounds)) {
|
|
26
|
+
throw new TypeError(`${label} 必须使用有限坐标和非负尺寸`);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const assertPoint = (point, label) => {
|
|
30
|
+
if (!isFiniteNumber(point.x) || !isFiniteNumber(point.y)) {
|
|
31
|
+
throw new TypeError(`${label} 必须使用有限坐标`);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const assertGroups = (groups) => {
|
|
35
|
+
for (const group of groups)
|
|
36
|
+
assertBounds(group, `分组 ${group.id}`);
|
|
37
|
+
};
|
|
38
|
+
export const snapGraphGroupCoordinate = (value) => {
|
|
39
|
+
if (!isFiniteNumber(value))
|
|
40
|
+
throw new TypeError("网格坐标必须是有限数");
|
|
41
|
+
const snapped = Math.round(value / PHYSICS_GRID_SIZE) * PHYSICS_GRID_SIZE;
|
|
42
|
+
return Object.is(snapped, -0) ? 0 : snapped;
|
|
43
|
+
};
|
|
44
|
+
export const snapGraphGroupPoint = (point) => {
|
|
45
|
+
assertPoint(point, "网格点");
|
|
46
|
+
return {
|
|
47
|
+
x: snapGraphGroupCoordinate(point.x),
|
|
48
|
+
y: snapGraphGroupCoordinate(point.y)
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
/** 同时吸附两角,避免 position 与 size 使用不同网格 authority。 */
|
|
52
|
+
export const snapGraphGroupBounds = (bounds) => {
|
|
53
|
+
assertBounds(bounds, "分组矩形");
|
|
54
|
+
const start = snapGraphGroupPoint(bounds);
|
|
55
|
+
const end = snapGraphGroupPoint({
|
|
56
|
+
x: bounds.x + bounds.width,
|
|
57
|
+
y: bounds.y + bounds.height
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
x: start.x,
|
|
61
|
+
y: start.y,
|
|
62
|
+
width: Math.max(0, end.x - start.x),
|
|
63
|
+
height: Math.max(0, end.y - start.y)
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
/** 画框可向任意方向进行;返回归一化且已吸附的非负矩形。 */
|
|
67
|
+
export const graphGroupBoundsFromPoints = (start, end) => {
|
|
68
|
+
const snappedStart = snapGraphGroupPoint(start);
|
|
69
|
+
const snappedEnd = snapGraphGroupPoint(end);
|
|
70
|
+
return {
|
|
71
|
+
x: Math.min(snappedStart.x, snappedEnd.x),
|
|
72
|
+
y: Math.min(snappedStart.y, snappedEnd.y),
|
|
73
|
+
width: Math.abs(snappedEnd.x - snappedStart.x),
|
|
74
|
+
height: Math.abs(snappedEnd.y - snappedStart.y)
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
export const graphGroupsOverlap = (left, right) => {
|
|
78
|
+
assertBounds(left, "左侧分组");
|
|
79
|
+
assertBounds(right, "右侧分组");
|
|
80
|
+
return left.x < right.x + right.width
|
|
81
|
+
&& left.x + left.width > right.x
|
|
82
|
+
&& left.y < right.y + right.height
|
|
83
|
+
&& left.y + left.height > right.y;
|
|
84
|
+
};
|
|
85
|
+
export const isGraphGroupBoundsValid = (candidate, groups, excludeId) => {
|
|
86
|
+
if (!isGraphGroupBoundsFinite(candidate)
|
|
87
|
+
|| candidate.width < GRAPH_GROUP_MIN_SIZE
|
|
88
|
+
|| candidate.height < GRAPH_GROUP_MIN_SIZE)
|
|
89
|
+
return false;
|
|
90
|
+
if (groups.some((group) => !isGraphGroupBoundsFinite(group)))
|
|
91
|
+
return false;
|
|
92
|
+
return groups.every((group) => group.id === excludeId || !graphGroupsOverlap(candidate, group));
|
|
93
|
+
};
|
|
94
|
+
/** 所有矩形必须有效,且任何两个矩形只能分离或边界相贴。 */
|
|
95
|
+
export const validateGraphGroups = (groups) => {
|
|
96
|
+
const ids = new Set();
|
|
97
|
+
for (let left = 0; left < groups.length; left += 1) {
|
|
98
|
+
const group = groups[left];
|
|
99
|
+
if (group.id.length === 0 || ids.has(group.id)
|
|
100
|
+
|| !isGraphGroupBoundsValid(group, groups, group.id))
|
|
101
|
+
return false;
|
|
102
|
+
ids.add(group.id);
|
|
103
|
+
for (let right = left + 1; right < groups.length; right += 1) {
|
|
104
|
+
if (graphGroupsOverlap(group, groups[right]))
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
};
|
|
110
|
+
export const nodesInsideGraphGroup = (group, centers) => {
|
|
111
|
+
assertBounds(group, "成员分组");
|
|
112
|
+
for (const [id, center] of Object.entries(centers))
|
|
113
|
+
assertPoint(center, `节点 ${id}`);
|
|
114
|
+
return Object.entries(centers)
|
|
115
|
+
.filter(([, center]) => center.x >= group.x
|
|
116
|
+
&& center.x < group.x + group.width
|
|
117
|
+
&& center.y >= group.y
|
|
118
|
+
&& center.y < group.y + group.height)
|
|
119
|
+
.map(([id]) => id);
|
|
120
|
+
};
|
|
121
|
+
const sweepAxis = (movingMin, movingMax, obstacleMin, obstacleMax, delta) => {
|
|
122
|
+
if (delta > 0)
|
|
123
|
+
return {
|
|
124
|
+
entry: (obstacleMin - movingMax) / delta,
|
|
125
|
+
exit: (obstacleMax - movingMin) / delta
|
|
126
|
+
};
|
|
127
|
+
if (delta < 0)
|
|
128
|
+
return {
|
|
129
|
+
entry: (obstacleMax - movingMin) / delta,
|
|
130
|
+
exit: (obstacleMin - movingMax) / delta
|
|
131
|
+
};
|
|
132
|
+
return movingMin < obstacleMax && movingMax > obstacleMin
|
|
133
|
+
? { entry: Number.NEGATIVE_INFINITY, exit: Number.POSITIVE_INFINITY }
|
|
134
|
+
: null;
|
|
135
|
+
};
|
|
136
|
+
/** 返回从 source 到 obstacle 首次产生正面积相交的比例;终点穿过障碍也会截停。 */
|
|
137
|
+
const sweptCollisionTime = (source, delta, obstacle) => {
|
|
138
|
+
const x = sweepAxis(source.x, source.x + source.width, obstacle.x, obstacle.x + obstacle.width, delta.x);
|
|
139
|
+
const y = sweepAxis(source.y, source.y + source.height, obstacle.y, obstacle.y + obstacle.height, delta.y);
|
|
140
|
+
if (x === null || y === null)
|
|
141
|
+
return null;
|
|
142
|
+
const entry = Math.max(x.entry, y.entry, 0);
|
|
143
|
+
const exit = Math.min(x.exit, y.exit, 1);
|
|
144
|
+
return entry < exit && entry <= 1 && exit >= 0 ? entry : null;
|
|
145
|
+
};
|
|
146
|
+
const snapTowardSource = (value, delta) => {
|
|
147
|
+
const scaled = value / PHYSICS_GRID_SIZE;
|
|
148
|
+
if (delta > 0)
|
|
149
|
+
return Math.floor(scaled + 1e-9) * PHYSICS_GRID_SIZE;
|
|
150
|
+
if (delta < 0)
|
|
151
|
+
return Math.ceil(scaled - 1e-9) * PHYSICS_GRID_SIZE;
|
|
152
|
+
return value;
|
|
153
|
+
};
|
|
154
|
+
export const clampGraphGroupTranslation = (source, requestedDelta, groups, excludeId) => {
|
|
155
|
+
assertBounds(source, "待移动分组");
|
|
156
|
+
assertPoint(requestedDelta, "移动距离");
|
|
157
|
+
assertGroups(groups);
|
|
158
|
+
const snappedTarget = snapGraphGroupPoint({
|
|
159
|
+
x: source.x + requestedDelta.x,
|
|
160
|
+
y: source.y + requestedDelta.y
|
|
161
|
+
});
|
|
162
|
+
const delta = { x: snappedTarget.x - source.x, y: snappedTarget.y - source.y };
|
|
163
|
+
let movementRatio = 1;
|
|
164
|
+
for (const group of groups) {
|
|
165
|
+
if (group.id === excludeId)
|
|
166
|
+
continue;
|
|
167
|
+
const collisionTime = sweptCollisionTime(source, delta, group);
|
|
168
|
+
if (collisionTime !== null)
|
|
169
|
+
movementRatio = Math.min(movementRatio, collisionTime);
|
|
170
|
+
}
|
|
171
|
+
// 碰撞发生在两次网格采样之间时,沿移动方向向 source 一侧吸附,避免
|
|
172
|
+
// 最近舍入把矩形送进障碍;成员套用同一 delta 后也继续位于节点网格。
|
|
173
|
+
const actualDelta = movementRatio < 1
|
|
174
|
+
? {
|
|
175
|
+
x: snapTowardSource(source.x + delta.x * movementRatio, delta.x) - source.x,
|
|
176
|
+
y: snapTowardSource(source.y + delta.y * movementRatio, delta.y) - source.y
|
|
177
|
+
}
|
|
178
|
+
: delta;
|
|
179
|
+
return {
|
|
180
|
+
bounds: {
|
|
181
|
+
x: source.x + actualDelta.x,
|
|
182
|
+
y: source.y + actualDelta.y,
|
|
183
|
+
width: source.width,
|
|
184
|
+
height: source.height
|
|
185
|
+
},
|
|
186
|
+
delta: actualDelta
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
const overlapsOnAxis = (leftStart, leftEnd, rightStart, rightEnd) => leftStart < rightEnd && leftEnd > rightStart;
|
|
190
|
+
export const resizeGraphGroupSide = (source, side, requestedCoordinate, groups, excludeId) => {
|
|
191
|
+
assertBounds(source, "待缩放分组");
|
|
192
|
+
assertGroups(groups);
|
|
193
|
+
const coordinate = snapGraphGroupCoordinate(requestedCoordinate);
|
|
194
|
+
const right = source.x + source.width;
|
|
195
|
+
const bottom = source.y + source.height;
|
|
196
|
+
let edge = side === "left"
|
|
197
|
+
? Math.min(coordinate, right - GRAPH_GROUP_MIN_SIZE)
|
|
198
|
+
: side === "right"
|
|
199
|
+
? Math.max(coordinate, source.x + GRAPH_GROUP_MIN_SIZE)
|
|
200
|
+
: side === "top"
|
|
201
|
+
? Math.min(coordinate, bottom - GRAPH_GROUP_MIN_SIZE)
|
|
202
|
+
: Math.max(coordinate, source.y + GRAPH_GROUP_MIN_SIZE);
|
|
203
|
+
for (const group of groups) {
|
|
204
|
+
if (group.id === excludeId)
|
|
205
|
+
continue;
|
|
206
|
+
const groupRight = group.x + group.width;
|
|
207
|
+
const groupBottom = group.y + group.height;
|
|
208
|
+
if ((side === "left" || side === "right")
|
|
209
|
+
&& !overlapsOnAxis(source.y, bottom, group.y, groupBottom))
|
|
210
|
+
continue;
|
|
211
|
+
if ((side === "top" || side === "bottom")
|
|
212
|
+
&& !overlapsOnAxis(source.x, right, group.x, groupRight))
|
|
213
|
+
continue;
|
|
214
|
+
if (side === "left" && edge < source.x && groupRight <= source.x && groupRight > edge) {
|
|
215
|
+
edge = groupRight;
|
|
216
|
+
}
|
|
217
|
+
else if (side === "right" && edge > right && group.x >= right && group.x < edge) {
|
|
218
|
+
edge = group.x;
|
|
219
|
+
}
|
|
220
|
+
else if (side === "top" && edge < source.y && groupBottom <= source.y && groupBottom > edge) {
|
|
221
|
+
edge = groupBottom;
|
|
222
|
+
}
|
|
223
|
+
else if (side === "bottom" && edge > bottom && group.y >= bottom && group.y < edge) {
|
|
224
|
+
edge = group.y;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (side === "left")
|
|
228
|
+
return { x: edge, y: source.y, width: right - edge, height: source.height };
|
|
229
|
+
if (side === "right")
|
|
230
|
+
return { x: source.x, y: source.y, width: edge - source.x, height: source.height };
|
|
231
|
+
if (side === "top")
|
|
232
|
+
return { x: source.x, y: edge, width: source.width, height: bottom - edge };
|
|
233
|
+
return { x: source.x, y: source.y, width: source.width, height: edge - source.y };
|
|
234
|
+
};
|
|
235
|
+
/** 使用 clamp 返回的同一 delta 更新成员,成员之间的相对偏移不会改变。 */
|
|
236
|
+
export const translateGraphGroupMembers = (centers, memberIds, delta) => {
|
|
237
|
+
assertPoint(delta, "成员移动距离");
|
|
238
|
+
const memberSet = new Set(memberIds);
|
|
239
|
+
return Object.fromEntries(Object.entries(centers).map(([id, center]) => {
|
|
240
|
+
assertPoint(center, `节点 ${id}`);
|
|
241
|
+
return [id, memberSet.has(id)
|
|
242
|
+
? { x: center.x + delta.x, y: center.y + delta.y }
|
|
243
|
+
: center];
|
|
244
|
+
}));
|
|
245
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 物理模块统一使用节点圆心坐标;GraphCanvas 的 canonical 48px 左上角换算只留在调用 seam。
|
|
3
|
+
*/
|
|
4
|
+
export interface GraphLayoutPosition {
|
|
5
|
+
readonly x: number;
|
|
6
|
+
readonly y: number;
|
|
7
|
+
}
|
|
8
|
+
export interface GraphLayoutNode extends GraphLayoutPosition {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly radius: number;
|
|
11
|
+
}
|
|
12
|
+
export interface GraphLayoutEdge {
|
|
13
|
+
readonly from: string;
|
|
14
|
+
readonly to: string;
|
|
15
|
+
}
|
|
16
|
+
export interface GraphLayoutRequest {
|
|
17
|
+
readonly nodes: readonly GraphLayoutNode[];
|
|
18
|
+
readonly edges: readonly GraphLayoutEdge[];
|
|
19
|
+
/** 被拖节点保持 request 中的圆心坐标,其他节点绕它退让。 */
|
|
20
|
+
readonly fixedNodeId?: string;
|
|
21
|
+
}
|
|
22
|
+
export type GraphLayoutSolver = (request: GraphLayoutRequest, signal?: AbortSignal) => Promise<Readonly<Record<string, GraphLayoutPosition>>>;
|
|
23
|
+
/** 每个 Canvas 宿主持有一个 handle,并在 unmount/场景切换时释放 Worker。 */
|
|
24
|
+
export type GraphLayoutSolverHandle = GraphLayoutSolver & {
|
|
25
|
+
dispose(): void;
|
|
26
|
+
};
|
|
27
|
+
export interface PackedGraphLayoutRequest {
|
|
28
|
+
readonly nodeIds: readonly string[];
|
|
29
|
+
readonly nodes: Float64Array;
|
|
30
|
+
readonly edges: Uint32Array;
|
|
31
|
+
readonly fixedIndex: number;
|
|
32
|
+
}
|
|
33
|
+
export declare const packGraphLayoutRequest: (request: GraphLayoutRequest) => PackedGraphLayoutRequest;
|
|
34
|
+
export declare const unpackGraphLayoutPositions: (nodeIds: readonly string[], packed: Float64Array) => Readonly<Record<string, GraphLayoutPosition>>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const packGraphLayoutRequest = (request) => {
|
|
2
|
+
const nodeIds = [];
|
|
3
|
+
const indexById = new Map();
|
|
4
|
+
const nodes = new Float64Array(request.nodes.length * 3);
|
|
5
|
+
request.nodes.forEach((node, index) => {
|
|
6
|
+
if (node.id.length === 0 || indexById.has(node.id)) {
|
|
7
|
+
throw new TypeError(`图布局节点 id 必须非空且唯一: ${node.id}`);
|
|
8
|
+
}
|
|
9
|
+
if (!Number.isFinite(node.x) || !Number.isFinite(node.y)) {
|
|
10
|
+
throw new TypeError(`图布局节点坐标必须是有限数: ${node.id}`);
|
|
11
|
+
}
|
|
12
|
+
if (!Number.isFinite(node.radius) || node.radius <= 0) {
|
|
13
|
+
throw new TypeError(`图布局节点半径必须是正有限数: ${node.id}`);
|
|
14
|
+
}
|
|
15
|
+
nodeIds.push(node.id);
|
|
16
|
+
indexById.set(node.id, index);
|
|
17
|
+
const offset = index * 3;
|
|
18
|
+
nodes[offset] = node.x;
|
|
19
|
+
nodes[offset + 1] = node.y;
|
|
20
|
+
nodes[offset + 2] = node.radius;
|
|
21
|
+
});
|
|
22
|
+
const edges = new Uint32Array(request.edges.length * 2);
|
|
23
|
+
request.edges.forEach((edge, index) => {
|
|
24
|
+
const from = indexById.get(edge.from);
|
|
25
|
+
const to = indexById.get(edge.to);
|
|
26
|
+
if (from === undefined || to === undefined) {
|
|
27
|
+
throw new TypeError(`图布局关系引用了未知节点: ${edge.from} -> ${edge.to}`);
|
|
28
|
+
}
|
|
29
|
+
edges[index * 2] = from;
|
|
30
|
+
edges[index * 2 + 1] = to;
|
|
31
|
+
});
|
|
32
|
+
let fixedIndex = -1;
|
|
33
|
+
if (request.fixedNodeId !== undefined) {
|
|
34
|
+
fixedIndex = indexById.get(request.fixedNodeId) ?? -1;
|
|
35
|
+
if (fixedIndex < 0) {
|
|
36
|
+
throw new TypeError(`图布局 fixedNodeId 引用了未知节点: ${request.fixedNodeId}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { nodeIds, nodes, edges, fixedIndex };
|
|
40
|
+
};
|
|
41
|
+
export const unpackGraphLayoutPositions = (nodeIds, packed) => {
|
|
42
|
+
if (packed.length !== nodeIds.length * 2) {
|
|
43
|
+
throw new Error("WASM 图布局返回的 position 长度不匹配");
|
|
44
|
+
}
|
|
45
|
+
const positions = {};
|
|
46
|
+
nodeIds.forEach((id, index) => {
|
|
47
|
+
const x = packed[index * 2];
|
|
48
|
+
const y = packed[index * 2 + 1];
|
|
49
|
+
if (x === undefined || y === undefined || !Number.isFinite(x) || !Number.isFinite(y)) {
|
|
50
|
+
throw new Error(`WASM 图布局返回了非法坐标: ${id}`);
|
|
51
|
+
}
|
|
52
|
+
positions[id] = { x, y };
|
|
53
|
+
});
|
|
54
|
+
return positions;
|
|
55
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type GraphLayoutSolverHandle } from "./graph-layout-contract.js";
|
|
2
|
+
import type { GraphLayoutWorkerResponse, GraphLayoutWorkerSolveMessage } from "./graph-layout-worker-protocol.js";
|
|
3
|
+
interface WorkerLike {
|
|
4
|
+
onmessage: ((event: MessageEvent<GraphLayoutWorkerResponse>) => void) | null;
|
|
5
|
+
onerror: ((event: ErrorEvent) => void) | null;
|
|
6
|
+
postMessage(message: GraphLayoutWorkerSolveMessage, transfer: Transferable[]): void;
|
|
7
|
+
terminate(): void;
|
|
8
|
+
}
|
|
9
|
+
type GraphLayoutWorkerFactory = () => WorkerLike;
|
|
10
|
+
/**
|
|
11
|
+
* 两个宿主共用的唯一 factory。每次新调用都会 supersede 尚未完成的旧帧;
|
|
12
|
+
* worker 和主线程各自检查 generation,旧结果永远不会写回 UI。
|
|
13
|
+
*/
|
|
14
|
+
export declare const createGraphLayoutSolverWithWorker: (workerFactory: GraphLayoutWorkerFactory) => GraphLayoutSolverHandle;
|
|
15
|
+
export declare const createGraphLayoutSolver: () => GraphLayoutSolverHandle;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { packGraphLayoutRequest, unpackGraphLayoutPositions } from "./graph-layout-contract.js";
|
|
2
|
+
const abortError = (message) => Object.assign(new Error(message), { name: "AbortError" });
|
|
3
|
+
const defaultWorkerFactory = () => new Worker(new URL("./graph-layout-worker.js", import.meta.url), { name: "worldengine-graph-layout", type: "module" });
|
|
4
|
+
/**
|
|
5
|
+
* 两个宿主共用的唯一 factory。每次新调用都会 supersede 尚未完成的旧帧;
|
|
6
|
+
* worker 和主线程各自检查 generation,旧结果永远不会写回 UI。
|
|
7
|
+
*/
|
|
8
|
+
export const createGraphLayoutSolverWithWorker = (workerFactory) => {
|
|
9
|
+
const worker = workerFactory();
|
|
10
|
+
let generation = 0;
|
|
11
|
+
let pending;
|
|
12
|
+
let disposed = false;
|
|
13
|
+
const rejectPending = (reason) => {
|
|
14
|
+
const current = pending;
|
|
15
|
+
pending = undefined;
|
|
16
|
+
if (current === undefined)
|
|
17
|
+
return;
|
|
18
|
+
current.removeAbortListener();
|
|
19
|
+
current.reject(reason);
|
|
20
|
+
};
|
|
21
|
+
worker.onmessage = (event) => {
|
|
22
|
+
if (disposed)
|
|
23
|
+
return;
|
|
24
|
+
const response = event.data;
|
|
25
|
+
const current = pending;
|
|
26
|
+
if (current === undefined || response.generation !== current.generation || response.generation !== generation) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
pending = undefined;
|
|
30
|
+
current.removeAbortListener();
|
|
31
|
+
if (response.kind === "error") {
|
|
32
|
+
current.reject(new Error(response.message));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
current.resolve(unpackGraphLayoutPositions(current.nodeIds, new Float64Array(response.positions)));
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
current.reject(error);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
worker.onerror = (event) => {
|
|
43
|
+
if (disposed)
|
|
44
|
+
return;
|
|
45
|
+
rejectPending(new Error(event.message || "图布局 worker 执行失败"));
|
|
46
|
+
};
|
|
47
|
+
const solve = ((request, signal) => {
|
|
48
|
+
if (disposed)
|
|
49
|
+
return Promise.reject(new Error("图布局 solver 已释放"));
|
|
50
|
+
if (signal?.aborted === true)
|
|
51
|
+
return Promise.reject(abortError("图布局请求已取消"));
|
|
52
|
+
const packed = packGraphLayoutRequest(request);
|
|
53
|
+
rejectPending(abortError("图布局请求已被更新帧取代"));
|
|
54
|
+
generation += 1;
|
|
55
|
+
const requestGeneration = generation;
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const onAbort = () => {
|
|
58
|
+
if (pending?.generation !== requestGeneration)
|
|
59
|
+
return;
|
|
60
|
+
rejectPending(abortError("图布局请求已取消"));
|
|
61
|
+
};
|
|
62
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
63
|
+
pending = {
|
|
64
|
+
generation: requestGeneration,
|
|
65
|
+
nodeIds: packed.nodeIds,
|
|
66
|
+
resolve,
|
|
67
|
+
reject,
|
|
68
|
+
removeAbortListener: () => signal?.removeEventListener("abort", onAbort)
|
|
69
|
+
};
|
|
70
|
+
const message = {
|
|
71
|
+
kind: "solve",
|
|
72
|
+
generation: requestGeneration,
|
|
73
|
+
nodes: packed.nodes.buffer,
|
|
74
|
+
edges: packed.edges.buffer,
|
|
75
|
+
fixedIndex: packed.fixedIndex
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
worker.postMessage(message, [packed.nodes.buffer, packed.edges.buffer]);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
rejectPending(error);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
solve.dispose = () => {
|
|
86
|
+
if (disposed)
|
|
87
|
+
return;
|
|
88
|
+
disposed = true;
|
|
89
|
+
rejectPending(abortError("图布局 solver 已释放"));
|
|
90
|
+
worker.onmessage = null;
|
|
91
|
+
worker.onerror = null;
|
|
92
|
+
worker.terminate();
|
|
93
|
+
};
|
|
94
|
+
return solve;
|
|
95
|
+
};
|
|
96
|
+
export const createGraphLayoutSolver = () => createGraphLayoutSolverWithWorker(defaultWorkerFactory);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type GraphLayoutRequest, type GraphLayoutPosition, type PackedGraphLayoutRequest } from "./graph-layout-contract.js";
|
|
2
|
+
export declare class GraphLayoutWasm {
|
|
3
|
+
#private;
|
|
4
|
+
constructor(exports: WebAssembly.Exports);
|
|
5
|
+
solvePacked(request: PackedGraphLayoutRequest): Float64Array;
|
|
6
|
+
solve(request: GraphLayoutRequest): Readonly<Record<string, GraphLayoutPosition>>;
|
|
7
|
+
}
|
|
8
|
+
export declare const instantiateGraphLayoutWasm: (bytes: BufferSource) => Promise<GraphLayoutWasm>;
|
|
9
|
+
export declare const loadDefaultGraphLayoutWasm: () => Promise<GraphLayoutWasm>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { packGraphLayoutRequest, unpackGraphLayoutPositions } from "./graph-layout-contract.js";
|
|
2
|
+
const MAGIC = 0x5745_474c;
|
|
3
|
+
const VERSION = 1;
|
|
4
|
+
const HEADER_BYTES = 24;
|
|
5
|
+
const NODE_BYTES = 24;
|
|
6
|
+
const EDGE_BYTES = 8;
|
|
7
|
+
const NO_FIXED_NODE = 0xffff_ffff;
|
|
8
|
+
const assertExports = (exports) => {
|
|
9
|
+
const candidate = exports;
|
|
10
|
+
if (!(candidate.memory instanceof WebAssembly.Memory)
|
|
11
|
+
|| typeof candidate.graph_layout_alloc !== "function"
|
|
12
|
+
|| typeof candidate.graph_layout_dealloc !== "function"
|
|
13
|
+
|| typeof candidate.graph_layout_solve !== "function") {
|
|
14
|
+
throw new Error("图布局 WASM 导出不完整");
|
|
15
|
+
}
|
|
16
|
+
return candidate;
|
|
17
|
+
};
|
|
18
|
+
export class GraphLayoutWasm {
|
|
19
|
+
#exports;
|
|
20
|
+
constructor(exports) {
|
|
21
|
+
this.#exports = assertExports(exports);
|
|
22
|
+
}
|
|
23
|
+
solvePacked(request) {
|
|
24
|
+
const nodeCount = request.nodes.length / 3;
|
|
25
|
+
const edgeCount = request.edges.length / 2;
|
|
26
|
+
if (!Number.isInteger(nodeCount) || !Number.isInteger(edgeCount)) {
|
|
27
|
+
throw new TypeError("图布局 packed request 长度非法");
|
|
28
|
+
}
|
|
29
|
+
if (nodeCount === 0)
|
|
30
|
+
return new Float64Array();
|
|
31
|
+
const byteLength = HEADER_BYTES + nodeCount * NODE_BYTES + edgeCount * EDGE_BYTES;
|
|
32
|
+
const pointer = this.#exports.graph_layout_alloc(byteLength);
|
|
33
|
+
if (pointer === 0)
|
|
34
|
+
throw new Error("图布局 WASM 内存分配失败");
|
|
35
|
+
try {
|
|
36
|
+
const view = new DataView(this.#exports.memory.buffer, pointer, byteLength);
|
|
37
|
+
view.setUint32(0, MAGIC, true);
|
|
38
|
+
view.setUint32(4, VERSION, true);
|
|
39
|
+
view.setUint32(8, nodeCount, true);
|
|
40
|
+
view.setUint32(12, edgeCount, true);
|
|
41
|
+
view.setUint32(16, request.fixedIndex < 0 ? NO_FIXED_NODE : request.fixedIndex, true);
|
|
42
|
+
view.setUint32(20, 0, true);
|
|
43
|
+
for (let index = 0; index < nodeCount; index += 1) {
|
|
44
|
+
const packedOffset = index * 3;
|
|
45
|
+
const wasmOffset = HEADER_BYTES + index * NODE_BYTES;
|
|
46
|
+
view.setFloat64(wasmOffset, request.nodes[packedOffset], true);
|
|
47
|
+
view.setFloat64(wasmOffset + 8, request.nodes[packedOffset + 1], true);
|
|
48
|
+
view.setFloat64(wasmOffset + 16, request.nodes[packedOffset + 2], true);
|
|
49
|
+
}
|
|
50
|
+
const edgeOffset = HEADER_BYTES + nodeCount * NODE_BYTES;
|
|
51
|
+
for (let index = 0; index < request.edges.length; index += 1) {
|
|
52
|
+
view.setUint32(edgeOffset + index * 4, request.edges[index], true);
|
|
53
|
+
}
|
|
54
|
+
const status = this.#exports.graph_layout_solve(pointer, byteLength);
|
|
55
|
+
if (status !== 0)
|
|
56
|
+
throw new Error(`图布局 WASM 求解失败,状态码 ${status}`);
|
|
57
|
+
// solve 可能触发 memory.grow,必须重新取得 buffer。
|
|
58
|
+
const resultView = new DataView(this.#exports.memory.buffer, pointer, byteLength);
|
|
59
|
+
const result = new Float64Array(nodeCount * 2);
|
|
60
|
+
for (let index = 0; index < nodeCount; index += 1) {
|
|
61
|
+
const wasmOffset = HEADER_BYTES + index * NODE_BYTES;
|
|
62
|
+
result[index * 2] = resultView.getFloat64(wasmOffset, true);
|
|
63
|
+
result[index * 2 + 1] = resultView.getFloat64(wasmOffset + 8, true);
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
this.#exports.graph_layout_dealloc(pointer, byteLength);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
solve(request) {
|
|
72
|
+
const packed = packGraphLayoutRequest(request);
|
|
73
|
+
return unpackGraphLayoutPositions(packed.nodeIds, this.solvePacked(packed));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export const instantiateGraphLayoutWasm = async (bytes) => {
|
|
77
|
+
const result = await WebAssembly.instantiate(bytes, {});
|
|
78
|
+
const instance = result instanceof WebAssembly.Instance ? result : result.instance;
|
|
79
|
+
return new GraphLayoutWasm(instance.exports);
|
|
80
|
+
};
|
|
81
|
+
export const loadDefaultGraphLayoutWasm = async () => {
|
|
82
|
+
const url = new URL("./graph-layout.wasm", import.meta.url);
|
|
83
|
+
const response = await fetch(url);
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new Error(`无法加载图布局 WASM (${response.status})`);
|
|
86
|
+
}
|
|
87
|
+
return instantiateGraphLayoutWasm(await response.arrayBuffer());
|
|
88
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface GraphLayoutWorkerSolveMessage {
|
|
2
|
+
readonly kind: "solve";
|
|
3
|
+
readonly generation: number;
|
|
4
|
+
readonly nodes: ArrayBuffer;
|
|
5
|
+
readonly edges: ArrayBuffer;
|
|
6
|
+
readonly fixedIndex: number;
|
|
7
|
+
}
|
|
8
|
+
export interface GraphLayoutWorkerResultMessage {
|
|
9
|
+
readonly kind: "result";
|
|
10
|
+
readonly generation: number;
|
|
11
|
+
readonly positions: ArrayBuffer;
|
|
12
|
+
}
|
|
13
|
+
export interface GraphLayoutWorkerErrorMessage {
|
|
14
|
+
readonly kind: "error";
|
|
15
|
+
readonly generation: number;
|
|
16
|
+
readonly message: string;
|
|
17
|
+
}
|
|
18
|
+
export type GraphLayoutWorkerResponse = GraphLayoutWorkerResultMessage | GraphLayoutWorkerErrorMessage;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
import { loadDefaultGraphLayoutWasm } from "./graph-layout-wasm.js";
|
|
3
|
+
const workerScope = self;
|
|
4
|
+
let runtimePromise;
|
|
5
|
+
let newestGeneration = 0;
|
|
6
|
+
let pending;
|
|
7
|
+
let scheduled = false;
|
|
8
|
+
const scheduleLatest = () => {
|
|
9
|
+
if (scheduled)
|
|
10
|
+
return;
|
|
11
|
+
scheduled = true;
|
|
12
|
+
setTimeout(() => { void solveLatest(); }, 0);
|
|
13
|
+
};
|
|
14
|
+
const solveLatest = async () => {
|
|
15
|
+
scheduled = false;
|
|
16
|
+
const request = pending;
|
|
17
|
+
pending = undefined;
|
|
18
|
+
if (request === undefined)
|
|
19
|
+
return;
|
|
20
|
+
try {
|
|
21
|
+
runtimePromise ??= loadDefaultGraphLayoutWasm();
|
|
22
|
+
const runtime = await runtimePromise;
|
|
23
|
+
// WASM 下载期间到达的新帧会直接取代本帧,本帧不再消耗求解时间。
|
|
24
|
+
if (request.generation !== newestGeneration) {
|
|
25
|
+
scheduleLatest();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const packed = {
|
|
29
|
+
nodeIds: [],
|
|
30
|
+
nodes: new Float64Array(request.nodes),
|
|
31
|
+
edges: new Uint32Array(request.edges),
|
|
32
|
+
fixedIndex: request.fixedIndex
|
|
33
|
+
};
|
|
34
|
+
const positions = runtime.solvePacked(packed);
|
|
35
|
+
if (request.generation === newestGeneration) {
|
|
36
|
+
const response = {
|
|
37
|
+
kind: "result",
|
|
38
|
+
generation: request.generation,
|
|
39
|
+
positions: positions.buffer
|
|
40
|
+
};
|
|
41
|
+
workerScope.postMessage(response, [positions.buffer]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (request.generation === newestGeneration) {
|
|
46
|
+
const response = {
|
|
47
|
+
kind: "error",
|
|
48
|
+
generation: request.generation,
|
|
49
|
+
message: error instanceof Error ? error.message : String(error)
|
|
50
|
+
};
|
|
51
|
+
workerScope.postMessage(response);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
if (pending !== undefined)
|
|
56
|
+
scheduleLatest();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
workerScope.onmessage = (event) => {
|
|
60
|
+
const request = event.data;
|
|
61
|
+
if (request.kind !== "solve" || !Number.isSafeInteger(request.generation))
|
|
62
|
+
return;
|
|
63
|
+
newestGeneration = Math.max(newestGeneration, request.generation);
|
|
64
|
+
pending = request;
|
|
65
|
+
scheduleLatest();
|
|
66
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createGraphLayoutSolver } from "./graph-layout-solver.js";
|
|
Binary file
|