backpack-viewer 0.2.20 → 0.3.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/README.md +2 -0
- package/bin/serve.js +184 -1
- package/dist/api.d.ts +25 -0
- package/dist/api.js +42 -0
- package/dist/app/assets/index-CBjy2b6N.js +34 -0
- package/dist/app/assets/index-CvkozBSE.css +1 -0
- package/dist/app/assets/layout-worker-BZXiBoiC.js +1 -0
- package/dist/app/index.html +2 -2
- package/dist/canvas.d.ts +19 -0
- package/dist/canvas.js +662 -144
- package/dist/config.js +1 -0
- package/dist/context-menu.d.ts +13 -0
- package/dist/context-menu.js +64 -0
- package/dist/default-config.json +6 -1
- package/dist/empty-state.js +13 -0
- package/dist/info-panel.js +2 -2
- package/dist/keybindings.d.ts +1 -1
- package/dist/keybindings.js +2 -0
- package/dist/label-cache.d.ts +14 -0
- package/dist/label-cache.js +54 -0
- package/dist/layout-worker.d.ts +17 -0
- package/dist/layout-worker.js +78 -0
- package/dist/layout.js +73 -18
- package/dist/main.js +266 -14
- package/dist/quadtree.d.ts +43 -0
- package/dist/quadtree.js +147 -0
- package/dist/shortcuts.js +2 -0
- package/dist/sidebar.d.ts +10 -0
- package/dist/sidebar.js +134 -4
- package/dist/spatial-hash.d.ts +22 -0
- package/dist/spatial-hash.js +67 -0
- package/dist/style.css +357 -0
- package/dist/tools-pane.d.ts +10 -0
- package/dist/tools-pane.js +192 -0
- package/package.json +2 -2
- package/dist/app/assets/index-BAsAhA_i.js +0 -21
- package/dist/app/assets/index-CvETIueX.css +0 -1
package/dist/config.js
CHANGED
|
@@ -23,6 +23,7 @@ export function loadViewerConfig() {
|
|
|
23
23
|
layout: { ...defaultConfig.layout, ...(user.layout ?? {}) },
|
|
24
24
|
navigation: { ...defaultConfig.navigation, ...(user.navigation ?? {}) },
|
|
25
25
|
lod: { ...defaultConfig.lod, ...(user.lod ?? {}) },
|
|
26
|
+
walk: { ...defaultConfig.walk, ...(user.walk ?? {}) },
|
|
26
27
|
limits: { ...defaultConfig.limits, ...(user.limits ?? {}) },
|
|
27
28
|
};
|
|
28
29
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ContextMenuCallbacks {
|
|
2
|
+
onStar: (nodeId: string) => void;
|
|
3
|
+
onFocusNode: (nodeId: string) => void;
|
|
4
|
+
onExploreInBranch: (nodeId: string) => void;
|
|
5
|
+
onCopyId: (nodeId: string) => void;
|
|
6
|
+
onExpand?: (nodeId: string) => void;
|
|
7
|
+
onExplainPath?: (nodeId: string) => void;
|
|
8
|
+
onEnrich?: (nodeId: string) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function initContextMenu(container: HTMLElement, callbacks: ContextMenuCallbacks): {
|
|
11
|
+
show: (nodeId: string, nodeLabel: string, isStarred: boolean, screenX: number, screenY: number) => void;
|
|
12
|
+
hide: () => void;
|
|
13
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export function initContextMenu(container, callbacks) {
|
|
2
|
+
let menuEl = null;
|
|
3
|
+
function show(nodeId, nodeLabel, isStarred, screenX, screenY) {
|
|
4
|
+
hide();
|
|
5
|
+
menuEl = document.createElement("div");
|
|
6
|
+
menuEl.className = "context-menu";
|
|
7
|
+
menuEl.style.left = `${screenX}px`;
|
|
8
|
+
menuEl.style.top = `${screenY}px`;
|
|
9
|
+
const items = [
|
|
10
|
+
{ label: isStarred ? "\u2605 Unstar" : "\u2606 Star", action: () => callbacks.onStar(nodeId), premium: false },
|
|
11
|
+
{ label: "\u25CE Focus on node", action: () => callbacks.onFocusNode(nodeId), premium: false },
|
|
12
|
+
{ label: "\u2442 Explore in branch", action: () => callbacks.onExploreInBranch(nodeId), premium: false },
|
|
13
|
+
{ label: "\u2398 Copy ID", action: () => callbacks.onCopyId(nodeId), premium: false },
|
|
14
|
+
];
|
|
15
|
+
if (callbacks.onExpand)
|
|
16
|
+
items.push({ label: "\u2295 Expand node", action: () => callbacks.onExpand(nodeId), premium: true });
|
|
17
|
+
if (callbacks.onExplainPath)
|
|
18
|
+
items.push({ label: "\u2194 Explain path to\u2026", action: () => callbacks.onExplainPath(nodeId), premium: true });
|
|
19
|
+
if (callbacks.onEnrich)
|
|
20
|
+
items.push({ label: "\u2261 Enrich from web", action: () => callbacks.onEnrich(nodeId), premium: true });
|
|
21
|
+
let addedSep = false;
|
|
22
|
+
for (const item of items) {
|
|
23
|
+
if (!addedSep && item.premium) {
|
|
24
|
+
const sep = document.createElement("div");
|
|
25
|
+
sep.className = "context-menu-separator";
|
|
26
|
+
menuEl.appendChild(sep);
|
|
27
|
+
addedSep = true;
|
|
28
|
+
}
|
|
29
|
+
const row = document.createElement("div");
|
|
30
|
+
row.className = "context-menu-item";
|
|
31
|
+
row.textContent = item.label;
|
|
32
|
+
row.addEventListener("click", () => {
|
|
33
|
+
item.action();
|
|
34
|
+
hide();
|
|
35
|
+
});
|
|
36
|
+
menuEl.appendChild(row);
|
|
37
|
+
}
|
|
38
|
+
container.appendChild(menuEl);
|
|
39
|
+
// Clamp to viewport
|
|
40
|
+
const rect = menuEl.getBoundingClientRect();
|
|
41
|
+
if (rect.right > window.innerWidth) {
|
|
42
|
+
menuEl.style.left = `${screenX - rect.width}px`;
|
|
43
|
+
}
|
|
44
|
+
if (rect.bottom > window.innerHeight) {
|
|
45
|
+
menuEl.style.top = `${screenY - rect.height}px`;
|
|
46
|
+
}
|
|
47
|
+
// Close on outside click (delayed to not catch the opening right-click)
|
|
48
|
+
setTimeout(() => document.addEventListener("click", hide), 0);
|
|
49
|
+
document.addEventListener("keydown", handleEscape);
|
|
50
|
+
}
|
|
51
|
+
function hide() {
|
|
52
|
+
if (menuEl) {
|
|
53
|
+
menuEl.remove();
|
|
54
|
+
menuEl = null;
|
|
55
|
+
}
|
|
56
|
+
document.removeEventListener("click", hide);
|
|
57
|
+
document.removeEventListener("keydown", handleEscape);
|
|
58
|
+
}
|
|
59
|
+
function handleEscape(e) {
|
|
60
|
+
if (e.key === "Escape")
|
|
61
|
+
hide();
|
|
62
|
+
}
|
|
63
|
+
return { show, hide };
|
|
64
|
+
}
|
package/dist/default-config.json
CHANGED
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
"spacingIncrease": "]",
|
|
30
30
|
"clusteringDecrease": "{",
|
|
31
31
|
"clusteringIncrease": "}",
|
|
32
|
-
"toggleSidebar": "Tab"
|
|
32
|
+
"toggleSidebar": "Tab",
|
|
33
|
+
"walkMode": "w",
|
|
34
|
+
"walkIsolate": "i"
|
|
33
35
|
},
|
|
34
36
|
"display": {
|
|
35
37
|
"edges": true,
|
|
@@ -57,6 +59,9 @@
|
|
|
57
59
|
"smallNodes": 0.2,
|
|
58
60
|
"hideArrows": 0.15
|
|
59
61
|
},
|
|
62
|
+
"walk": {
|
|
63
|
+
"pulseSpeed": 0.02
|
|
64
|
+
},
|
|
60
65
|
"limits": {
|
|
61
66
|
"maxSearchResults": 8,
|
|
62
67
|
"maxQualityItems": 5,
|
package/dist/empty-state.js
CHANGED
|
@@ -2,6 +2,19 @@ export function initEmptyState(container) {
|
|
|
2
2
|
const el = document.createElement("div");
|
|
3
3
|
el.className = "empty-state";
|
|
4
4
|
el.innerHTML = `
|
|
5
|
+
<div class="empty-state-bg">
|
|
6
|
+
<div class="empty-state-circle c1"></div>
|
|
7
|
+
<div class="empty-state-circle c2"></div>
|
|
8
|
+
<div class="empty-state-circle c3"></div>
|
|
9
|
+
<div class="empty-state-circle c4"></div>
|
|
10
|
+
<div class="empty-state-circle c5"></div>
|
|
11
|
+
<svg class="empty-state-lines" viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice">
|
|
12
|
+
<line x1="80" y1="60" x2="220" y2="140" stroke="currentColor" stroke-width="0.5" opacity="0.15"/>
|
|
13
|
+
<line x1="220" y1="140" x2="320" y2="80" stroke="currentColor" stroke-width="0.5" opacity="0.15"/>
|
|
14
|
+
<line x1="220" y1="140" x2="160" y2="240" stroke="currentColor" stroke-width="0.5" opacity="0.15"/>
|
|
15
|
+
<line x1="160" y1="240" x2="300" y2="220" stroke="currentColor" stroke-width="0.5" opacity="0.15"/>
|
|
16
|
+
</svg>
|
|
17
|
+
</div>
|
|
5
18
|
<div class="empty-state-content">
|
|
6
19
|
<div class="empty-state-icon">
|
|
7
20
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
package/dist/info-panel.js
CHANGED
|
@@ -403,7 +403,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
|
|
|
403
403
|
label.textContent = `${nodes.length} nodes selected`;
|
|
404
404
|
header.appendChild(label);
|
|
405
405
|
const badgeRow = document.createElement("div");
|
|
406
|
-
badgeRow.
|
|
406
|
+
badgeRow.className = "info-badge-row";
|
|
407
407
|
const typeCounts = new Map();
|
|
408
408
|
for (const node of nodes) {
|
|
409
409
|
typeCounts.set(node.type, (typeCounts.get(node.type) ?? 0) + 1);
|
|
@@ -451,7 +451,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
|
|
|
451
451
|
: "Connections Between Selected");
|
|
452
452
|
if (sharedEdges.length === 0) {
|
|
453
453
|
const empty = document.createElement("p");
|
|
454
|
-
empty.
|
|
454
|
+
empty.className = "info-empty-message";
|
|
455
455
|
empty.textContent = "No direct connections between selected nodes";
|
|
456
456
|
connSection.appendChild(empty);
|
|
457
457
|
}
|
package/dist/keybindings.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type KeybindingAction = "search" | "searchAlt" | "undo" | "redo" | "help" | "escape" | "focus" | "toggleEdges" | "center" | "nextNode" | "prevNode" | "nextConnection" | "prevConnection" | "historyBack" | "historyForward" | "hopsIncrease" | "hopsDecrease" | "panLeft" | "panDown" | "panUp" | "panRight" | "panFastLeft" | "zoomOut" | "zoomIn" | "panFastRight" | "spacingDecrease" | "spacingIncrease" | "clusteringDecrease" | "clusteringIncrease" | "toggleSidebar";
|
|
1
|
+
export type KeybindingAction = "search" | "searchAlt" | "undo" | "redo" | "help" | "escape" | "focus" | "toggleEdges" | "center" | "nextNode" | "prevNode" | "nextConnection" | "prevConnection" | "historyBack" | "historyForward" | "hopsIncrease" | "hopsDecrease" | "panLeft" | "panDown" | "panUp" | "panRight" | "panFastLeft" | "zoomOut" | "zoomIn" | "panFastRight" | "spacingDecrease" | "spacingIncrease" | "clusteringDecrease" | "clusteringIncrease" | "toggleSidebar" | "walkMode" | "walkIsolate";
|
|
2
2
|
export type KeybindingMap = Record<KeybindingAction, string>;
|
|
3
3
|
/** Parse a binding string like "ctrl+shift+z" and check if it matches a KeyboardEvent. */
|
|
4
4
|
export declare function matchKey(e: KeyboardEvent, binding: string): boolean;
|
package/dist/keybindings.js
CHANGED
|
@@ -63,5 +63,7 @@ export function actionDescriptions() {
|
|
|
63
63
|
clusteringDecrease: "Decrease clustering",
|
|
64
64
|
clusteringIncrease: "Increase clustering",
|
|
65
65
|
toggleSidebar: "Toggle sidebar",
|
|
66
|
+
walkMode: "Toggle walk mode (in focus)",
|
|
67
|
+
walkIsolate: "Isolate walk trail nodes",
|
|
66
68
|
};
|
|
67
69
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offscreen canvas label cache.
|
|
3
|
+
*
|
|
4
|
+
* Pre-renders text labels to small offscreen canvases, then draws them
|
|
5
|
+
* via drawImage() which is much faster than fillText() per frame.
|
|
6
|
+
* Cache keys are "text|font|color" to handle theme changes.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Draw a cached label centered at (x, y) with the given baseline alignment.
|
|
10
|
+
* Returns immediately — cache miss renders inline and stores for next frame.
|
|
11
|
+
*/
|
|
12
|
+
export declare function drawCachedLabel(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, font: string, color: string, align: "top" | "bottom"): void;
|
|
13
|
+
/** Clear the entire cache (call on theme change or graph reload). */
|
|
14
|
+
export declare function clearLabelCache(): void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offscreen canvas label cache.
|
|
3
|
+
*
|
|
4
|
+
* Pre-renders text labels to small offscreen canvases, then draws them
|
|
5
|
+
* via drawImage() which is much faster than fillText() per frame.
|
|
6
|
+
* Cache keys are "text|font|color" to handle theme changes.
|
|
7
|
+
*/
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
const MAX_CACHE_SIZE = 2000;
|
|
10
|
+
function key(text, font, color) {
|
|
11
|
+
return `${text}|${font}|${color}`;
|
|
12
|
+
}
|
|
13
|
+
// Shared measurement canvas — reused across all renderLabel calls
|
|
14
|
+
const measureCanvas = new OffscreenCanvas(1, 1);
|
|
15
|
+
const measureCtx = measureCanvas.getContext("2d");
|
|
16
|
+
function renderLabel(text, font, color) {
|
|
17
|
+
measureCtx.font = font;
|
|
18
|
+
const metrics = measureCtx.measureText(text);
|
|
19
|
+
const w = Math.ceil(metrics.width) + 2; // 1px padding each side
|
|
20
|
+
const h = Math.ceil(metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent) + 4;
|
|
21
|
+
const canvas = new OffscreenCanvas(w, h);
|
|
22
|
+
const ctx = canvas.getContext("2d");
|
|
23
|
+
ctx.font = font;
|
|
24
|
+
ctx.fillStyle = color;
|
|
25
|
+
ctx.textAlign = "left";
|
|
26
|
+
ctx.textBaseline = "top";
|
|
27
|
+
ctx.fillText(text, 1, 1);
|
|
28
|
+
return { canvas, width: w, height: h };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Draw a cached label centered at (x, y) with the given baseline alignment.
|
|
32
|
+
* Returns immediately — cache miss renders inline and stores for next frame.
|
|
33
|
+
*/
|
|
34
|
+
export function drawCachedLabel(ctx, text, x, y, font, color, align) {
|
|
35
|
+
const k = key(text, font, color);
|
|
36
|
+
let entry = cache.get(k);
|
|
37
|
+
if (!entry) {
|
|
38
|
+
// Evict oldest entries if cache is full
|
|
39
|
+
if (cache.size >= MAX_CACHE_SIZE) {
|
|
40
|
+
const first = cache.keys().next().value;
|
|
41
|
+
if (first !== undefined)
|
|
42
|
+
cache.delete(first);
|
|
43
|
+
}
|
|
44
|
+
entry = renderLabel(text, font, color);
|
|
45
|
+
cache.set(k, entry);
|
|
46
|
+
}
|
|
47
|
+
const dx = x - entry.width / 2;
|
|
48
|
+
const dy = align === "top" ? y : y - entry.height;
|
|
49
|
+
ctx.drawImage(entry.canvas, dx, dy);
|
|
50
|
+
}
|
|
51
|
+
/** Clear the entire cache (call on theme change or graph reload). */
|
|
52
|
+
export function clearLabelCache() {
|
|
53
|
+
cache.clear();
|
|
54
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Worker for off-main-thread force-directed layout.
|
|
3
|
+
*
|
|
4
|
+
* Runs the tick loop in a worker thread so physics never blocks
|
|
5
|
+
* the main thread's rendering or input handling.
|
|
6
|
+
*
|
|
7
|
+
* Protocol:
|
|
8
|
+
* Main → Worker:
|
|
9
|
+
* { type: 'start', nodes, edges, params } — begin simulation
|
|
10
|
+
* { type: 'stop' } — halt simulation
|
|
11
|
+
* { type: 'params', params } — update layout params + reheat
|
|
12
|
+
*
|
|
13
|
+
* Worker → Main:
|
|
14
|
+
* { type: 'tick', positions: Float64Array, alpha } — position update per tick
|
|
15
|
+
* { type: 'settled' } — simulation converged
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Worker for off-main-thread force-directed layout.
|
|
3
|
+
*
|
|
4
|
+
* Runs the tick loop in a worker thread so physics never blocks
|
|
5
|
+
* the main thread's rendering or input handling.
|
|
6
|
+
*
|
|
7
|
+
* Protocol:
|
|
8
|
+
* Main → Worker:
|
|
9
|
+
* { type: 'start', nodes, edges, params } — begin simulation
|
|
10
|
+
* { type: 'stop' } — halt simulation
|
|
11
|
+
* { type: 'params', params } — update layout params + reheat
|
|
12
|
+
*
|
|
13
|
+
* Worker → Main:
|
|
14
|
+
* { type: 'tick', positions: Float64Array, alpha } — position update per tick
|
|
15
|
+
* { type: 'settled' } — simulation converged
|
|
16
|
+
*/
|
|
17
|
+
import { createLayout, tick, setLayoutParams, autoLayoutParams } from "./layout.js";
|
|
18
|
+
const ALPHA_MIN = 0.001;
|
|
19
|
+
const TICK_BATCH = 3; // ticks per message to reduce postMessage overhead
|
|
20
|
+
let running = false;
|
|
21
|
+
let state = null;
|
|
22
|
+
let alpha = 1;
|
|
23
|
+
function packPositions(nodes) {
|
|
24
|
+
const buf = new Float64Array(nodes.length * 4);
|
|
25
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
26
|
+
const n = nodes[i];
|
|
27
|
+
buf[i * 4] = n.x;
|
|
28
|
+
buf[i * 4 + 1] = n.y;
|
|
29
|
+
buf[i * 4 + 2] = n.vx;
|
|
30
|
+
buf[i * 4 + 3] = n.vy;
|
|
31
|
+
}
|
|
32
|
+
return buf;
|
|
33
|
+
}
|
|
34
|
+
function runLoop() {
|
|
35
|
+
if (!running || !state)
|
|
36
|
+
return;
|
|
37
|
+
for (let i = 0; i < TICK_BATCH; i++) {
|
|
38
|
+
if (alpha < ALPHA_MIN) {
|
|
39
|
+
running = false;
|
|
40
|
+
const positions = packPositions(state.nodes);
|
|
41
|
+
self.postMessage({ type: "tick", positions, alpha }, { transfer: [positions.buffer] });
|
|
42
|
+
self.postMessage({ type: "settled" });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
alpha = tick(state, alpha);
|
|
46
|
+
}
|
|
47
|
+
const positions = packPositions(state.nodes);
|
|
48
|
+
self.postMessage({ type: "tick", positions, alpha }, { transfer: [positions.buffer] });
|
|
49
|
+
// Yield to allow incoming messages, then continue
|
|
50
|
+
setTimeout(runLoop, 0);
|
|
51
|
+
}
|
|
52
|
+
self.onmessage = (e) => {
|
|
53
|
+
const msg = e.data;
|
|
54
|
+
if (msg.type === "start") {
|
|
55
|
+
running = false; // stop any existing loop
|
|
56
|
+
const data = msg.data;
|
|
57
|
+
const params = msg.params;
|
|
58
|
+
// Auto-scale params based on graph size, then apply overrides
|
|
59
|
+
const auto = autoLayoutParams(data.nodes.length);
|
|
60
|
+
setLayoutParams({ ...auto, ...params });
|
|
61
|
+
state = createLayout(data);
|
|
62
|
+
alpha = 1;
|
|
63
|
+
running = true;
|
|
64
|
+
runLoop();
|
|
65
|
+
}
|
|
66
|
+
if (msg.type === "stop") {
|
|
67
|
+
running = false;
|
|
68
|
+
}
|
|
69
|
+
if (msg.type === "params") {
|
|
70
|
+
setLayoutParams(msg.params);
|
|
71
|
+
// Reheat simulation
|
|
72
|
+
alpha = Math.max(alpha, 0.3);
|
|
73
|
+
if (!running && state) {
|
|
74
|
+
running = true;
|
|
75
|
+
runLoop();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
package/dist/layout.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildQuadtree, applyRepulsion } from "./quadtree.js";
|
|
1
2
|
export const DEFAULT_LAYOUT_PARAMS = {
|
|
2
3
|
clusterStrength: 0.08,
|
|
3
4
|
spacing: 1.5,
|
|
@@ -106,27 +107,81 @@ export function createLayout(data) {
|
|
|
106
107
|
}));
|
|
107
108
|
return { nodes, edges, nodeMap };
|
|
108
109
|
}
|
|
110
|
+
// Barnes-Hut accuracy parameter (0.5 = accurate, 1.0 = fast).
|
|
111
|
+
// 0.7 is a good balance for interactive use.
|
|
112
|
+
const BH_THETA = 0.7;
|
|
113
|
+
// Threshold below which we fall back to direct O(n²) — quadtree overhead isn't worth it
|
|
114
|
+
const BH_THRESHOLD = 80;
|
|
109
115
|
/** Run one tick of the force simulation. Returns new alpha. */
|
|
110
116
|
export function tick(state, alpha) {
|
|
111
117
|
const { nodes, edges, nodeMap } = state;
|
|
112
|
-
// Repulsion —
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
118
|
+
// Repulsion — Barnes-Hut O(n log n) for large graphs, direct O(n²) for small
|
|
119
|
+
const crossRep = CROSS_TYPE_REPULSION_BASE * params.spacing;
|
|
120
|
+
if (nodes.length >= BH_THRESHOLD) {
|
|
121
|
+
// Barnes-Hut: apply cross-type repulsion strength globally via quadtree
|
|
122
|
+
const tree = buildQuadtree(nodes);
|
|
123
|
+
if (tree) {
|
|
124
|
+
for (const node of nodes) {
|
|
125
|
+
applyRepulsion(tree, node, BH_THETA, crossRep, alpha, MIN_DISTANCE);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Same-type correction: same-type pairs should use REPULSION (weaker) not crossRep.
|
|
129
|
+
// Apply a negative correction of (crossRep - REPULSION) for same-type pairs.
|
|
130
|
+
// Group by type to avoid checking all n² pairs — only intra-group pairs.
|
|
131
|
+
const repDiff = crossRep - REPULSION;
|
|
132
|
+
if (repDiff > 0) {
|
|
133
|
+
const typeGroups = new Map();
|
|
134
|
+
for (const node of nodes) {
|
|
135
|
+
let group = typeGroups.get(node.type);
|
|
136
|
+
if (!group) {
|
|
137
|
+
group = [];
|
|
138
|
+
typeGroups.set(node.type, group);
|
|
139
|
+
}
|
|
140
|
+
group.push(node);
|
|
141
|
+
}
|
|
142
|
+
for (const group of typeGroups.values()) {
|
|
143
|
+
for (let i = 0; i < group.length; i++) {
|
|
144
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
145
|
+
const a = group[i];
|
|
146
|
+
const b = group[j];
|
|
147
|
+
let dx = b.x - a.x;
|
|
148
|
+
let dy = b.y - a.y;
|
|
149
|
+
let dist = Math.sqrt(dx * dx + dy * dy);
|
|
150
|
+
if (dist < MIN_DISTANCE)
|
|
151
|
+
dist = MIN_DISTANCE;
|
|
152
|
+
// Subtract the excess repulsion (correction is attractive between same-type)
|
|
153
|
+
const force = (repDiff * alpha) / (dist * dist);
|
|
154
|
+
const fx = (dx / dist) * force;
|
|
155
|
+
const fy = (dy / dist) * force;
|
|
156
|
+
a.vx += fx;
|
|
157
|
+
a.vy += fy;
|
|
158
|
+
b.vx -= fx;
|
|
159
|
+
b.vy -= fy;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
// Small graph — direct all-pairs (original algorithm)
|
|
167
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
168
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
169
|
+
const a = nodes[i];
|
|
170
|
+
const b = nodes[j];
|
|
171
|
+
let dx = b.x - a.x;
|
|
172
|
+
let dy = b.y - a.y;
|
|
173
|
+
let dist = Math.sqrt(dx * dx + dy * dy);
|
|
174
|
+
if (dist < MIN_DISTANCE)
|
|
175
|
+
dist = MIN_DISTANCE;
|
|
176
|
+
const rep = a.type === b.type ? REPULSION : crossRep;
|
|
177
|
+
const force = (rep * alpha) / (dist * dist);
|
|
178
|
+
const fx = (dx / dist) * force;
|
|
179
|
+
const fy = (dy / dist) * force;
|
|
180
|
+
a.vx -= fx;
|
|
181
|
+
a.vy -= fy;
|
|
182
|
+
b.vx += fx;
|
|
183
|
+
b.vy += fy;
|
|
184
|
+
}
|
|
130
185
|
}
|
|
131
186
|
}
|
|
132
187
|
// Attraction — along edges (shorter rest length within same type)
|