@lingkkkk/atlas-mindmap 0.1.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/PERFORMANCE.md +159 -0
- package/README.md +161 -0
- package/dist/index.cjs +2048 -0
- package/dist/index.css +237 -0
- package/dist/index.d.ts +486 -0
- package/dist/index.global.js +2053 -0
- package/dist/index.js +2035 -0
- package/package.json +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2035 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
3
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
4
|
+
|
|
5
|
+
// ../core/src/events.ts
|
|
6
|
+
var EventEmitter = class {
|
|
7
|
+
constructor() {
|
|
8
|
+
__publicField(this, "listeners", /* @__PURE__ */ new Map());
|
|
9
|
+
}
|
|
10
|
+
on(event, listener) {
|
|
11
|
+
const listeners = this.listeners.get(event) ?? /* @__PURE__ */ new Set();
|
|
12
|
+
listeners.add(listener);
|
|
13
|
+
this.listeners.set(event, listeners);
|
|
14
|
+
return () => listeners.delete(listener);
|
|
15
|
+
}
|
|
16
|
+
emit(event, payload) {
|
|
17
|
+
const listeners = this.listeners.get(event);
|
|
18
|
+
if (!listeners) return;
|
|
19
|
+
for (const listener of listeners) listener(payload);
|
|
20
|
+
}
|
|
21
|
+
clear() {
|
|
22
|
+
this.listeners.clear();
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ../core/src/history.ts
|
|
27
|
+
var History = class {
|
|
28
|
+
constructor() {
|
|
29
|
+
__publicField(this, "undoEntries", []);
|
|
30
|
+
__publicField(this, "redoEntries", []);
|
|
31
|
+
}
|
|
32
|
+
get canUndo() {
|
|
33
|
+
return this.undoEntries.length > 0;
|
|
34
|
+
}
|
|
35
|
+
get canRedo() {
|
|
36
|
+
return this.redoEntries.length > 0;
|
|
37
|
+
}
|
|
38
|
+
execute(entry) {
|
|
39
|
+
entry.redo();
|
|
40
|
+
this.undoEntries.push(entry);
|
|
41
|
+
this.redoEntries.length = 0;
|
|
42
|
+
}
|
|
43
|
+
undo() {
|
|
44
|
+
const entry = this.undoEntries.pop();
|
|
45
|
+
if (!entry) return void 0;
|
|
46
|
+
entry.undo();
|
|
47
|
+
this.redoEntries.push(entry);
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
redo() {
|
|
51
|
+
const entry = this.redoEntries.pop();
|
|
52
|
+
if (!entry) return void 0;
|
|
53
|
+
entry.redo();
|
|
54
|
+
this.undoEntries.push(entry);
|
|
55
|
+
return entry;
|
|
56
|
+
}
|
|
57
|
+
clear() {
|
|
58
|
+
this.undoEntries.length = 0;
|
|
59
|
+
this.redoEntries.length = 0;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ../core/src/topic-store.ts
|
|
64
|
+
var cloneValue = (value) => {
|
|
65
|
+
if (value === void 0 || value === null) return value;
|
|
66
|
+
if (typeof structuredClone === "function") return structuredClone(value);
|
|
67
|
+
return JSON.parse(JSON.stringify(value));
|
|
68
|
+
};
|
|
69
|
+
var normalizeTopic = (input, parentId, childIds) => ({
|
|
70
|
+
id: input.id,
|
|
71
|
+
text: input.text,
|
|
72
|
+
parentId,
|
|
73
|
+
childIds,
|
|
74
|
+
expanded: input.expanded ?? true,
|
|
75
|
+
kind: input.kind ?? (parentId === null ? "central" : "subtopic"),
|
|
76
|
+
structure: input.structure,
|
|
77
|
+
style: cloneValue(input.style),
|
|
78
|
+
markers: cloneValue(input.markers),
|
|
79
|
+
labels: cloneValue(input.labels),
|
|
80
|
+
note: input.note,
|
|
81
|
+
hyperlink: input.hyperlink,
|
|
82
|
+
image: input.image,
|
|
83
|
+
attachment: input.attachment,
|
|
84
|
+
equation: input.equation,
|
|
85
|
+
task: cloneValue(input.task),
|
|
86
|
+
extensions: cloneValue(input.extensions)
|
|
87
|
+
});
|
|
88
|
+
var cloneTopic = (topic) => ({
|
|
89
|
+
...topic,
|
|
90
|
+
childIds: [...topic.childIds],
|
|
91
|
+
style: cloneValue(topic.style),
|
|
92
|
+
markers: cloneValue(topic.markers),
|
|
93
|
+
labels: cloneValue(topic.labels),
|
|
94
|
+
task: cloneValue(topic.task),
|
|
95
|
+
extensions: cloneValue(topic.extensions)
|
|
96
|
+
});
|
|
97
|
+
var TopicStore = class _TopicStore {
|
|
98
|
+
constructor(rootId) {
|
|
99
|
+
__publicField(this, "rootId");
|
|
100
|
+
__publicField(this, "topics", /* @__PURE__ */ new Map());
|
|
101
|
+
this.rootId = rootId;
|
|
102
|
+
}
|
|
103
|
+
static fromTree(root) {
|
|
104
|
+
if (!root?.id) throw new Error("Root topic requires a stable id");
|
|
105
|
+
const store = new _TopicStore(root.id);
|
|
106
|
+
const stack = [
|
|
107
|
+
{ input: root, parentId: null }
|
|
108
|
+
];
|
|
109
|
+
while (stack.length > 0) {
|
|
110
|
+
const current = stack.pop();
|
|
111
|
+
if (!current) break;
|
|
112
|
+
const { input, parentId } = current;
|
|
113
|
+
if (!input.id) throw new Error("Every topic requires a stable id");
|
|
114
|
+
if (store.topics.has(input.id)) throw new Error(`Duplicate topic id: ${input.id}`);
|
|
115
|
+
const children = input.children ?? [];
|
|
116
|
+
store.topics.set(
|
|
117
|
+
input.id,
|
|
118
|
+
normalizeTopic(
|
|
119
|
+
input,
|
|
120
|
+
parentId,
|
|
121
|
+
children.map((child) => child.id)
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
125
|
+
const child = children[index];
|
|
126
|
+
if (child) stack.push({ input: child, parentId: input.id });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return store;
|
|
130
|
+
}
|
|
131
|
+
get size() {
|
|
132
|
+
return this.topics.size;
|
|
133
|
+
}
|
|
134
|
+
has(id) {
|
|
135
|
+
return this.topics.has(id);
|
|
136
|
+
}
|
|
137
|
+
get(id) {
|
|
138
|
+
const topic = this.topics.get(id);
|
|
139
|
+
if (!topic) throw new Error(`Unknown topic: ${id}`);
|
|
140
|
+
return topic;
|
|
141
|
+
}
|
|
142
|
+
tryGet(id) {
|
|
143
|
+
return this.topics.get(id);
|
|
144
|
+
}
|
|
145
|
+
values() {
|
|
146
|
+
return this.topics.values();
|
|
147
|
+
}
|
|
148
|
+
clone(id) {
|
|
149
|
+
return cloneTopic(this.get(id));
|
|
150
|
+
}
|
|
151
|
+
add(parentId, input, index) {
|
|
152
|
+
const parent = this.topics.get(parentId);
|
|
153
|
+
if (!parent) throw new Error(`Parent topic does not exist: ${parentId}`);
|
|
154
|
+
if (this.topics.has(input.id)) throw new Error(`Duplicate topic id: ${input.id}`);
|
|
155
|
+
if ((input.children?.length ?? 0) > 0) {
|
|
156
|
+
throw new Error("Use restoreSubtree when adding a nested topic tree");
|
|
157
|
+
}
|
|
158
|
+
const topic = normalizeTopic(input, parentId, []);
|
|
159
|
+
const targetIndex = Math.max(0, Math.min(index ?? parent.childIds.length, parent.childIds.length));
|
|
160
|
+
parent.childIds.splice(targetIndex, 0, topic.id);
|
|
161
|
+
this.topics.set(topic.id, topic);
|
|
162
|
+
return topic;
|
|
163
|
+
}
|
|
164
|
+
update(id, patch) {
|
|
165
|
+
const topic = this.get(id);
|
|
166
|
+
if (patch.text !== void 0 && patch.text.trim().length === 0) {
|
|
167
|
+
throw new Error("Topic text cannot be empty");
|
|
168
|
+
}
|
|
169
|
+
Object.assign(topic, cloneValue(patch));
|
|
170
|
+
return topic;
|
|
171
|
+
}
|
|
172
|
+
move(id, parentId, index) {
|
|
173
|
+
const topic = this.get(id);
|
|
174
|
+
const targetParent = this.topics.get(parentId);
|
|
175
|
+
if (!targetParent) throw new Error(`Parent topic does not exist: ${parentId}`);
|
|
176
|
+
if (id === this.rootId) throw new Error("Root topic cannot be moved");
|
|
177
|
+
if (id === parentId || this.isAncestor(id, parentId)) {
|
|
178
|
+
throw new Error("Move would create a cycle");
|
|
179
|
+
}
|
|
180
|
+
const oldParent = topic.parentId ? this.get(topic.parentId) : void 0;
|
|
181
|
+
if (oldParent) {
|
|
182
|
+
const oldIndex = oldParent.childIds.indexOf(id);
|
|
183
|
+
if (oldIndex >= 0) oldParent.childIds.splice(oldIndex, 1);
|
|
184
|
+
}
|
|
185
|
+
const targetIndex = Math.max(
|
|
186
|
+
0,
|
|
187
|
+
Math.min(index ?? targetParent.childIds.length, targetParent.childIds.length)
|
|
188
|
+
);
|
|
189
|
+
targetParent.childIds.splice(targetIndex, 0, id);
|
|
190
|
+
topic.parentId = parentId;
|
|
191
|
+
return topic;
|
|
192
|
+
}
|
|
193
|
+
removeSubtree(id) {
|
|
194
|
+
if (id === this.rootId) throw new Error("Root topic cannot be removed");
|
|
195
|
+
const topic = this.get(id);
|
|
196
|
+
const ids = [];
|
|
197
|
+
const stack = [id];
|
|
198
|
+
while (stack.length > 0) {
|
|
199
|
+
const currentId = stack.pop();
|
|
200
|
+
if (!currentId) break;
|
|
201
|
+
ids.push(currentId);
|
|
202
|
+
const current = this.get(currentId);
|
|
203
|
+
for (let index = current.childIds.length - 1; index >= 0; index -= 1) {
|
|
204
|
+
const childId = current.childIds[index];
|
|
205
|
+
if (childId) stack.push(childId);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const removed = ids.map((topicId) => cloneTopic(this.get(topicId)));
|
|
209
|
+
if (topic.parentId) {
|
|
210
|
+
const parent = this.get(topic.parentId);
|
|
211
|
+
const index = parent.childIds.indexOf(id);
|
|
212
|
+
if (index >= 0) parent.childIds.splice(index, 1);
|
|
213
|
+
}
|
|
214
|
+
for (const topicId of ids) this.topics.delete(topicId);
|
|
215
|
+
return removed;
|
|
216
|
+
}
|
|
217
|
+
restoreSubtree(topics, parentId, index) {
|
|
218
|
+
const parent = this.get(parentId);
|
|
219
|
+
if (topics.length === 0) throw new Error("Cannot restore an empty subtree");
|
|
220
|
+
for (const topic of topics) {
|
|
221
|
+
if (this.topics.has(topic.id)) throw new Error(`Duplicate topic id: ${topic.id}`);
|
|
222
|
+
}
|
|
223
|
+
const root = topics.find((topic) => topic.parentId === parentId);
|
|
224
|
+
if (!root) throw new Error("Subtree snapshot does not contain a restorable root");
|
|
225
|
+
for (const topic of topics) this.topics.set(topic.id, cloneTopic(topic));
|
|
226
|
+
const targetIndex = Math.max(0, Math.min(index, parent.childIds.length));
|
|
227
|
+
parent.childIds.splice(targetIndex, 0, root.id);
|
|
228
|
+
}
|
|
229
|
+
setExpanded(id, expanded) {
|
|
230
|
+
const topic = this.get(id);
|
|
231
|
+
topic.expanded = expanded;
|
|
232
|
+
return topic.expanded;
|
|
233
|
+
}
|
|
234
|
+
toggle(id) {
|
|
235
|
+
const topic = this.get(id);
|
|
236
|
+
topic.expanded = !topic.expanded;
|
|
237
|
+
return topic.expanded;
|
|
238
|
+
}
|
|
239
|
+
isAncestor(ancestorId, possibleDescendantId) {
|
|
240
|
+
let cursor = this.tryGet(possibleDescendantId);
|
|
241
|
+
while (cursor?.parentId) {
|
|
242
|
+
if (cursor.parentId === ancestorId) return true;
|
|
243
|
+
cursor = this.tryGet(cursor.parentId);
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
depthOf(id) {
|
|
248
|
+
let depth = 0;
|
|
249
|
+
let cursor = this.get(id);
|
|
250
|
+
while (cursor.parentId) {
|
|
251
|
+
depth += 1;
|
|
252
|
+
cursor = this.get(cursor.parentId);
|
|
253
|
+
}
|
|
254
|
+
return depth;
|
|
255
|
+
}
|
|
256
|
+
visibleIds() {
|
|
257
|
+
const result = [];
|
|
258
|
+
const stack = [this.rootId];
|
|
259
|
+
while (stack.length > 0) {
|
|
260
|
+
const id = stack.pop();
|
|
261
|
+
if (!id) break;
|
|
262
|
+
result.push(id);
|
|
263
|
+
const topic = this.get(id);
|
|
264
|
+
if (!topic.expanded) continue;
|
|
265
|
+
for (let index = topic.childIds.length - 1; index >= 0; index -= 1) {
|
|
266
|
+
const childId = topic.childIds[index];
|
|
267
|
+
if (childId) stack.push(childId);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
toTree() {
|
|
273
|
+
const result = /* @__PURE__ */ new Map();
|
|
274
|
+
for (const topic of this.topics.values()) {
|
|
275
|
+
result.set(topic.id, {
|
|
276
|
+
id: topic.id,
|
|
277
|
+
text: topic.text,
|
|
278
|
+
expanded: topic.expanded,
|
|
279
|
+
kind: topic.kind,
|
|
280
|
+
structure: topic.structure,
|
|
281
|
+
style: cloneValue(topic.style),
|
|
282
|
+
markers: cloneValue(topic.markers),
|
|
283
|
+
labels: cloneValue(topic.labels),
|
|
284
|
+
note: topic.note,
|
|
285
|
+
hyperlink: topic.hyperlink,
|
|
286
|
+
image: topic.image,
|
|
287
|
+
attachment: topic.attachment,
|
|
288
|
+
equation: topic.equation,
|
|
289
|
+
task: cloneValue(topic.task),
|
|
290
|
+
extensions: cloneValue(topic.extensions),
|
|
291
|
+
children: []
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
for (const topic of this.topics.values()) {
|
|
295
|
+
const output = result.get(topic.id);
|
|
296
|
+
if (!output) continue;
|
|
297
|
+
output.children = topic.childIds.map((childId) => result.get(childId)).filter((child) => child !== void 0);
|
|
298
|
+
}
|
|
299
|
+
const root = result.get(this.rootId);
|
|
300
|
+
if (!root) throw new Error("Store has no root topic");
|
|
301
|
+
return root;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// ../core/src/engine.ts
|
|
306
|
+
var cloneValue2 = (value) => {
|
|
307
|
+
if (value === void 0 || value === null) return value;
|
|
308
|
+
if (typeof structuredClone === "function") return structuredClone(value);
|
|
309
|
+
return JSON.parse(JSON.stringify(value));
|
|
310
|
+
};
|
|
311
|
+
var MindMapEngine = class _MindMapEngine {
|
|
312
|
+
constructor(root) {
|
|
313
|
+
__publicField(this, "store");
|
|
314
|
+
__publicField(this, "events", new EventEmitter());
|
|
315
|
+
__publicField(this, "history", new History());
|
|
316
|
+
__publicField(this, "idSequence", 1);
|
|
317
|
+
this.store = TopicStore.fromTree(root);
|
|
318
|
+
}
|
|
319
|
+
static fromJSON(json) {
|
|
320
|
+
const document2 = _MindMapEngine.parseDocument(json);
|
|
321
|
+
return new _MindMapEngine(document2.root);
|
|
322
|
+
}
|
|
323
|
+
get canUndo() {
|
|
324
|
+
return this.history.canUndo;
|
|
325
|
+
}
|
|
326
|
+
get canRedo() {
|
|
327
|
+
return this.history.canRedo;
|
|
328
|
+
}
|
|
329
|
+
on(event, listener) {
|
|
330
|
+
return this.events.on(event, listener);
|
|
331
|
+
}
|
|
332
|
+
addChild(parentId, input) {
|
|
333
|
+
const topic = this.createTopicInput(input);
|
|
334
|
+
const parentWasExpanded = this.store.get(parentId).expanded;
|
|
335
|
+
const entry = {
|
|
336
|
+
label: "Add topic",
|
|
337
|
+
redo: () => {
|
|
338
|
+
this.store.add(parentId, topic);
|
|
339
|
+
this.store.setExpanded(parentId, true);
|
|
340
|
+
},
|
|
341
|
+
undo: () => {
|
|
342
|
+
this.store.removeSubtree(topic.id);
|
|
343
|
+
this.store.setExpanded(parentId, parentWasExpanded);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
this.commit(entry, { type: "add", topicId: topic.id });
|
|
347
|
+
return this.store.get(topic.id);
|
|
348
|
+
}
|
|
349
|
+
addSibling(topicId, input) {
|
|
350
|
+
const reference = this.store.get(topicId);
|
|
351
|
+
if (!reference.parentId) throw new Error("Root topic cannot have a sibling");
|
|
352
|
+
const parent = this.store.get(reference.parentId);
|
|
353
|
+
const topic = this.createTopicInput(input);
|
|
354
|
+
const index = parent.childIds.indexOf(topicId) + 1;
|
|
355
|
+
const entry = {
|
|
356
|
+
label: "Add sibling",
|
|
357
|
+
redo: () => this.store.add(parent.id, topic, index),
|
|
358
|
+
undo: () => {
|
|
359
|
+
this.store.removeSubtree(topic.id);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
this.commit(entry, { type: "add", topicId: topic.id });
|
|
363
|
+
return this.store.get(topic.id);
|
|
364
|
+
}
|
|
365
|
+
updateTopic(topicId, patch) {
|
|
366
|
+
const beforeTopic = this.store.clone(topicId);
|
|
367
|
+
const before = {};
|
|
368
|
+
for (const key of Object.keys(patch)) {
|
|
369
|
+
before[key] = cloneValue2(beforeTopic[key]);
|
|
370
|
+
}
|
|
371
|
+
const next = cloneValue2(patch);
|
|
372
|
+
const entry = {
|
|
373
|
+
label: "Update topic",
|
|
374
|
+
redo: () => {
|
|
375
|
+
this.store.update(topicId, next);
|
|
376
|
+
},
|
|
377
|
+
undo: () => {
|
|
378
|
+
this.store.update(topicId, before);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
const layoutAffecting = patch.text !== void 0 || patch.expanded !== void 0;
|
|
382
|
+
this.commit(entry, { type: "update", topicId, layoutAffecting });
|
|
383
|
+
return this.store.get(topicId);
|
|
384
|
+
}
|
|
385
|
+
deleteTopic(topicId) {
|
|
386
|
+
const topic = this.store.get(topicId);
|
|
387
|
+
if (!topic.parentId) throw new Error("Root topic cannot be removed");
|
|
388
|
+
const parentId = topic.parentId;
|
|
389
|
+
const index = this.store.get(parentId).childIds.indexOf(topicId);
|
|
390
|
+
let snapshot = [];
|
|
391
|
+
const entry = {
|
|
392
|
+
label: "Delete topic",
|
|
393
|
+
redo: () => {
|
|
394
|
+
snapshot = this.store.removeSubtree(topicId);
|
|
395
|
+
},
|
|
396
|
+
undo: () => this.store.restoreSubtree(snapshot, parentId, index)
|
|
397
|
+
};
|
|
398
|
+
this.commit(entry, { type: "delete", topicId });
|
|
399
|
+
}
|
|
400
|
+
moveTopic(topicId, parentId, index) {
|
|
401
|
+
const topic = this.store.get(topicId);
|
|
402
|
+
if (!topic.parentId) throw new Error("Root topic cannot be moved");
|
|
403
|
+
const oldParentId = topic.parentId;
|
|
404
|
+
const oldIndex = this.store.get(oldParentId).childIds.indexOf(topicId);
|
|
405
|
+
const entry = {
|
|
406
|
+
label: "Move topic",
|
|
407
|
+
redo: () => {
|
|
408
|
+
this.store.move(topicId, parentId, index);
|
|
409
|
+
},
|
|
410
|
+
undo: () => {
|
|
411
|
+
this.store.move(topicId, oldParentId, oldIndex);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
this.commit(entry, { type: "move", topicId });
|
|
415
|
+
return this.store.get(topicId);
|
|
416
|
+
}
|
|
417
|
+
toggleTopic(topicId) {
|
|
418
|
+
const before = this.store.get(topicId).expanded;
|
|
419
|
+
const after = !before;
|
|
420
|
+
const entry = {
|
|
421
|
+
label: after ? "Expand topic" : "Collapse topic",
|
|
422
|
+
redo: () => {
|
|
423
|
+
this.store.setExpanded(topicId, after);
|
|
424
|
+
},
|
|
425
|
+
undo: () => {
|
|
426
|
+
this.store.setExpanded(topicId, before);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
this.commit(entry, { type: "toggle", topicId });
|
|
430
|
+
return after;
|
|
431
|
+
}
|
|
432
|
+
setExpanded(topicId, expanded) {
|
|
433
|
+
if (this.store.get(topicId).expanded === expanded) return;
|
|
434
|
+
this.toggleTopic(topicId);
|
|
435
|
+
}
|
|
436
|
+
revealTopic(topicId) {
|
|
437
|
+
let cursor = this.store.get(topicId);
|
|
438
|
+
let changed = false;
|
|
439
|
+
while (cursor.parentId) {
|
|
440
|
+
const parent = this.store.get(cursor.parentId);
|
|
441
|
+
if (!parent.expanded) {
|
|
442
|
+
parent.expanded = true;
|
|
443
|
+
changed = true;
|
|
444
|
+
}
|
|
445
|
+
cursor = parent;
|
|
446
|
+
}
|
|
447
|
+
if (changed) this.events.emit("document:change", { type: "reveal", topicId });
|
|
448
|
+
}
|
|
449
|
+
search(query, limit = 100) {
|
|
450
|
+
const normalized = query.trim().toLocaleLowerCase();
|
|
451
|
+
if (!normalized) return [];
|
|
452
|
+
const result = [];
|
|
453
|
+
for (const topic of this.store.values()) {
|
|
454
|
+
const haystack = [topic.text, topic.note, ...topic.labels ?? []].filter(Boolean).join("\n").toLocaleLowerCase();
|
|
455
|
+
if (!haystack.includes(normalized)) continue;
|
|
456
|
+
result.push({ id: topic.id, text: topic.text, path: this.topicPath(topic.id) });
|
|
457
|
+
if (result.length >= limit) break;
|
|
458
|
+
}
|
|
459
|
+
return result;
|
|
460
|
+
}
|
|
461
|
+
undo() {
|
|
462
|
+
const entry = this.history.undo();
|
|
463
|
+
if (!entry) return false;
|
|
464
|
+
this.events.emit("document:change", { type: "undo", label: entry.label });
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
redo() {
|
|
468
|
+
const entry = this.history.redo();
|
|
469
|
+
if (!entry) return false;
|
|
470
|
+
this.events.emit("document:change", { type: "redo", label: entry.label });
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
exportJSON() {
|
|
474
|
+
const document2 = { version: 1, root: this.store.toTree() };
|
|
475
|
+
return JSON.stringify(document2);
|
|
476
|
+
}
|
|
477
|
+
importJSON(json) {
|
|
478
|
+
const document2 = _MindMapEngine.parseDocument(json);
|
|
479
|
+
const nextStore = TopicStore.fromTree(document2.root);
|
|
480
|
+
this.store = nextStore;
|
|
481
|
+
this.history.clear();
|
|
482
|
+
this.events.emit("document:change", { type: "import" });
|
|
483
|
+
}
|
|
484
|
+
static parseDocument(json) {
|
|
485
|
+
let value;
|
|
486
|
+
try {
|
|
487
|
+
value = JSON.parse(json);
|
|
488
|
+
} catch (cause) {
|
|
489
|
+
throw new Error(`Invalid mind-map document JSON: ${String(cause)}`);
|
|
490
|
+
}
|
|
491
|
+
if (typeof value !== "object" || value === null || !("version" in value) || value.version !== 1 || !("root" in value) || typeof value.root !== "object" || value.root === null || !("id" in value.root) || !("text" in value.root)) {
|
|
492
|
+
throw new Error("Invalid mind-map document structure");
|
|
493
|
+
}
|
|
494
|
+
return value;
|
|
495
|
+
}
|
|
496
|
+
createTopicInput(input) {
|
|
497
|
+
let id = input.id;
|
|
498
|
+
while (!id || this.store.has(id)) {
|
|
499
|
+
id = `topic-user-${this.idSequence}`;
|
|
500
|
+
this.idSequence += 1;
|
|
501
|
+
}
|
|
502
|
+
return { ...cloneValue2(input), id, children: [] };
|
|
503
|
+
}
|
|
504
|
+
topicPath(topicId) {
|
|
505
|
+
const path = [];
|
|
506
|
+
let cursor = this.store.get(topicId);
|
|
507
|
+
while (cursor) {
|
|
508
|
+
path.push(cursor.text);
|
|
509
|
+
cursor = cursor.parentId ? this.store.tryGet(cursor.parentId) : void 0;
|
|
510
|
+
}
|
|
511
|
+
path.reverse();
|
|
512
|
+
return path;
|
|
513
|
+
}
|
|
514
|
+
commit(entry, change) {
|
|
515
|
+
this.history.execute(entry);
|
|
516
|
+
this.events.emit("document:change", change);
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// ../core/src/plugin-manager.ts
|
|
521
|
+
var PluginManager = class {
|
|
522
|
+
constructor(host, onError) {
|
|
523
|
+
__publicField(this, "host", host);
|
|
524
|
+
__publicField(this, "onError", onError);
|
|
525
|
+
__publicField(this, "plugins", /* @__PURE__ */ new Map());
|
|
526
|
+
}
|
|
527
|
+
async use(plugin) {
|
|
528
|
+
if (this.plugins.has(plugin.name)) throw new Error(`Plugin already installed: ${plugin.name}`);
|
|
529
|
+
try {
|
|
530
|
+
await plugin.install(this.host);
|
|
531
|
+
this.plugins.set(plugin.name, plugin);
|
|
532
|
+
} catch (cause) {
|
|
533
|
+
this.onError(plugin, cause);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async destroy() {
|
|
537
|
+
for (const plugin of [...this.plugins.values()].reverse()) {
|
|
538
|
+
try {
|
|
539
|
+
await plugin.destroy?.();
|
|
540
|
+
} catch (cause) {
|
|
541
|
+
this.onError(plugin, cause);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
this.plugins.clear();
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// ../layouts/src/mindmap-layout.ts
|
|
549
|
+
var DEFAULTS = {
|
|
550
|
+
columnGap: 218,
|
|
551
|
+
rootGap: 72,
|
|
552
|
+
rowGap: 18,
|
|
553
|
+
nodeHeight: 36,
|
|
554
|
+
minNodeWidth: 112,
|
|
555
|
+
maxNodeWidth: 190
|
|
556
|
+
};
|
|
557
|
+
var MindMapLayout = class {
|
|
558
|
+
constructor(options = {}) {
|
|
559
|
+
__publicField(this, "options");
|
|
560
|
+
this.options = { ...DEFAULTS, ...options };
|
|
561
|
+
}
|
|
562
|
+
layout(store) {
|
|
563
|
+
const startedAt = performance.now();
|
|
564
|
+
const ids = store.visibleIds();
|
|
565
|
+
const count = ids.length;
|
|
566
|
+
const indexById = /* @__PURE__ */ new Map();
|
|
567
|
+
for (let index = 0; index < count; index += 1) {
|
|
568
|
+
const id = ids[index];
|
|
569
|
+
if (id) indexById.set(id, index);
|
|
570
|
+
}
|
|
571
|
+
const x = new Float32Array(count);
|
|
572
|
+
const y = new Float32Array(count);
|
|
573
|
+
const width = new Float32Array(count);
|
|
574
|
+
const height = new Float32Array(count);
|
|
575
|
+
const depth = new Uint16Array(count);
|
|
576
|
+
const branchIndex = new Uint16Array(count);
|
|
577
|
+
const parentIndices = new Int32Array(count);
|
|
578
|
+
const subtreeUnits = new Float32Array(count);
|
|
579
|
+
parentIndices.fill(-1);
|
|
580
|
+
let maxDepth = 0;
|
|
581
|
+
let nextBranchIndex = 0;
|
|
582
|
+
for (let index = 0; index < count; index += 1) {
|
|
583
|
+
const id = ids[index];
|
|
584
|
+
if (!id) continue;
|
|
585
|
+
const topic = store.get(id);
|
|
586
|
+
const parentIndex = topic.parentId ? indexById.get(topic.parentId) ?? -1 : -1;
|
|
587
|
+
parentIndices[index] = parentIndex;
|
|
588
|
+
const topicDepth = parentIndex >= 0 ? (depth[parentIndex] ?? 0) + 1 : 0;
|
|
589
|
+
depth[index] = topicDepth;
|
|
590
|
+
if (topicDepth === 1) {
|
|
591
|
+
branchIndex[index] = nextBranchIndex;
|
|
592
|
+
nextBranchIndex += 1;
|
|
593
|
+
} else if (parentIndex >= 0) {
|
|
594
|
+
branchIndex[index] = branchIndex[parentIndex] ?? 0;
|
|
595
|
+
}
|
|
596
|
+
maxDepth = Math.max(maxDepth, topicDepth);
|
|
597
|
+
const depthMinimum = topicDepth === 0 ? 220 : topicDepth === 1 ? Math.max(148, this.options.minNodeWidth) : this.options.minNodeWidth;
|
|
598
|
+
const depthMaximum = topicDepth === 0 ? 260 : this.options.maxNodeWidth;
|
|
599
|
+
const estimated = depthMinimum + Math.max(0, topic.text.length - 18) * 4.2;
|
|
600
|
+
width[index] = Math.min(depthMaximum, estimated);
|
|
601
|
+
height[index] = this.options.nodeHeight;
|
|
602
|
+
}
|
|
603
|
+
for (let index = count - 1; index >= 0; index -= 1) {
|
|
604
|
+
const id = ids[index];
|
|
605
|
+
if (!id) continue;
|
|
606
|
+
const topic = store.get(id);
|
|
607
|
+
let units = 0;
|
|
608
|
+
if (topic.expanded) {
|
|
609
|
+
for (const childId of topic.childIds) {
|
|
610
|
+
const childIndex = indexById.get(childId);
|
|
611
|
+
if (childIndex !== void 0) units += subtreeUnits[childIndex] ?? 0;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
subtreeUnits[index] = Math.max(1, units);
|
|
615
|
+
}
|
|
616
|
+
const rowPitch = this.options.nodeHeight + this.options.rowGap;
|
|
617
|
+
const rootWidth = width[0] ?? 0;
|
|
618
|
+
const stack = [{ index: 0, start: 0 }];
|
|
619
|
+
while (stack.length > 0) {
|
|
620
|
+
const frame = stack.pop();
|
|
621
|
+
if (!frame) break;
|
|
622
|
+
const { index, start } = frame;
|
|
623
|
+
const id = ids[index];
|
|
624
|
+
if (!id) continue;
|
|
625
|
+
const topic = store.get(id);
|
|
626
|
+
const nodeDepth = depth[index] ?? 0;
|
|
627
|
+
x[index] = nodeDepth === 0 ? 0 : rootWidth + this.options.rootGap + (nodeDepth - 1) * this.options.columnGap;
|
|
628
|
+
y[index] = (start + (subtreeUnits[index] ?? 1) / 2) * rowPitch - this.options.nodeHeight / 2;
|
|
629
|
+
let childStart = start;
|
|
630
|
+
const children = [];
|
|
631
|
+
if (topic.expanded) {
|
|
632
|
+
for (const childId of topic.childIds) {
|
|
633
|
+
const childIndex = indexById.get(childId);
|
|
634
|
+
if (childIndex === void 0) continue;
|
|
635
|
+
children.push({ index: childIndex, start: childStart });
|
|
636
|
+
childStart += subtreeUnits[childIndex] ?? 1;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
for (let childIndex = children.length - 1; childIndex >= 0; childIndex -= 1) {
|
|
640
|
+
const child = children[childIndex];
|
|
641
|
+
if (child) stack.push(child);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
645
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
646
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
647
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
648
|
+
for (let index = 0; index < count; index += 1) {
|
|
649
|
+
minX = Math.min(minX, x[index] ?? 0);
|
|
650
|
+
minY = Math.min(minY, y[index] ?? 0);
|
|
651
|
+
maxX = Math.max(maxX, (x[index] ?? 0) + (width[index] ?? 0));
|
|
652
|
+
maxY = Math.max(maxY, (y[index] ?? 0) + (height[index] ?? 0));
|
|
653
|
+
}
|
|
654
|
+
const bounds = count === 0 ? { x: 0, y: 0, width: 0, height: 0 } : { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
655
|
+
return {
|
|
656
|
+
ids,
|
|
657
|
+
indexById,
|
|
658
|
+
parentIndices,
|
|
659
|
+
x,
|
|
660
|
+
y,
|
|
661
|
+
width,
|
|
662
|
+
height,
|
|
663
|
+
depth,
|
|
664
|
+
branchIndex,
|
|
665
|
+
bounds,
|
|
666
|
+
maxDepth,
|
|
667
|
+
durationMs: performance.now() - startedAt
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// ../renderer-canvas/src/camera.ts
|
|
673
|
+
var Camera = class {
|
|
674
|
+
constructor(options = {}) {
|
|
675
|
+
__publicField(this, "x");
|
|
676
|
+
__publicField(this, "y");
|
|
677
|
+
__publicField(this, "scale");
|
|
678
|
+
__publicField(this, "minScale");
|
|
679
|
+
__publicField(this, "maxScale");
|
|
680
|
+
this.x = options.x ?? 0;
|
|
681
|
+
this.y = options.y ?? 0;
|
|
682
|
+
this.scale = options.scale ?? 1;
|
|
683
|
+
this.minScale = options.minScale ?? 0.02;
|
|
684
|
+
this.maxScale = options.maxScale ?? 5;
|
|
685
|
+
this.scale = this.clampScale(this.scale);
|
|
686
|
+
}
|
|
687
|
+
get state() {
|
|
688
|
+
return { x: this.x, y: this.y, scale: this.scale };
|
|
689
|
+
}
|
|
690
|
+
screenToWorld(point) {
|
|
691
|
+
return { x: (point.x - this.x) / this.scale, y: (point.y - this.y) / this.scale };
|
|
692
|
+
}
|
|
693
|
+
worldToScreen(point) {
|
|
694
|
+
return { x: point.x * this.scale + this.x, y: point.y * this.scale + this.y };
|
|
695
|
+
}
|
|
696
|
+
panBy(dx, dy) {
|
|
697
|
+
this.x += dx;
|
|
698
|
+
this.y += dy;
|
|
699
|
+
}
|
|
700
|
+
zoomBy(factor, anchor) {
|
|
701
|
+
this.zoomTo(this.scale * factor, anchor);
|
|
702
|
+
}
|
|
703
|
+
zoomTo(scale, anchor) {
|
|
704
|
+
const worldAnchor = this.screenToWorld(anchor);
|
|
705
|
+
this.scale = this.clampScale(scale);
|
|
706
|
+
this.x = anchor.x - worldAnchor.x * this.scale;
|
|
707
|
+
this.y = anchor.y - worldAnchor.y * this.scale;
|
|
708
|
+
}
|
|
709
|
+
fit(bounds, viewport, padding = 48) {
|
|
710
|
+
if (bounds.width <= 0 || bounds.height <= 0) return;
|
|
711
|
+
const availableWidth = Math.max(1, viewport.width - padding * 2);
|
|
712
|
+
const availableHeight = Math.max(1, viewport.height - padding * 2);
|
|
713
|
+
this.scale = this.clampScale(
|
|
714
|
+
Math.min(availableWidth / bounds.width, availableHeight / bounds.height)
|
|
715
|
+
);
|
|
716
|
+
this.x = (viewport.width - bounds.width * this.scale) / 2 - bounds.x * this.scale;
|
|
717
|
+
this.y = (viewport.height - bounds.height * this.scale) / 2 - bounds.y * this.scale;
|
|
718
|
+
}
|
|
719
|
+
center(bounds, viewport) {
|
|
720
|
+
this.x = viewport.width / 2 - (bounds.x + bounds.width / 2) * this.scale;
|
|
721
|
+
this.y = viewport.height / 2 - (bounds.y + bounds.height / 2) * this.scale;
|
|
722
|
+
}
|
|
723
|
+
reset() {
|
|
724
|
+
this.x = 0;
|
|
725
|
+
this.y = 0;
|
|
726
|
+
this.scale = 1;
|
|
727
|
+
}
|
|
728
|
+
viewport(width, height) {
|
|
729
|
+
const topLeft = this.screenToWorld({ x: 0, y: 0 });
|
|
730
|
+
return {
|
|
731
|
+
x: topLeft.x,
|
|
732
|
+
y: topLeft.y,
|
|
733
|
+
width: width / this.scale,
|
|
734
|
+
height: height / this.scale
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
clampScale(scale) {
|
|
738
|
+
return Math.min(this.maxScale, Math.max(this.minScale, scale));
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// ../renderer-canvas/src/metrics.ts
|
|
743
|
+
var percentile = (sorted, value) => {
|
|
744
|
+
if (sorted.length === 0) return 0;
|
|
745
|
+
const index = Math.max(0, Math.ceil(value * sorted.length) - 1);
|
|
746
|
+
return sorted[Math.min(index, sorted.length - 1)] ?? 0;
|
|
747
|
+
};
|
|
748
|
+
var RollingMetrics = class {
|
|
749
|
+
constructor(capacity = 120) {
|
|
750
|
+
__publicField(this, "capacity", capacity);
|
|
751
|
+
__publicField(this, "samples", []);
|
|
752
|
+
}
|
|
753
|
+
record(sample) {
|
|
754
|
+
this.samples.push(sample);
|
|
755
|
+
if (this.samples.length > this.capacity) this.samples.shift();
|
|
756
|
+
}
|
|
757
|
+
snapshot() {
|
|
758
|
+
const last = this.samples[this.samples.length - 1];
|
|
759
|
+
const frameTimes = this.samples.map((sample) => sample.frameMs).sort((a, b) => a - b);
|
|
760
|
+
const first = this.samples[0];
|
|
761
|
+
const elapsed = first && last ? last.timestamp - first.timestamp : 0;
|
|
762
|
+
const fps = elapsed > 0 ? (this.samples.length - 1) * 1e3 / elapsed : 0;
|
|
763
|
+
return {
|
|
764
|
+
fps,
|
|
765
|
+
frameP50: percentile(frameTimes, 0.5),
|
|
766
|
+
frameP95: percentile(frameTimes, 0.95),
|
|
767
|
+
frameP99: percentile(frameTimes, 0.99),
|
|
768
|
+
cullMs: last?.cullMs ?? 0,
|
|
769
|
+
drawMs: last?.drawMs ?? 0,
|
|
770
|
+
rendered: last?.rendered ?? 0,
|
|
771
|
+
sampleCount: this.samples.length
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
// ../renderer-canvas/src/spatial-index.ts
|
|
777
|
+
var cellKey = (x, y) => `${x}:${y}`;
|
|
778
|
+
var SpatialIndex = class {
|
|
779
|
+
constructor(cellSize = 768) {
|
|
780
|
+
__publicField(this, "cellSize", cellSize);
|
|
781
|
+
__publicField(this, "buckets", /* @__PURE__ */ new Map());
|
|
782
|
+
__publicField(this, "layout");
|
|
783
|
+
__publicField(this, "marks", new Uint32Array(0));
|
|
784
|
+
__publicField(this, "generation", 0);
|
|
785
|
+
}
|
|
786
|
+
build(layout) {
|
|
787
|
+
this.layout = layout;
|
|
788
|
+
this.buckets.clear();
|
|
789
|
+
this.marks = new Uint32Array(layout.ids.length);
|
|
790
|
+
this.generation = 0;
|
|
791
|
+
for (let index = 0; index < layout.ids.length; index += 1) {
|
|
792
|
+
const minCellX = Math.floor((layout.x[index] ?? 0) / this.cellSize);
|
|
793
|
+
const minCellY = Math.floor((layout.y[index] ?? 0) / this.cellSize);
|
|
794
|
+
const maxCellX = Math.floor(
|
|
795
|
+
((layout.x[index] ?? 0) + (layout.width[index] ?? 0)) / this.cellSize
|
|
796
|
+
);
|
|
797
|
+
const maxCellY = Math.floor(
|
|
798
|
+
((layout.y[index] ?? 0) + (layout.height[index] ?? 0)) / this.cellSize
|
|
799
|
+
);
|
|
800
|
+
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
|
|
801
|
+
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
|
|
802
|
+
const key = cellKey(cellX, cellY);
|
|
803
|
+
const bucket = this.buckets.get(key) ?? [];
|
|
804
|
+
bucket.push(index);
|
|
805
|
+
this.buckets.set(key, bucket);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
query(rect, output = []) {
|
|
811
|
+
output.length = 0;
|
|
812
|
+
const layout = this.layout;
|
|
813
|
+
if (!layout) return output;
|
|
814
|
+
this.generation += 1;
|
|
815
|
+
if (this.generation >= 4294967295) {
|
|
816
|
+
this.marks.fill(0);
|
|
817
|
+
this.generation = 1;
|
|
818
|
+
}
|
|
819
|
+
const minCellX = Math.floor(rect.x / this.cellSize);
|
|
820
|
+
const minCellY = Math.floor(rect.y / this.cellSize);
|
|
821
|
+
const maxCellX = Math.floor((rect.x + rect.width) / this.cellSize);
|
|
822
|
+
const maxCellY = Math.floor((rect.y + rect.height) / this.cellSize);
|
|
823
|
+
const cellCount = (maxCellX - minCellX + 1) * (maxCellY - minCellY + 1);
|
|
824
|
+
if (cellCount > layout.ids.length * 4) {
|
|
825
|
+
const bounds = layout.bounds;
|
|
826
|
+
const containsLayout = rect.x <= bounds.x && rect.y <= bounds.y && rect.x + rect.width >= bounds.x + bounds.width && rect.y + rect.height >= bounds.y + bounds.height;
|
|
827
|
+
for (let index = 0; index < layout.ids.length; index += 1) {
|
|
828
|
+
if (containsLayout || this.intersects(index, rect)) output.push(index);
|
|
829
|
+
}
|
|
830
|
+
return output;
|
|
831
|
+
}
|
|
832
|
+
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
|
|
833
|
+
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
|
|
834
|
+
const bucket = this.buckets.get(cellKey(cellX, cellY));
|
|
835
|
+
if (!bucket) continue;
|
|
836
|
+
for (const index of bucket) {
|
|
837
|
+
if (this.marks[index] === this.generation) continue;
|
|
838
|
+
this.marks[index] = this.generation;
|
|
839
|
+
if (this.intersects(index, rect)) output.push(index);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return output;
|
|
844
|
+
}
|
|
845
|
+
intersects(index, rect) {
|
|
846
|
+
const layout = this.layout;
|
|
847
|
+
if (!layout) return false;
|
|
848
|
+
const x = layout.x[index] ?? 0;
|
|
849
|
+
const y = layout.y[index] ?? 0;
|
|
850
|
+
const width = layout.width[index] ?? 0;
|
|
851
|
+
const height = layout.height[index] ?? 0;
|
|
852
|
+
return x <= rect.x + rect.width && x + width >= rect.x && y <= rect.y + rect.height && y + height >= rect.y;
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
// ../renderer-canvas/src/renderer.ts
|
|
857
|
+
var resolveLod = (scale) => {
|
|
858
|
+
if (scale < 0.07) return "dots";
|
|
859
|
+
if (scale < 0.25) return "blocks";
|
|
860
|
+
if (scale < 0.55) return "labels";
|
|
861
|
+
return "full";
|
|
862
|
+
};
|
|
863
|
+
var themePalettes = {
|
|
864
|
+
dark: {
|
|
865
|
+
accents: ["#8177ff", "#38c9af", "#55a9ff", "#efb85f", "#ed7da7", "#8dcc70", "#b17dea", "#51bdd2"],
|
|
866
|
+
edge: "rgba(121, 137, 177, 0.46)",
|
|
867
|
+
root: "rgba(52, 45, 104, 0.98)",
|
|
868
|
+
rootStroke: "#a79eff",
|
|
869
|
+
branchTopic: "rgba(24, 31, 53, 0.98)",
|
|
870
|
+
topic: "rgba(17, 23, 40, 0.96)",
|
|
871
|
+
deepTopic: "rgba(13, 18, 31, 0.88)",
|
|
872
|
+
topicSelected: "rgba(66, 61, 118, 0.98)",
|
|
873
|
+
text: "#dce4f5",
|
|
874
|
+
textSelected: "#ffffff",
|
|
875
|
+
selection: "#a99cff",
|
|
876
|
+
hover: "#68c7ff",
|
|
877
|
+
dropTarget: "#5ff2c8",
|
|
878
|
+
collapseBadge: "#f6bb5f",
|
|
879
|
+
badgeText: "#10172b",
|
|
880
|
+
drag: "rgba(73, 89, 150, 0.78)",
|
|
881
|
+
dragStroke: "#a99cff"
|
|
882
|
+
},
|
|
883
|
+
light: {
|
|
884
|
+
accents: ["#695ce0", "#168d7d", "#347fc8", "#b87922", "#c45177", "#5c923f", "#8856bd", "#287f94"],
|
|
885
|
+
edge: "rgba(72, 85, 116, 0.38)",
|
|
886
|
+
root: "rgba(239, 237, 255, 0.99)",
|
|
887
|
+
rootStroke: "#6758d9",
|
|
888
|
+
branchTopic: "rgba(255, 255, 255, 0.99)",
|
|
889
|
+
topic: "rgba(252, 253, 255, 0.98)",
|
|
890
|
+
deepTopic: "rgba(246, 248, 252, 0.92)",
|
|
891
|
+
topicSelected: "rgba(227, 225, 255, 0.99)",
|
|
892
|
+
text: "#293248",
|
|
893
|
+
textSelected: "#171d35",
|
|
894
|
+
selection: "#6758d9",
|
|
895
|
+
hover: "#2688c9",
|
|
896
|
+
dropTarget: "#168b74",
|
|
897
|
+
collapseBadge: "#d79a34",
|
|
898
|
+
badgeText: "#ffffff",
|
|
899
|
+
drag: "rgba(219, 224, 255, 0.9)",
|
|
900
|
+
dragStroke: "#6758d9"
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
var CanvasRenderer = class {
|
|
904
|
+
constructor(options) {
|
|
905
|
+
__publicField(this, "camera");
|
|
906
|
+
__publicField(this, "metrics", new RollingMetrics());
|
|
907
|
+
__publicField(this, "canvas");
|
|
908
|
+
__publicField(this, "context");
|
|
909
|
+
__publicField(this, "requestFrame");
|
|
910
|
+
__publicField(this, "cancelFrame");
|
|
911
|
+
__publicField(this, "spatialIndex", new SpatialIndex());
|
|
912
|
+
__publicField(this, "candidates", []);
|
|
913
|
+
__publicField(this, "store");
|
|
914
|
+
__publicField(this, "layout");
|
|
915
|
+
__publicField(this, "width");
|
|
916
|
+
__publicField(this, "height");
|
|
917
|
+
__publicField(this, "dpr", 1);
|
|
918
|
+
__publicField(this, "frameId", null);
|
|
919
|
+
__publicField(this, "destroyed", false);
|
|
920
|
+
__publicField(this, "continuous");
|
|
921
|
+
__publicField(this, "themeName");
|
|
922
|
+
__publicField(this, "palette");
|
|
923
|
+
__publicField(this, "lastTimestamp", 0);
|
|
924
|
+
__publicField(this, "firstRenderTimestampMs", null);
|
|
925
|
+
__publicField(this, "selectedIds", /* @__PURE__ */ new Set());
|
|
926
|
+
__publicField(this, "hoverId");
|
|
927
|
+
__publicField(this, "dragId");
|
|
928
|
+
__publicField(this, "dropTargetId");
|
|
929
|
+
__publicField(this, "dragPoint");
|
|
930
|
+
__publicField(this, "rootIndex", -1);
|
|
931
|
+
__publicField(this, "rootChildren", []);
|
|
932
|
+
__publicField(this, "rootBusBounds");
|
|
933
|
+
this.canvas = options.canvas;
|
|
934
|
+
const context = this.canvas.getContext("2d", { alpha: true });
|
|
935
|
+
if (!context) throw new Error("Canvas 2D rendering context is unavailable");
|
|
936
|
+
this.context = context;
|
|
937
|
+
this.camera = options.camera ?? new Camera();
|
|
938
|
+
this.requestFrame = options.requestFrame ?? ((callback) => window.requestAnimationFrame(callback));
|
|
939
|
+
this.cancelFrame = options.cancelFrame ?? ((id) => window.cancelAnimationFrame(id));
|
|
940
|
+
this.continuous = options.continuous ?? false;
|
|
941
|
+
this.themeName = options.theme ?? "dark";
|
|
942
|
+
this.palette = themePalettes[this.themeName];
|
|
943
|
+
this.width = this.canvas.clientWidth || this.canvas.width || 800;
|
|
944
|
+
this.height = this.canvas.clientHeight || this.canvas.height || 600;
|
|
945
|
+
}
|
|
946
|
+
get firstRenderAtMs() {
|
|
947
|
+
return this.firstRenderTimestampMs;
|
|
948
|
+
}
|
|
949
|
+
setScene(store, layout) {
|
|
950
|
+
this.assertAlive();
|
|
951
|
+
this.store = store;
|
|
952
|
+
this.layout = layout;
|
|
953
|
+
this.spatialIndex.build(layout);
|
|
954
|
+
this.cacheRootBus(layout);
|
|
955
|
+
this.invalidate();
|
|
956
|
+
}
|
|
957
|
+
resize(width, height, dpr = window.devicePixelRatio || 1) {
|
|
958
|
+
this.assertAlive();
|
|
959
|
+
this.width = Math.max(1, width);
|
|
960
|
+
this.height = Math.max(1, height);
|
|
961
|
+
this.dpr = Math.min(2, Math.max(1, dpr));
|
|
962
|
+
this.canvas.width = Math.round(this.width * this.dpr);
|
|
963
|
+
this.canvas.height = Math.round(this.height * this.dpr);
|
|
964
|
+
this.canvas.style.width = `${this.width}px`;
|
|
965
|
+
this.canvas.style.height = `${this.height}px`;
|
|
966
|
+
this.invalidate();
|
|
967
|
+
}
|
|
968
|
+
start() {
|
|
969
|
+
this.assertAlive();
|
|
970
|
+
this.continuous = true;
|
|
971
|
+
this.invalidate();
|
|
972
|
+
}
|
|
973
|
+
stop() {
|
|
974
|
+
this.continuous = false;
|
|
975
|
+
}
|
|
976
|
+
invalidate() {
|
|
977
|
+
this.assertAlive();
|
|
978
|
+
if (this.frameId !== null) return;
|
|
979
|
+
this.frameId = this.requestFrame((timestamp) => this.onFrame(timestamp));
|
|
980
|
+
}
|
|
981
|
+
renderNow(timestamp = performance.now()) {
|
|
982
|
+
this.assertAlive();
|
|
983
|
+
const layout = this.layout;
|
|
984
|
+
const store = this.store;
|
|
985
|
+
if (!layout || !store) return;
|
|
986
|
+
const frameStartedAt = performance.now();
|
|
987
|
+
const worldViewport = this.camera.viewport(this.width, this.height);
|
|
988
|
+
const overscan = 80 / this.camera.scale;
|
|
989
|
+
const queryRect = {
|
|
990
|
+
x: worldViewport.x - overscan,
|
|
991
|
+
y: worldViewport.y - overscan,
|
|
992
|
+
width: worldViewport.width + overscan * 2,
|
|
993
|
+
height: worldViewport.height + overscan * 2
|
|
994
|
+
};
|
|
995
|
+
const cullStartedAt = performance.now();
|
|
996
|
+
this.spatialIndex.query(queryRect, this.candidates);
|
|
997
|
+
const cullMs = performance.now() - cullStartedAt;
|
|
998
|
+
const drawStartedAt = performance.now();
|
|
999
|
+
this.draw(layout, store, this.candidates);
|
|
1000
|
+
const drawMs = performance.now() - drawStartedAt;
|
|
1001
|
+
const interval = this.lastTimestamp > 0 ? timestamp - this.lastTimestamp : performance.now() - frameStartedAt;
|
|
1002
|
+
this.lastTimestamp = timestamp;
|
|
1003
|
+
this.metrics.record({
|
|
1004
|
+
timestamp,
|
|
1005
|
+
frameMs: Math.max(0, interval),
|
|
1006
|
+
cullMs,
|
|
1007
|
+
drawMs,
|
|
1008
|
+
rendered: this.candidates.length
|
|
1009
|
+
});
|
|
1010
|
+
if (this.firstRenderTimestampMs === null) this.firstRenderTimestampMs = performance.now();
|
|
1011
|
+
}
|
|
1012
|
+
hitTest(screenPoint) {
|
|
1013
|
+
this.assertAlive();
|
|
1014
|
+
const layout = this.layout;
|
|
1015
|
+
if (!layout) return void 0;
|
|
1016
|
+
const world = this.camera.screenToWorld(screenPoint);
|
|
1017
|
+
const radius = 5 / this.camera.scale;
|
|
1018
|
+
this.spatialIndex.query(
|
|
1019
|
+
{ x: world.x - radius, y: world.y - radius, width: radius * 2, height: radius * 2 },
|
|
1020
|
+
this.candidates
|
|
1021
|
+
);
|
|
1022
|
+
for (let cursor = this.candidates.length - 1; cursor >= 0; cursor -= 1) {
|
|
1023
|
+
const index = this.candidates[cursor];
|
|
1024
|
+
if (index === void 0) continue;
|
|
1025
|
+
const x = layout.x[index] ?? 0;
|
|
1026
|
+
const y = layout.y[index] ?? 0;
|
|
1027
|
+
if (world.x >= x && world.x <= x + (layout.width[index] ?? 0) && world.y >= y && world.y <= y + (layout.height[index] ?? 0)) {
|
|
1028
|
+
return layout.ids[index];
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return void 0;
|
|
1032
|
+
}
|
|
1033
|
+
getNodeScreenRect(id) {
|
|
1034
|
+
const layout = this.layout;
|
|
1035
|
+
const index = layout?.indexById.get(id);
|
|
1036
|
+
if (!layout || index === void 0) return void 0;
|
|
1037
|
+
const point = this.camera.worldToScreen({ x: layout.x[index] ?? 0, y: layout.y[index] ?? 0 });
|
|
1038
|
+
return {
|
|
1039
|
+
id,
|
|
1040
|
+
x: point.x,
|
|
1041
|
+
y: point.y,
|
|
1042
|
+
width: (layout.width[index] ?? 0) * this.camera.scale,
|
|
1043
|
+
height: (layout.height[index] ?? 0) * this.camera.scale
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
setSelection(ids) {
|
|
1047
|
+
this.selectedIds = new Set(ids);
|
|
1048
|
+
this.invalidate();
|
|
1049
|
+
}
|
|
1050
|
+
setHover(id) {
|
|
1051
|
+
if (this.hoverId === id) return;
|
|
1052
|
+
this.hoverId = id;
|
|
1053
|
+
this.invalidate();
|
|
1054
|
+
}
|
|
1055
|
+
setDragState(id, point, dropTargetId) {
|
|
1056
|
+
this.dragId = id;
|
|
1057
|
+
this.dragPoint = point;
|
|
1058
|
+
this.dropTargetId = dropTargetId;
|
|
1059
|
+
this.invalidate();
|
|
1060
|
+
}
|
|
1061
|
+
get theme() {
|
|
1062
|
+
return this.themeName;
|
|
1063
|
+
}
|
|
1064
|
+
setTheme(theme) {
|
|
1065
|
+
this.assertAlive();
|
|
1066
|
+
if (theme === this.themeName) return;
|
|
1067
|
+
this.themeName = theme;
|
|
1068
|
+
this.palette = themePalettes[theme];
|
|
1069
|
+
this.invalidate();
|
|
1070
|
+
}
|
|
1071
|
+
destroy() {
|
|
1072
|
+
if (this.destroyed) return;
|
|
1073
|
+
this.destroyed = true;
|
|
1074
|
+
this.continuous = false;
|
|
1075
|
+
if (this.frameId !== null) this.cancelFrame(this.frameId);
|
|
1076
|
+
this.frameId = null;
|
|
1077
|
+
this.store = void 0;
|
|
1078
|
+
this.layout = void 0;
|
|
1079
|
+
this.rootIndex = -1;
|
|
1080
|
+
this.rootChildren.length = 0;
|
|
1081
|
+
this.rootBusBounds = void 0;
|
|
1082
|
+
this.selectedIds.clear();
|
|
1083
|
+
}
|
|
1084
|
+
onFrame(timestamp) {
|
|
1085
|
+
this.frameId = null;
|
|
1086
|
+
if (this.destroyed) return;
|
|
1087
|
+
this.renderNow(timestamp);
|
|
1088
|
+
if (this.continuous) this.invalidate();
|
|
1089
|
+
}
|
|
1090
|
+
draw(layout, store, candidates) {
|
|
1091
|
+
const context = this.context;
|
|
1092
|
+
if (typeof context.resetTransform === "function") context.resetTransform();
|
|
1093
|
+
else context.setTransform(1, 0, 0, 1, 0, 0);
|
|
1094
|
+
context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
1095
|
+
context.setTransform(
|
|
1096
|
+
this.dpr * this.camera.scale,
|
|
1097
|
+
0,
|
|
1098
|
+
0,
|
|
1099
|
+
this.dpr * this.camera.scale,
|
|
1100
|
+
this.dpr * this.camera.x,
|
|
1101
|
+
this.dpr * this.camera.y
|
|
1102
|
+
);
|
|
1103
|
+
const lod = resolveLod(this.camera.scale);
|
|
1104
|
+
if (lod !== "dots") {
|
|
1105
|
+
this.drawRootBus(context, layout);
|
|
1106
|
+
this.drawBranchEdges(context, layout, candidates);
|
|
1107
|
+
}
|
|
1108
|
+
for (const index of candidates) this.drawTopic(context, layout, store, index, lod);
|
|
1109
|
+
this.drawDragPreview(context, layout);
|
|
1110
|
+
}
|
|
1111
|
+
drawRootBus(context, layout) {
|
|
1112
|
+
const bounds = this.rootBusBounds;
|
|
1113
|
+
if (this.rootIndex < 0 || this.rootChildren.length === 0 || !bounds) return;
|
|
1114
|
+
const viewport = this.camera.viewport(this.width, this.height);
|
|
1115
|
+
if (bounds.x > viewport.x + viewport.width || bounds.x + bounds.width < viewport.x || bounds.y > viewport.y + viewport.height || bounds.y + bounds.height < viewport.y) return;
|
|
1116
|
+
const rootRight = (layout.x[this.rootIndex] ?? 0) + (layout.width[this.rootIndex] ?? 0);
|
|
1117
|
+
const rootCenterY = (layout.y[this.rootIndex] ?? 0) + (layout.height[this.rootIndex] ?? 0) / 2;
|
|
1118
|
+
const firstChild = this.rootChildren[0];
|
|
1119
|
+
if (firstChild === void 0) return;
|
|
1120
|
+
const firstChildX = layout.x[firstChild] ?? rootRight;
|
|
1121
|
+
const busX = rootRight + (firstChildX - rootRight) * 0.46;
|
|
1122
|
+
let minY = rootCenterY;
|
|
1123
|
+
let maxY = rootCenterY;
|
|
1124
|
+
for (const index of this.rootChildren) {
|
|
1125
|
+
const centerY = (layout.y[index] ?? 0) + (layout.height[index] ?? 0) / 2;
|
|
1126
|
+
minY = Math.min(minY, centerY);
|
|
1127
|
+
maxY = Math.max(maxY, centerY);
|
|
1128
|
+
}
|
|
1129
|
+
context.save();
|
|
1130
|
+
context.lineCap = "round";
|
|
1131
|
+
context.lineJoin = "round";
|
|
1132
|
+
context.globalAlpha = 1;
|
|
1133
|
+
context.lineWidth = 1.65 / Math.max(0.4, this.camera.scale);
|
|
1134
|
+
context.strokeStyle = this.palette.edge;
|
|
1135
|
+
context.beginPath();
|
|
1136
|
+
context.moveTo(rootRight, rootCenterY);
|
|
1137
|
+
context.lineTo(busX, rootCenterY);
|
|
1138
|
+
context.moveTo(busX, minY);
|
|
1139
|
+
context.lineTo(busX, maxY);
|
|
1140
|
+
context.stroke();
|
|
1141
|
+
context.lineWidth = 1.8 / Math.max(0.4, this.camera.scale);
|
|
1142
|
+
context.globalAlpha = 0.72;
|
|
1143
|
+
for (const index of this.rootChildren) {
|
|
1144
|
+
const childY = (layout.y[index] ?? 0) + (layout.height[index] ?? 0) / 2;
|
|
1145
|
+
const colorIndex = (layout.branchIndex[index] ?? 0) % this.palette.accents.length;
|
|
1146
|
+
context.strokeStyle = this.palette.accents[colorIndex] ?? this.palette.accents[0];
|
|
1147
|
+
context.beginPath();
|
|
1148
|
+
context.moveTo(busX, childY);
|
|
1149
|
+
context.lineTo(layout.x[index] ?? firstChildX, childY);
|
|
1150
|
+
context.stroke();
|
|
1151
|
+
}
|
|
1152
|
+
context.restore();
|
|
1153
|
+
}
|
|
1154
|
+
drawBranchEdges(context, layout, candidates) {
|
|
1155
|
+
context.save();
|
|
1156
|
+
context.lineCap = "round";
|
|
1157
|
+
context.lineJoin = "round";
|
|
1158
|
+
context.lineWidth = 1.45 / Math.max(0.4, this.camera.scale);
|
|
1159
|
+
for (const index of candidates) {
|
|
1160
|
+
const parentIndex = layout.parentIndices[index] ?? -1;
|
|
1161
|
+
const nodeDepth = layout.depth[index] ?? 0;
|
|
1162
|
+
if (parentIndex < 0 || nodeDepth === 1) continue;
|
|
1163
|
+
const fromX = (layout.x[parentIndex] ?? 0) + (layout.width[parentIndex] ?? 0);
|
|
1164
|
+
const fromY = (layout.y[parentIndex] ?? 0) + (layout.height[parentIndex] ?? 0) / 2;
|
|
1165
|
+
const toX = layout.x[index] ?? 0;
|
|
1166
|
+
const toY = (layout.y[index] ?? 0) + (layout.height[index] ?? 0) / 2;
|
|
1167
|
+
const control = Math.max(28, (toX - fromX) * 0.46);
|
|
1168
|
+
const colorIndex = (layout.branchIndex[index] ?? 0) % this.palette.accents.length;
|
|
1169
|
+
context.strokeStyle = this.palette.accents[colorIndex] ?? this.palette.edge;
|
|
1170
|
+
context.globalAlpha = Math.max(0.2, 0.52 - nodeDepth * 7e-3);
|
|
1171
|
+
context.beginPath();
|
|
1172
|
+
context.moveTo(fromX, fromY);
|
|
1173
|
+
context.bezierCurveTo(fromX + control, fromY, toX - control, toY, toX, toY);
|
|
1174
|
+
context.stroke();
|
|
1175
|
+
}
|
|
1176
|
+
context.restore();
|
|
1177
|
+
}
|
|
1178
|
+
cacheRootBus(layout) {
|
|
1179
|
+
this.rootChildren.length = 0;
|
|
1180
|
+
this.rootIndex = -1;
|
|
1181
|
+
for (let index = 0; index < layout.ids.length; index += 1) {
|
|
1182
|
+
if ((layout.parentIndices[index] ?? -1) < 0) {
|
|
1183
|
+
this.rootIndex = index;
|
|
1184
|
+
break;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (this.rootIndex < 0) {
|
|
1188
|
+
this.rootBusBounds = void 0;
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
for (let index = 0; index < layout.ids.length; index += 1) {
|
|
1192
|
+
if ((layout.parentIndices[index] ?? -1) === this.rootIndex) this.rootChildren.push(index);
|
|
1193
|
+
}
|
|
1194
|
+
if (this.rootChildren.length === 0) {
|
|
1195
|
+
this.rootBusBounds = void 0;
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
const rootRight = (layout.x[this.rootIndex] ?? 0) + (layout.width[this.rootIndex] ?? 0);
|
|
1199
|
+
let minX = rootRight;
|
|
1200
|
+
let maxX = rootRight;
|
|
1201
|
+
let minY = (layout.y[this.rootIndex] ?? 0) + (layout.height[this.rootIndex] ?? 0) / 2;
|
|
1202
|
+
let maxY = minY;
|
|
1203
|
+
for (const index of this.rootChildren) {
|
|
1204
|
+
const childX = layout.x[index] ?? rootRight;
|
|
1205
|
+
const childY = (layout.y[index] ?? 0) + (layout.height[index] ?? 0) / 2;
|
|
1206
|
+
minX = Math.min(minX, childX);
|
|
1207
|
+
maxX = Math.max(maxX, childX);
|
|
1208
|
+
minY = Math.min(minY, childY);
|
|
1209
|
+
maxY = Math.max(maxY, childY);
|
|
1210
|
+
}
|
|
1211
|
+
this.rootBusBounds = {
|
|
1212
|
+
x: minX,
|
|
1213
|
+
y: minY,
|
|
1214
|
+
width: Math.max(1, maxX - minX),
|
|
1215
|
+
height: Math.max(1, maxY - minY)
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
drawTopic(context, layout, store, index, lod) {
|
|
1219
|
+
const id = layout.ids[index];
|
|
1220
|
+
if (!id) return;
|
|
1221
|
+
const topic = store.get(id);
|
|
1222
|
+
const x = layout.x[index] ?? 0;
|
|
1223
|
+
const y = layout.y[index] ?? 0;
|
|
1224
|
+
const width = layout.width[index] ?? 0;
|
|
1225
|
+
const height = layout.height[index] ?? 0;
|
|
1226
|
+
const nodeDepth = layout.depth[index] ?? 0;
|
|
1227
|
+
const branch = layout.branchIndex[index] ?? 0;
|
|
1228
|
+
const accentColor = topic.style?.border ?? this.palette.accents[branch % this.palette.accents.length] ?? this.palette.accents[0];
|
|
1229
|
+
if (lod === "dots") {
|
|
1230
|
+
context.fillStyle = topic.style?.fill ?? accentColor;
|
|
1231
|
+
context.fillRect(x, y + height / 2 - 2, Math.max(6, width * 0.24), 4);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
const isSelected = this.selectedIds.has(id);
|
|
1235
|
+
const isHovered = this.hoverId === id;
|
|
1236
|
+
const isDropTarget = this.dropTargetId === id;
|
|
1237
|
+
const isRoot = nodeDepth === 0;
|
|
1238
|
+
const isBranch = nodeDepth === 1;
|
|
1239
|
+
const isDeep = nodeDepth > 6;
|
|
1240
|
+
const visualY = isDeep ? y + 3 : y;
|
|
1241
|
+
const visualHeight = isDeep ? Math.max(24, height - 6) : height;
|
|
1242
|
+
const isUnderline = topic.style?.shape === "underline";
|
|
1243
|
+
const tierRadius = isRoot ? 16 : isBranch ? 12 : isDeep ? 6 : 9;
|
|
1244
|
+
const shapeRadius = topic.style?.shape === "pill" ? visualHeight / 2 : topic.style?.shape === "rect" ? 2 : tierRadius;
|
|
1245
|
+
context.save();
|
|
1246
|
+
context.globalAlpha = 1;
|
|
1247
|
+
if (isRoot) {
|
|
1248
|
+
context.shadowColor = topic.style?.fill ?? accentColor;
|
|
1249
|
+
context.shadowBlur = 22 / Math.max(0.6, this.camera.scale);
|
|
1250
|
+
}
|
|
1251
|
+
context.strokeStyle = isDropTarget ? this.palette.dropTarget : isSelected ? this.palette.selection : isHovered ? this.palette.hover : topic.style?.border ?? (isRoot ? this.palette.rootStroke : accentColor);
|
|
1252
|
+
context.globalAlpha = isSelected || isHovered || isDropTarget || isRoot ? 1 : isDeep ? 0.42 : isBranch ? 0.82 : 0.58;
|
|
1253
|
+
context.lineWidth = (isSelected || isHovered || isDropTarget ? 2.5 : 1.25) / Math.max(0.5, this.camera.scale);
|
|
1254
|
+
context.beginPath();
|
|
1255
|
+
if (isUnderline) {
|
|
1256
|
+
context.moveTo(x, visualY + visualHeight - 1);
|
|
1257
|
+
context.lineTo(x + width, visualY + visualHeight - 1);
|
|
1258
|
+
context.stroke();
|
|
1259
|
+
} else {
|
|
1260
|
+
if (typeof context.roundRect === "function") {
|
|
1261
|
+
context.roundRect(x, visualY, width, visualHeight, shapeRadius);
|
|
1262
|
+
} else context.rect(x, visualY, width, visualHeight);
|
|
1263
|
+
context.fillStyle = isSelected ? this.palette.topicSelected : topic.style?.fill ?? (isRoot ? this.palette.root : isBranch ? this.palette.branchTopic : isDeep ? this.palette.deepTopic : this.palette.topic);
|
|
1264
|
+
context.fill();
|
|
1265
|
+
context.shadowBlur = 0;
|
|
1266
|
+
context.stroke();
|
|
1267
|
+
}
|
|
1268
|
+
context.globalAlpha = 1;
|
|
1269
|
+
context.shadowBlur = 0;
|
|
1270
|
+
context.fillStyle = accentColor;
|
|
1271
|
+
if (isUnderline) ; else if (isBranch) {
|
|
1272
|
+
context.fillRect(x, visualY + 7, 4 / Math.max(0.55, this.camera.scale), visualHeight - 14);
|
|
1273
|
+
} else if (isDeep) {
|
|
1274
|
+
context.fillRect(x + 10, visualY + visualHeight - 2, Math.min(56, width - 20), 2);
|
|
1275
|
+
} else if (!isRoot) {
|
|
1276
|
+
context.fillRect(x, visualY + 5, 3 / Math.max(0.55, this.camera.scale), visualHeight - 10);
|
|
1277
|
+
}
|
|
1278
|
+
if (lod === "blocks") {
|
|
1279
|
+
context.restore();
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
context.fillStyle = topic.style?.color ?? (isSelected ? this.palette.textSelected : this.palette.text);
|
|
1283
|
+
const fontSize = topic.style?.fontSize ?? (isRoot ? 15 : isBranch ? 13 : isDeep ? 11 : 12);
|
|
1284
|
+
const fontWeight = topic.style?.fontWeight ?? (isRoot ? 720 : isBranch ? 650 : isDeep ? 520 : 570);
|
|
1285
|
+
context.font = `${fontWeight} ${fontSize}px Inter, ui-sans-serif, system-ui, sans-serif`;
|
|
1286
|
+
context.textBaseline = "middle";
|
|
1287
|
+
const labelInset = isRoot ? 18 : isDeep ? 10 : 14;
|
|
1288
|
+
const maxCharacters = Math.max(8, Math.floor((width - labelInset - 10) / (fontSize * 0.53)));
|
|
1289
|
+
const label = topic.text.length > maxCharacters ? `${topic.text.slice(0, maxCharacters - 1)}\u2026` : topic.text;
|
|
1290
|
+
context.fillText(label, x + labelInset, visualY + visualHeight / 2, width - labelInset - 8);
|
|
1291
|
+
if (lod === "full" && topic.childIds.length > 0) {
|
|
1292
|
+
const badgeSize = 14;
|
|
1293
|
+
const badgeX = x + width - badgeSize / 2;
|
|
1294
|
+
const badgeY = visualY + visualHeight / 2 - badgeSize / 2;
|
|
1295
|
+
context.beginPath();
|
|
1296
|
+
if (typeof context.roundRect === "function") {
|
|
1297
|
+
context.roundRect(badgeX, badgeY, badgeSize, badgeSize, badgeSize / 2);
|
|
1298
|
+
} else context.rect(badgeX, badgeY, badgeSize, badgeSize);
|
|
1299
|
+
context.fillStyle = topic.expanded ? accentColor : this.palette.collapseBadge;
|
|
1300
|
+
context.fill();
|
|
1301
|
+
context.fillStyle = this.palette.badgeText;
|
|
1302
|
+
context.font = "750 10px Inter, ui-sans-serif, system-ui, sans-serif";
|
|
1303
|
+
context.fillText(topic.expanded ? "\u2212" : "+", x + width - 3.5, visualY + visualHeight / 2 + 0.5);
|
|
1304
|
+
}
|
|
1305
|
+
context.restore();
|
|
1306
|
+
}
|
|
1307
|
+
drawDragPreview(context, layout) {
|
|
1308
|
+
if (!this.dragId || !this.dragPoint) return;
|
|
1309
|
+
const index = layout.indexById.get(this.dragId);
|
|
1310
|
+
if (index === void 0) return;
|
|
1311
|
+
const width = layout.width[index] ?? 120;
|
|
1312
|
+
const height = layout.height[index] ?? 36;
|
|
1313
|
+
const world = this.camera.screenToWorld(this.dragPoint);
|
|
1314
|
+
context.beginPath();
|
|
1315
|
+
if (typeof context.roundRect === "function") {
|
|
1316
|
+
context.roundRect(world.x - width / 2, world.y - height / 2, width, height, 10);
|
|
1317
|
+
} else {
|
|
1318
|
+
context.rect(world.x - width / 2, world.y - height / 2, width, height);
|
|
1319
|
+
}
|
|
1320
|
+
context.fillStyle = this.palette.drag;
|
|
1321
|
+
context.fill();
|
|
1322
|
+
context.strokeStyle = this.palette.dragStroke;
|
|
1323
|
+
context.stroke();
|
|
1324
|
+
}
|
|
1325
|
+
assertAlive() {
|
|
1326
|
+
if (this.destroyed) throw new Error("CanvasRenderer has been destroyed");
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
// ../vanilla/src/mind-map.ts
|
|
1331
|
+
var resolveContainer = (container) => {
|
|
1332
|
+
if (typeof container !== "string") return container;
|
|
1333
|
+
const resolved = document.querySelector(container);
|
|
1334
|
+
if (!resolved) throw new Error(`MindMap container not found: ${container}`);
|
|
1335
|
+
return resolved;
|
|
1336
|
+
};
|
|
1337
|
+
var button = (action, label, title) => {
|
|
1338
|
+
const element = document.createElement("button");
|
|
1339
|
+
element.type = "button";
|
|
1340
|
+
element.dataset.action = action;
|
|
1341
|
+
element.textContent = label;
|
|
1342
|
+
element.title = title;
|
|
1343
|
+
return element;
|
|
1344
|
+
};
|
|
1345
|
+
var MindMap = class {
|
|
1346
|
+
constructor(options) {
|
|
1347
|
+
__publicField(this, "container");
|
|
1348
|
+
__publicField(this, "root");
|
|
1349
|
+
__publicField(this, "canvas");
|
|
1350
|
+
__publicField(this, "camera");
|
|
1351
|
+
__publicField(this, "layout");
|
|
1352
|
+
__publicField(this, "engine");
|
|
1353
|
+
__publicField(this, "layoutEngine", new MindMapLayout());
|
|
1354
|
+
__publicField(this, "renderer");
|
|
1355
|
+
__publicField(this, "pluginManager");
|
|
1356
|
+
__publicField(this, "toolbar");
|
|
1357
|
+
__publicField(this, "editor");
|
|
1358
|
+
__publicField(this, "hud");
|
|
1359
|
+
__publicField(this, "status");
|
|
1360
|
+
__publicField(this, "editable");
|
|
1361
|
+
__publicField(this, "selection", /* @__PURE__ */ new Set());
|
|
1362
|
+
__publicField(this, "cleanups", []);
|
|
1363
|
+
__publicField(this, "resizeObserver");
|
|
1364
|
+
__publicField(this, "hudTimer");
|
|
1365
|
+
__publicField(this, "destroyed", false);
|
|
1366
|
+
__publicField(this, "panState");
|
|
1367
|
+
__publicField(this, "dragState");
|
|
1368
|
+
__publicField(this, "editingId");
|
|
1369
|
+
__publicField(this, "hoveredId");
|
|
1370
|
+
this.container = resolveContainer(options.container);
|
|
1371
|
+
this.editable = options.editable ?? true;
|
|
1372
|
+
this.engine = new MindMapEngine(options.document);
|
|
1373
|
+
this.layout = this.layoutEngine.layout(this.engine.store);
|
|
1374
|
+
this.root = document.createElement("div");
|
|
1375
|
+
this.root.className = "mm-root";
|
|
1376
|
+
this.root.dataset.theme = options.theme ?? "dark";
|
|
1377
|
+
this.root.tabIndex = 0;
|
|
1378
|
+
this.root.setAttribute("role", "application");
|
|
1379
|
+
this.root.setAttribute("aria-label", "Interactive mind map");
|
|
1380
|
+
this.canvas = document.createElement("canvas");
|
|
1381
|
+
this.canvas.className = "mm-canvas";
|
|
1382
|
+
this.canvas.setAttribute("aria-label", "Mind-map canvas");
|
|
1383
|
+
const overlay = document.createElement("div");
|
|
1384
|
+
overlay.className = "mm-overlay";
|
|
1385
|
+
this.toolbar = this.createToolbar();
|
|
1386
|
+
this.editor = document.createElement("input");
|
|
1387
|
+
this.editor.className = "mm-text-editor";
|
|
1388
|
+
this.editor.type = "text";
|
|
1389
|
+
this.editor.hidden = true;
|
|
1390
|
+
this.editor.setAttribute("aria-label", "Edit topic text");
|
|
1391
|
+
this.hud = this.createHud();
|
|
1392
|
+
this.status = document.createElement("div");
|
|
1393
|
+
this.status.className = "mm-status";
|
|
1394
|
+
this.status.setAttribute("aria-live", "polite");
|
|
1395
|
+
overlay.append(this.toolbar, this.editor, this.hud, this.status);
|
|
1396
|
+
this.root.append(this.canvas, overlay);
|
|
1397
|
+
this.container.replaceChildren(this.root);
|
|
1398
|
+
this.camera = new Camera({ minScale: 1e-5, maxScale: 5 });
|
|
1399
|
+
this.renderer = new CanvasRenderer({
|
|
1400
|
+
canvas: this.canvas,
|
|
1401
|
+
camera: this.camera,
|
|
1402
|
+
theme: options.theme,
|
|
1403
|
+
requestFrame: options.requestFrame,
|
|
1404
|
+
cancelFrame: options.cancelFrame,
|
|
1405
|
+
continuous: options.continuousMetrics ?? true
|
|
1406
|
+
});
|
|
1407
|
+
this.pluginManager = new PluginManager(this, (_plugin, cause) => {
|
|
1408
|
+
this.showStatus(`Plugin error: ${String(cause)}`, true);
|
|
1409
|
+
});
|
|
1410
|
+
this.renderer.setScene(this.engine.store, this.layout);
|
|
1411
|
+
this.installInteractions();
|
|
1412
|
+
this.cleanups.push(
|
|
1413
|
+
this.engine.on("document:change", (change) => this.handleDocumentChange(change)),
|
|
1414
|
+
this.engine.on("error", (error) => this.showStatus(error.message, true))
|
|
1415
|
+
);
|
|
1416
|
+
this.resize();
|
|
1417
|
+
this.fitView();
|
|
1418
|
+
if (options.continuousMetrics ?? true) this.renderer.start();
|
|
1419
|
+
this.hudTimer = window.setInterval(() => this.updateHud(), 250);
|
|
1420
|
+
this.updateHud();
|
|
1421
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
1422
|
+
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
1423
|
+
this.resizeObserver.observe(this.container);
|
|
1424
|
+
} else {
|
|
1425
|
+
const onResize = () => this.resize();
|
|
1426
|
+
window.addEventListener("resize", onResize);
|
|
1427
|
+
this.cleanups.push(() => window.removeEventListener("resize", onResize));
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
get totalTopics() {
|
|
1431
|
+
return this.engine.store.size;
|
|
1432
|
+
}
|
|
1433
|
+
get theme() {
|
|
1434
|
+
return this.renderer.theme;
|
|
1435
|
+
}
|
|
1436
|
+
setTheme(theme) {
|
|
1437
|
+
this.assertAlive();
|
|
1438
|
+
this.root.dataset.theme = theme;
|
|
1439
|
+
this.renderer.setTheme(theme);
|
|
1440
|
+
}
|
|
1441
|
+
get canUndo() {
|
|
1442
|
+
return this.engine.canUndo;
|
|
1443
|
+
}
|
|
1444
|
+
get canRedo() {
|
|
1445
|
+
return this.engine.canRedo;
|
|
1446
|
+
}
|
|
1447
|
+
getTopic(id) {
|
|
1448
|
+
this.assertAlive();
|
|
1449
|
+
return this.engine.store.get(id);
|
|
1450
|
+
}
|
|
1451
|
+
hasTopic(id) {
|
|
1452
|
+
this.assertAlive();
|
|
1453
|
+
return this.engine.store.has(id);
|
|
1454
|
+
}
|
|
1455
|
+
addTopic(parentId, input) {
|
|
1456
|
+
this.assertAlive();
|
|
1457
|
+
return this.engine.addChild(parentId, input);
|
|
1458
|
+
}
|
|
1459
|
+
addSibling(topicId, input) {
|
|
1460
|
+
this.assertAlive();
|
|
1461
|
+
return this.engine.addSibling(topicId, input);
|
|
1462
|
+
}
|
|
1463
|
+
updateTopic(topicId, patch) {
|
|
1464
|
+
this.assertAlive();
|
|
1465
|
+
return this.engine.updateTopic(topicId, patch);
|
|
1466
|
+
}
|
|
1467
|
+
editTopic(topicId) {
|
|
1468
|
+
this.assertAlive();
|
|
1469
|
+
this.engine.store.get(topicId);
|
|
1470
|
+
this.select(topicId);
|
|
1471
|
+
this.openEditor(topicId);
|
|
1472
|
+
}
|
|
1473
|
+
deleteTopic(topicId) {
|
|
1474
|
+
this.assertAlive();
|
|
1475
|
+
this.engine.deleteTopic(topicId);
|
|
1476
|
+
this.selection.delete(topicId);
|
|
1477
|
+
this.syncSelection();
|
|
1478
|
+
}
|
|
1479
|
+
moveTopic(topicId, parentId, index) {
|
|
1480
|
+
this.assertAlive();
|
|
1481
|
+
return this.engine.moveTopic(topicId, parentId, index);
|
|
1482
|
+
}
|
|
1483
|
+
expand(topicId) {
|
|
1484
|
+
this.assertAlive();
|
|
1485
|
+
this.engine.setExpanded(topicId, true);
|
|
1486
|
+
}
|
|
1487
|
+
collapse(topicId) {
|
|
1488
|
+
this.assertAlive();
|
|
1489
|
+
this.engine.setExpanded(topicId, false);
|
|
1490
|
+
}
|
|
1491
|
+
toggle(topicId) {
|
|
1492
|
+
this.assertAlive();
|
|
1493
|
+
return this.engine.toggleTopic(topicId);
|
|
1494
|
+
}
|
|
1495
|
+
expandAll(options = {}) {
|
|
1496
|
+
this.assertAlive();
|
|
1497
|
+
let changed = false;
|
|
1498
|
+
for (const topic of this.engine.store.values()) {
|
|
1499
|
+
if (!topic.expanded) {
|
|
1500
|
+
topic.expanded = true;
|
|
1501
|
+
changed = true;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
if (changed) this.relayout(this.engine.store.rootId);
|
|
1505
|
+
if (options.view === "root") this.resetView();
|
|
1506
|
+
else if (options.view === "branch") this.centerReadableBranch();
|
|
1507
|
+
else if (options.view === "fit") this.fitView();
|
|
1508
|
+
}
|
|
1509
|
+
collapseToLevel(level) {
|
|
1510
|
+
this.assertAlive();
|
|
1511
|
+
for (const topic of this.engine.store.values()) {
|
|
1512
|
+
topic.expanded = this.engine.store.depthOf(topic.id) < level;
|
|
1513
|
+
}
|
|
1514
|
+
this.relayout(this.engine.store.rootId);
|
|
1515
|
+
}
|
|
1516
|
+
select(topicId, additive = false) {
|
|
1517
|
+
this.assertAlive();
|
|
1518
|
+
this.engine.store.get(topicId);
|
|
1519
|
+
if (!additive) this.selection.clear();
|
|
1520
|
+
if (additive && this.selection.has(topicId)) this.selection.delete(topicId);
|
|
1521
|
+
else this.selection.add(topicId);
|
|
1522
|
+
this.syncSelection();
|
|
1523
|
+
}
|
|
1524
|
+
clearSelection() {
|
|
1525
|
+
this.assertAlive();
|
|
1526
|
+
this.selection.clear();
|
|
1527
|
+
this.syncSelection();
|
|
1528
|
+
}
|
|
1529
|
+
getSelection() {
|
|
1530
|
+
this.assertAlive();
|
|
1531
|
+
return [...this.selection];
|
|
1532
|
+
}
|
|
1533
|
+
search(query) {
|
|
1534
|
+
this.assertAlive();
|
|
1535
|
+
return this.engine.search(query);
|
|
1536
|
+
}
|
|
1537
|
+
focusTopic(topicId, options = {}) {
|
|
1538
|
+
this.assertAlive();
|
|
1539
|
+
this.engine.revealTopic(topicId);
|
|
1540
|
+
const index = this.layout.indexById.get(topicId);
|
|
1541
|
+
if (index === void 0) return;
|
|
1542
|
+
this.select(topicId);
|
|
1543
|
+
const viewport = this.viewportSize();
|
|
1544
|
+
if (options.scale !== void 0) {
|
|
1545
|
+
this.camera.zoomTo(options.scale, { x: viewport.width / 2, y: viewport.height / 2 });
|
|
1546
|
+
}
|
|
1547
|
+
this.camera.center(
|
|
1548
|
+
{
|
|
1549
|
+
x: this.layout.x[index] ?? 0,
|
|
1550
|
+
y: this.layout.y[index] ?? 0,
|
|
1551
|
+
width: this.layout.width[index] ?? 0,
|
|
1552
|
+
height: this.layout.height[index] ?? 0
|
|
1553
|
+
},
|
|
1554
|
+
viewport
|
|
1555
|
+
);
|
|
1556
|
+
this.refreshToolbarPosition();
|
|
1557
|
+
this.renderer.invalidate();
|
|
1558
|
+
}
|
|
1559
|
+
undo() {
|
|
1560
|
+
this.assertAlive();
|
|
1561
|
+
return this.engine.undo();
|
|
1562
|
+
}
|
|
1563
|
+
redo() {
|
|
1564
|
+
this.assertAlive();
|
|
1565
|
+
return this.engine.redo();
|
|
1566
|
+
}
|
|
1567
|
+
zoomIn() {
|
|
1568
|
+
this.zoomBy(1.25);
|
|
1569
|
+
}
|
|
1570
|
+
zoomOut() {
|
|
1571
|
+
this.zoomBy(0.8);
|
|
1572
|
+
}
|
|
1573
|
+
zoomBy(factor, anchor) {
|
|
1574
|
+
this.assertAlive();
|
|
1575
|
+
const viewport = this.viewportSize();
|
|
1576
|
+
this.camera.zoomBy(factor, anchor ?? { x: viewport.width / 2, y: viewport.height / 2 });
|
|
1577
|
+
this.refreshToolbarPosition();
|
|
1578
|
+
this.renderer.invalidate();
|
|
1579
|
+
this.updateHud();
|
|
1580
|
+
}
|
|
1581
|
+
zoomTo(scale, anchor) {
|
|
1582
|
+
this.assertAlive();
|
|
1583
|
+
const viewport = this.viewportSize();
|
|
1584
|
+
this.camera.zoomTo(scale, anchor ?? { x: viewport.width / 2, y: viewport.height / 2 });
|
|
1585
|
+
this.refreshToolbarPosition();
|
|
1586
|
+
this.renderer.invalidate();
|
|
1587
|
+
this.updateHud();
|
|
1588
|
+
}
|
|
1589
|
+
panBy(dx, dy) {
|
|
1590
|
+
this.assertAlive();
|
|
1591
|
+
this.camera.panBy(dx, dy);
|
|
1592
|
+
this.refreshToolbarPosition();
|
|
1593
|
+
this.renderer.invalidate();
|
|
1594
|
+
}
|
|
1595
|
+
fitView() {
|
|
1596
|
+
this.assertAlive();
|
|
1597
|
+
this.camera.fit(this.layout.bounds, this.viewportSize(), 56);
|
|
1598
|
+
this.refreshToolbarPosition();
|
|
1599
|
+
this.renderer.invalidate();
|
|
1600
|
+
this.updateHud();
|
|
1601
|
+
}
|
|
1602
|
+
resetView() {
|
|
1603
|
+
this.assertAlive();
|
|
1604
|
+
this.camera.reset();
|
|
1605
|
+
const rootIndex = this.layout.indexById.get(this.engine.store.rootId);
|
|
1606
|
+
if (rootIndex !== void 0) {
|
|
1607
|
+
this.camera.center(
|
|
1608
|
+
{
|
|
1609
|
+
x: this.layout.x[rootIndex] ?? 0,
|
|
1610
|
+
y: this.layout.y[rootIndex] ?? 0,
|
|
1611
|
+
width: this.layout.width[rootIndex] ?? 0,
|
|
1612
|
+
height: this.layout.height[rootIndex] ?? 0
|
|
1613
|
+
},
|
|
1614
|
+
this.viewportSize()
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
this.refreshToolbarPosition();
|
|
1618
|
+
this.renderer.invalidate();
|
|
1619
|
+
this.updateHud();
|
|
1620
|
+
}
|
|
1621
|
+
centerReadableBranch() {
|
|
1622
|
+
const centerY = this.layout.bounds.y + this.layout.bounds.height / 2;
|
|
1623
|
+
let targetIndex = 0;
|
|
1624
|
+
let targetDepth = -1;
|
|
1625
|
+
let targetDistance = Number.POSITIVE_INFINITY;
|
|
1626
|
+
for (let index = 0; index < this.layout.ids.length; index += 1) {
|
|
1627
|
+
const depth = this.layout.depth[index] ?? 0;
|
|
1628
|
+
const nodeCenterY = (this.layout.y[index] ?? 0) + (this.layout.height[index] ?? 0) / 2;
|
|
1629
|
+
const distance = Math.abs(nodeCenterY - centerY);
|
|
1630
|
+
if (depth > targetDepth || depth === targetDepth && distance < targetDistance) {
|
|
1631
|
+
targetIndex = index;
|
|
1632
|
+
targetDepth = depth;
|
|
1633
|
+
targetDistance = distance;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
this.camera.reset();
|
|
1637
|
+
this.camera.center(
|
|
1638
|
+
{
|
|
1639
|
+
x: this.layout.x[targetIndex] ?? 0,
|
|
1640
|
+
y: this.layout.y[targetIndex] ?? 0,
|
|
1641
|
+
width: this.layout.width[targetIndex] ?? 0,
|
|
1642
|
+
height: this.layout.height[targetIndex] ?? 0
|
|
1643
|
+
},
|
|
1644
|
+
this.viewportSize()
|
|
1645
|
+
);
|
|
1646
|
+
this.refreshToolbarPosition();
|
|
1647
|
+
this.renderer.invalidate();
|
|
1648
|
+
this.updateHud();
|
|
1649
|
+
}
|
|
1650
|
+
resize() {
|
|
1651
|
+
this.assertAlive();
|
|
1652
|
+
const viewport = this.viewportSize();
|
|
1653
|
+
this.renderer.resize(viewport.width, viewport.height);
|
|
1654
|
+
}
|
|
1655
|
+
exportJSON() {
|
|
1656
|
+
this.assertAlive();
|
|
1657
|
+
return this.engine.exportJSON();
|
|
1658
|
+
}
|
|
1659
|
+
importJSON(json) {
|
|
1660
|
+
this.assertAlive();
|
|
1661
|
+
this.engine.importJSON(json);
|
|
1662
|
+
this.selection.clear();
|
|
1663
|
+
this.syncSelection();
|
|
1664
|
+
this.fitView();
|
|
1665
|
+
}
|
|
1666
|
+
async use(plugin) {
|
|
1667
|
+
this.assertAlive();
|
|
1668
|
+
await this.pluginManager.use(plugin);
|
|
1669
|
+
}
|
|
1670
|
+
getPerformanceSnapshot() {
|
|
1671
|
+
this.assertAlive();
|
|
1672
|
+
const metrics = this.renderer.metrics.snapshot();
|
|
1673
|
+
const memory = performance;
|
|
1674
|
+
return {
|
|
1675
|
+
...metrics,
|
|
1676
|
+
totalTopics: this.engine.store.size,
|
|
1677
|
+
layoutVisible: this.layout.ids.length,
|
|
1678
|
+
renderRate: this.layout.ids.length > 0 ? metrics.rendered / this.layout.ids.length * 100 : 0,
|
|
1679
|
+
layoutMs: this.layout.durationMs,
|
|
1680
|
+
maxDepth: this.layout.maxDepth,
|
|
1681
|
+
scale: this.camera.scale,
|
|
1682
|
+
domElements: this.root.querySelectorAll("*").length,
|
|
1683
|
+
heapMB: memory.memory ? memory.memory.usedJSHeapSize / 1024 / 1024 : null,
|
|
1684
|
+
firstRenderAtMs: this.renderer.firstRenderAtMs
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
getCameraState() {
|
|
1688
|
+
return this.camera.state;
|
|
1689
|
+
}
|
|
1690
|
+
destroy() {
|
|
1691
|
+
if (this.destroyed) return;
|
|
1692
|
+
this.destroyed = true;
|
|
1693
|
+
this.renderer.destroy();
|
|
1694
|
+
this.resizeObserver?.disconnect();
|
|
1695
|
+
if (this.hudTimer !== void 0) window.clearInterval(this.hudTimer);
|
|
1696
|
+
for (const cleanup of this.cleanups.splice(0)) cleanup();
|
|
1697
|
+
void this.pluginManager.destroy();
|
|
1698
|
+
this.root.remove();
|
|
1699
|
+
}
|
|
1700
|
+
relayout(anchorId = [...this.selection][0] ?? this.engine.store.rootId) {
|
|
1701
|
+
if (this.destroyed) return;
|
|
1702
|
+
const previousIndex = this.layout.indexById.get(anchorId);
|
|
1703
|
+
const previousScreen = previousIndex === void 0 ? void 0 : this.camera.worldToScreen({
|
|
1704
|
+
x: this.layout.x[previousIndex] ?? 0,
|
|
1705
|
+
y: this.layout.y[previousIndex] ?? 0
|
|
1706
|
+
});
|
|
1707
|
+
this.layout = this.layoutEngine.layout(this.engine.store);
|
|
1708
|
+
this.renderer.setScene(this.engine.store, this.layout);
|
|
1709
|
+
const nextIndex = this.layout.indexById.get(anchorId);
|
|
1710
|
+
if (previousScreen && nextIndex !== void 0) {
|
|
1711
|
+
const nextScreen = this.camera.worldToScreen({
|
|
1712
|
+
x: this.layout.x[nextIndex] ?? 0,
|
|
1713
|
+
y: this.layout.y[nextIndex] ?? 0
|
|
1714
|
+
});
|
|
1715
|
+
this.camera.panBy(previousScreen.x - nextScreen.x, previousScreen.y - nextScreen.y);
|
|
1716
|
+
}
|
|
1717
|
+
for (const selected of [...this.selection]) {
|
|
1718
|
+
if (!this.engine.store.has(selected)) this.selection.delete(selected);
|
|
1719
|
+
}
|
|
1720
|
+
this.syncSelection();
|
|
1721
|
+
this.updateHud();
|
|
1722
|
+
}
|
|
1723
|
+
handleDocumentChange(change) {
|
|
1724
|
+
if (change.type === "update" && change.layoutAffecting === false) {
|
|
1725
|
+
this.renderer.invalidate();
|
|
1726
|
+
this.updateHud();
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
this.relayout(change.topicId);
|
|
1730
|
+
}
|
|
1731
|
+
createToolbar() {
|
|
1732
|
+
const toolbar = document.createElement("div");
|
|
1733
|
+
toolbar.className = "mm-shared-toolbar";
|
|
1734
|
+
toolbar.hidden = true;
|
|
1735
|
+
toolbar.append(
|
|
1736
|
+
button("edit", "\u270E", "Edit topic"),
|
|
1737
|
+
button("add-child", "+", "Add child"),
|
|
1738
|
+
button("add-sibling", "\u21B3", "Add sibling"),
|
|
1739
|
+
button("toggle", "\u2212/+", "Expand or collapse"),
|
|
1740
|
+
button("delete", "\xD7", "Delete topic")
|
|
1741
|
+
);
|
|
1742
|
+
return toolbar;
|
|
1743
|
+
}
|
|
1744
|
+
createHud() {
|
|
1745
|
+
const hud = document.createElement("div");
|
|
1746
|
+
hud.className = "mm-performance-hud";
|
|
1747
|
+
hud.innerHTML = `
|
|
1748
|
+
<div class="mm-hud-title"><span class="mm-live-dot"></span> PERFORMANCE</div>
|
|
1749
|
+
<div class="mm-hud-grid">
|
|
1750
|
+
<span>Nodes</span><strong data-metric="nodes">\u2014</strong>
|
|
1751
|
+
<span>Rendered</span><strong data-metric="rendered">\u2014</strong>
|
|
1752
|
+
<span>Render rate</span><strong data-metric="rate">\u2014</strong>
|
|
1753
|
+
<span>FPS</span><strong data-metric="fps">\u2014</strong>
|
|
1754
|
+
<span>Frame P95</span><strong data-metric="frame">\u2014</strong>
|
|
1755
|
+
<span>Cull / draw</span><strong data-metric="pipeline">\u2014</strong>
|
|
1756
|
+
<span>Layout</span><strong data-metric="layout">\u2014</strong>
|
|
1757
|
+
<span>Scale</span><strong data-metric="scale">\u2014</strong>
|
|
1758
|
+
<span>DOM / heap</span><strong data-metric="memory">\u2014</strong>
|
|
1759
|
+
</div>`;
|
|
1760
|
+
return hud;
|
|
1761
|
+
}
|
|
1762
|
+
installInteractions() {
|
|
1763
|
+
this.listen(this.canvas, "pointerdown", (event) => this.onPointerDown(event));
|
|
1764
|
+
this.listen(this.canvas, "pointermove", (event) => this.onPointerMove(event));
|
|
1765
|
+
this.listen(this.canvas, "pointerup", (event) => this.onPointerUp(event));
|
|
1766
|
+
this.listen(this.canvas, "pointercancel", (event) => this.onPointerUp(event));
|
|
1767
|
+
this.listen(this.canvas, "dblclick", (event) => {
|
|
1768
|
+
const id = this.renderer.hitTest(this.pointerPoint(event));
|
|
1769
|
+
if (id) this.openEditor(id);
|
|
1770
|
+
});
|
|
1771
|
+
this.listen(
|
|
1772
|
+
this.canvas,
|
|
1773
|
+
"wheel",
|
|
1774
|
+
(event) => {
|
|
1775
|
+
const wheel = event;
|
|
1776
|
+
wheel.preventDefault();
|
|
1777
|
+
this.zoomBy(Math.exp(-wheel.deltaY * 15e-4), this.pointerPoint(wheel));
|
|
1778
|
+
},
|
|
1779
|
+
{ passive: false }
|
|
1780
|
+
);
|
|
1781
|
+
this.listen(this.root, "keydown", (event) => this.onKeyDown(event));
|
|
1782
|
+
this.listen(this.toolbar, "pointerdown", (event) => event.preventDefault());
|
|
1783
|
+
this.listen(this.toolbar, "click", (event) => this.onToolbarAction(event));
|
|
1784
|
+
this.listen(this.editor, "keydown", (event) => this.onEditorKeyDown(event));
|
|
1785
|
+
this.listen(this.editor, "blur", () => this.commitEditor());
|
|
1786
|
+
}
|
|
1787
|
+
onPointerDown(event) {
|
|
1788
|
+
if (event.button !== 0) return;
|
|
1789
|
+
const point = this.pointerPoint(event);
|
|
1790
|
+
const hit = this.renderer.hitTest(point);
|
|
1791
|
+
this.root.focus({ preventScroll: true });
|
|
1792
|
+
this.canvas.setPointerCapture?.(event.pointerId);
|
|
1793
|
+
if (!hit) {
|
|
1794
|
+
this.clearSelection();
|
|
1795
|
+
this.panState = { id: event.pointerId, x: point.x, y: point.y };
|
|
1796
|
+
this.toolbar.hidden = true;
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
const screenRect = this.renderer.getNodeScreenRect(hit);
|
|
1800
|
+
const topic = this.engine.store.get(hit);
|
|
1801
|
+
if (screenRect && topic.childIds.length > 0 && point.x >= screenRect.x + screenRect.width - 18) {
|
|
1802
|
+
this.toggle(hit);
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
this.select(hit, event.metaKey || event.ctrlKey);
|
|
1806
|
+
this.dragState = {
|
|
1807
|
+
id: event.pointerId,
|
|
1808
|
+
x: point.x,
|
|
1809
|
+
y: point.y,
|
|
1810
|
+
topicId: hit,
|
|
1811
|
+
active: false
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
onPointerMove(event) {
|
|
1815
|
+
const point = this.pointerPoint(event);
|
|
1816
|
+
if (this.panState?.id === event.pointerId) {
|
|
1817
|
+
this.camera.panBy(point.x - this.panState.x, point.y - this.panState.y);
|
|
1818
|
+
this.panState.x = point.x;
|
|
1819
|
+
this.panState.y = point.y;
|
|
1820
|
+
this.refreshToolbarPosition();
|
|
1821
|
+
this.renderer.invalidate();
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
if (this.dragState?.id === event.pointerId) {
|
|
1825
|
+
const distance = Math.hypot(point.x - this.dragState.x, point.y - this.dragState.y);
|
|
1826
|
+
if (distance > 6) this.dragState.active = true;
|
|
1827
|
+
if (this.dragState.active) {
|
|
1828
|
+
const candidate = this.renderer.hitTest(point);
|
|
1829
|
+
this.dragState.targetId = this.validDropTarget(this.dragState.topicId, candidate);
|
|
1830
|
+
this.renderer.setDragState(
|
|
1831
|
+
this.dragState.topicId,
|
|
1832
|
+
point,
|
|
1833
|
+
this.dragState.targetId
|
|
1834
|
+
);
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
const hover = this.renderer.hitTest(point);
|
|
1839
|
+
if (hover !== this.hoveredId) {
|
|
1840
|
+
this.hoveredId = hover;
|
|
1841
|
+
this.renderer.setHover(hover);
|
|
1842
|
+
if (hover) this.positionToolbar(hover);
|
|
1843
|
+
else if (this.selection.size === 0) this.toolbar.hidden = true;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
onPointerUp(event) {
|
|
1847
|
+
if (this.panState?.id === event.pointerId) this.panState = void 0;
|
|
1848
|
+
if (this.dragState?.id !== event.pointerId) return;
|
|
1849
|
+
const drag = this.dragState;
|
|
1850
|
+
this.dragState = void 0;
|
|
1851
|
+
this.renderer.setDragState();
|
|
1852
|
+
if (drag.active && drag.targetId) {
|
|
1853
|
+
try {
|
|
1854
|
+
this.moveTopic(drag.topicId, drag.targetId);
|
|
1855
|
+
this.select(drag.topicId);
|
|
1856
|
+
this.showStatus(`Moved under \u201C${this.engine.store.get(drag.targetId).text}\u201D`);
|
|
1857
|
+
} catch (cause) {
|
|
1858
|
+
this.showStatus(String(cause), true);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
onKeyDown(event) {
|
|
1863
|
+
const shortcutTarget = event.target instanceof Element ? event.target : void 0;
|
|
1864
|
+
if (shortcutTarget?.closest('button, input, select, textarea, [contenteditable="true"], [contenteditable=""]')) {
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
const selected = [...this.selection][0];
|
|
1868
|
+
const command = event.metaKey || event.ctrlKey;
|
|
1869
|
+
const modified = command || event.altKey || event.shiftKey;
|
|
1870
|
+
if (command && event.key.toLowerCase() === "z") {
|
|
1871
|
+
event.preventDefault();
|
|
1872
|
+
event.shiftKey ? this.redo() : this.undo();
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
if (command && event.key.toLowerCase() === "y") {
|
|
1876
|
+
event.preventDefault();
|
|
1877
|
+
this.redo();
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
if (event.key === "+" || event.key === "=") this.zoomIn();
|
|
1881
|
+
else if (event.key === "-") this.zoomOut();
|
|
1882
|
+
else if (event.key === "0") this.resetView();
|
|
1883
|
+
else if (selected && (event.key === "F2" || event.key === " ")) {
|
|
1884
|
+
event.preventDefault();
|
|
1885
|
+
this.openEditor(selected);
|
|
1886
|
+
} else if (selected && event.key === "Tab" && !modified) {
|
|
1887
|
+
event.preventDefault();
|
|
1888
|
+
const topic = this.addTopic(selected, { text: "New topic" });
|
|
1889
|
+
this.select(topic.id);
|
|
1890
|
+
this.openEditor(topic.id);
|
|
1891
|
+
} else if (selected && event.key === "Enter" && !modified) {
|
|
1892
|
+
event.preventDefault();
|
|
1893
|
+
const topic = selected === this.engine.store.rootId ? this.addTopic(selected, { text: "New topic" }) : this.addSibling(selected, { text: "New topic" });
|
|
1894
|
+
this.select(topic.id);
|
|
1895
|
+
this.openEditor(topic.id);
|
|
1896
|
+
} else if (selected && (event.key === "Delete" || event.key === "Backspace")) {
|
|
1897
|
+
event.preventDefault();
|
|
1898
|
+
if (selected !== this.engine.store.rootId) this.deleteTopic(selected);
|
|
1899
|
+
} else if (selected && event.key === "ArrowLeft") {
|
|
1900
|
+
const parentId = this.engine.store.get(selected).parentId;
|
|
1901
|
+
if (parentId) this.focusTopic(parentId);
|
|
1902
|
+
} else if (selected && event.key === "ArrowRight") {
|
|
1903
|
+
const childId = this.engine.store.get(selected).childIds[0];
|
|
1904
|
+
if (childId) this.focusTopic(childId);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
onToolbarAction(event) {
|
|
1908
|
+
const target = event.target.closest("button[data-action]");
|
|
1909
|
+
const id = [...this.selection][0] ?? this.hoveredId;
|
|
1910
|
+
if (!target || !id) return;
|
|
1911
|
+
const action = target.dataset.action;
|
|
1912
|
+
try {
|
|
1913
|
+
if (action === "edit") this.openEditor(id);
|
|
1914
|
+
else if (action === "add-child") {
|
|
1915
|
+
const topic = this.addTopic(id, { text: "New topic" });
|
|
1916
|
+
this.select(topic.id);
|
|
1917
|
+
this.openEditor(topic.id);
|
|
1918
|
+
} else if (action === "add-sibling") {
|
|
1919
|
+
const topic = this.addSibling(id, { text: "New topic" });
|
|
1920
|
+
this.select(topic.id);
|
|
1921
|
+
this.openEditor(topic.id);
|
|
1922
|
+
} else if (action === "toggle") this.toggle(id);
|
|
1923
|
+
else if (action === "delete" && id !== this.engine.store.rootId) this.deleteTopic(id);
|
|
1924
|
+
} catch (cause) {
|
|
1925
|
+
this.showStatus(String(cause), true);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
openEditor(topicId) {
|
|
1929
|
+
if (!this.editable) return;
|
|
1930
|
+
const rect = this.renderer.getNodeScreenRect(topicId);
|
|
1931
|
+
if (!rect) return;
|
|
1932
|
+
this.editingId = topicId;
|
|
1933
|
+
this.editor.value = this.engine.store.get(topicId).text;
|
|
1934
|
+
this.editor.hidden = false;
|
|
1935
|
+
this.editor.style.left = `${Math.max(8, rect.x)}px`;
|
|
1936
|
+
this.editor.style.top = `${Math.max(8, rect.y)}px`;
|
|
1937
|
+
this.editor.style.width = `${Math.max(190, rect.width)}px`;
|
|
1938
|
+
this.editor.style.height = `${Math.max(36, rect.height)}px`;
|
|
1939
|
+
this.editor.focus();
|
|
1940
|
+
this.editor.select();
|
|
1941
|
+
}
|
|
1942
|
+
commitEditor() {
|
|
1943
|
+
if (!this.editingId || this.editor.hidden) return;
|
|
1944
|
+
const id = this.editingId;
|
|
1945
|
+
const text = this.editor.value.trim();
|
|
1946
|
+
this.editingId = void 0;
|
|
1947
|
+
this.editor.hidden = true;
|
|
1948
|
+
if (text && this.engine.store.has(id) && text !== this.engine.store.get(id).text) {
|
|
1949
|
+
this.updateTopic(id, { text });
|
|
1950
|
+
this.positionToolbar(id);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
onEditorKeyDown(event) {
|
|
1954
|
+
if (event.key === "Enter") {
|
|
1955
|
+
event.preventDefault();
|
|
1956
|
+
this.commitEditor();
|
|
1957
|
+
this.root.focus();
|
|
1958
|
+
} else if (event.key === "Escape") {
|
|
1959
|
+
this.editingId = void 0;
|
|
1960
|
+
this.editor.hidden = true;
|
|
1961
|
+
this.root.focus();
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
syncSelection() {
|
|
1965
|
+
if (this.destroyed) return;
|
|
1966
|
+
this.renderer.setSelection(this.selection);
|
|
1967
|
+
this.refreshToolbarPosition();
|
|
1968
|
+
}
|
|
1969
|
+
refreshToolbarPosition() {
|
|
1970
|
+
const selected = [...this.selection][0];
|
|
1971
|
+
if (selected && this.layout.indexById.has(selected)) this.positionToolbar(selected);
|
|
1972
|
+
else if (!this.hoveredId) this.toolbar.hidden = true;
|
|
1973
|
+
}
|
|
1974
|
+
positionToolbar(id) {
|
|
1975
|
+
const rect = this.renderer.getNodeScreenRect(id);
|
|
1976
|
+
const viewport = this.viewportSize();
|
|
1977
|
+
if (!rect || rect.width < 8 || rect.height < 4 || rect.x + rect.width < 0 || rect.y + rect.height < 0 || rect.x > viewport.width || rect.y > viewport.height) {
|
|
1978
|
+
this.toolbar.hidden = true;
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
this.toolbar.hidden = false;
|
|
1982
|
+
this.toolbar.style.left = `${Math.max(8, rect.x)}px`;
|
|
1983
|
+
this.toolbar.style.top = `${Math.max(8, rect.y - 42)}px`;
|
|
1984
|
+
}
|
|
1985
|
+
validDropTarget(dragId, candidate) {
|
|
1986
|
+
if (!candidate || candidate === dragId) return void 0;
|
|
1987
|
+
if (this.engine.store.isAncestor(dragId, candidate)) return void 0;
|
|
1988
|
+
return candidate;
|
|
1989
|
+
}
|
|
1990
|
+
pointerPoint(event) {
|
|
1991
|
+
const bounds = this.canvas.getBoundingClientRect();
|
|
1992
|
+
return { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
|
|
1993
|
+
}
|
|
1994
|
+
viewportSize() {
|
|
1995
|
+
return {
|
|
1996
|
+
width: Math.max(1, this.container.clientWidth || this.root.clientWidth || 800),
|
|
1997
|
+
height: Math.max(1, this.container.clientHeight || this.root.clientHeight || 600)
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
updateHud() {
|
|
2001
|
+
if (this.destroyed) return;
|
|
2002
|
+
const snapshot = this.getPerformanceSnapshot();
|
|
2003
|
+
this.setMetric("nodes", `${snapshot.totalTopics.toLocaleString()} / ${snapshot.layoutVisible.toLocaleString()}`);
|
|
2004
|
+
this.setMetric("rendered", snapshot.rendered.toLocaleString());
|
|
2005
|
+
this.setMetric("rate", `${snapshot.renderRate.toFixed(1)}%`);
|
|
2006
|
+
this.setMetric("fps", snapshot.fps ? snapshot.fps.toFixed(0) : "\u2014");
|
|
2007
|
+
this.setMetric("frame", `${snapshot.frameP95.toFixed(2)} ms`);
|
|
2008
|
+
this.setMetric("pipeline", `${snapshot.cullMs.toFixed(2)} / ${snapshot.drawMs.toFixed(2)} ms`);
|
|
2009
|
+
this.setMetric("layout", `${snapshot.layoutMs.toFixed(2)} ms \xB7 L${snapshot.maxDepth + 1}`);
|
|
2010
|
+
this.setMetric("scale", `${(snapshot.scale * 100).toFixed(snapshot.scale < 0.1 ? 2 : 0)}%`);
|
|
2011
|
+
this.setMetric(
|
|
2012
|
+
"memory",
|
|
2013
|
+
`${snapshot.domElements} / ${snapshot.heapMB === null ? "n/a" : `${snapshot.heapMB.toFixed(1)} MB`}`
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
setMetric(name, value) {
|
|
2017
|
+
const element = this.hud.querySelector(`[data-metric="${name}"]`);
|
|
2018
|
+
if (element) element.textContent = value;
|
|
2019
|
+
}
|
|
2020
|
+
showStatus(message, error = false) {
|
|
2021
|
+
this.status.textContent = message;
|
|
2022
|
+
this.status.dataset.tone = error ? "error" : "success";
|
|
2023
|
+
this.status.classList.add("is-visible");
|
|
2024
|
+
window.setTimeout(() => this.status.classList.remove("is-visible"), 1800);
|
|
2025
|
+
}
|
|
2026
|
+
listen(target, event, listener, options) {
|
|
2027
|
+
target.addEventListener(event, listener, options);
|
|
2028
|
+
this.cleanups.push(() => target.removeEventListener(event, listener, options));
|
|
2029
|
+
}
|
|
2030
|
+
assertAlive() {
|
|
2031
|
+
if (this.destroyed) throw new Error("MindMap has been destroyed");
|
|
2032
|
+
}
|
|
2033
|
+
};
|
|
2034
|
+
|
|
2035
|
+
export { Camera, CanvasRenderer, EventEmitter, History, MindMap, MindMapEngine, MindMapLayout, PluginManager, RollingMetrics, SpatialIndex, TopicStore, resolveLod };
|