backpack-viewer 0.5.1 → 0.7.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.
Files changed (62) hide show
  1. package/bin/serve.js +159 -396
  2. package/dist/app/assets/index-D-H7agBH.js +12 -0
  3. package/dist/app/assets/index-DE73ngo-.css +1 -0
  4. package/dist/app/assets/index-DFW3OKgJ.js +6 -0
  5. package/dist/app/assets/layout-worker-4xak23M6.js +1 -0
  6. package/dist/app/index.html +2 -2
  7. package/dist/bridge.d.ts +22 -0
  8. package/dist/bridge.js +41 -0
  9. package/dist/canvas.d.ts +15 -0
  10. package/dist/canvas.js +352 -12
  11. package/dist/config.js +10 -0
  12. package/dist/copy-prompt.d.ts +17 -0
  13. package/dist/copy-prompt.js +81 -0
  14. package/dist/default-config.json +6 -1
  15. package/dist/dom-utils.d.ts +46 -0
  16. package/dist/dom-utils.js +57 -0
  17. package/dist/empty-state.js +63 -31
  18. package/dist/extensions/api.d.ts +15 -0
  19. package/dist/extensions/api.js +185 -0
  20. package/dist/extensions/chat/backpack-extension.json +23 -0
  21. package/dist/extensions/chat/src/index.js +32 -0
  22. package/dist/extensions/chat/src/panel.js +306 -0
  23. package/dist/extensions/chat/src/providers/anthropic.js +158 -0
  24. package/dist/extensions/chat/src/providers/types.js +15 -0
  25. package/dist/extensions/chat/src/tools.js +281 -0
  26. package/dist/extensions/chat/style.css +147 -0
  27. package/dist/extensions/event-bus.d.ts +12 -0
  28. package/dist/extensions/event-bus.js +30 -0
  29. package/dist/extensions/loader.d.ts +32 -0
  30. package/dist/extensions/loader.js +71 -0
  31. package/dist/extensions/manifest.d.ts +54 -0
  32. package/dist/extensions/manifest.js +116 -0
  33. package/dist/extensions/panel-mount.d.ts +26 -0
  34. package/dist/extensions/panel-mount.js +377 -0
  35. package/dist/extensions/taskbar.d.ts +29 -0
  36. package/dist/extensions/taskbar.js +64 -0
  37. package/dist/extensions/types.d.ts +182 -0
  38. package/dist/extensions/types.js +8 -0
  39. package/dist/info-panel.d.ts +2 -1
  40. package/dist/info-panel.js +78 -87
  41. package/dist/keybindings.d.ts +1 -1
  42. package/dist/keybindings.js +1 -0
  43. package/dist/layout-worker.d.ts +4 -1
  44. package/dist/layout-worker.js +51 -1
  45. package/dist/layout.d.ts +8 -0
  46. package/dist/layout.js +8 -1
  47. package/dist/main.js +216 -35
  48. package/dist/search.js +1 -1
  49. package/dist/server-api-routes.d.ts +56 -0
  50. package/dist/server-api-routes.js +442 -0
  51. package/dist/server-extensions.d.ts +126 -0
  52. package/dist/server-extensions.js +272 -0
  53. package/dist/server-viewer-state.d.ts +18 -0
  54. package/dist/server-viewer-state.js +33 -0
  55. package/dist/shortcuts.js +6 -2
  56. package/dist/sidebar.js +19 -7
  57. package/dist/style.css +356 -74
  58. package/dist/tools-pane.js +31 -14
  59. package/package.json +4 -3
  60. package/dist/app/assets/index-B3z5bBGl.css +0 -1
  61. package/dist/app/assets/index-CKYlU1zT.js +0 -35
  62. package/dist/app/assets/layout-worker-BZXiBoiC.js +0 -1
@@ -0,0 +1,182 @@
1
+ import type { LearningGraphData } from "backpack-ontology";
2
+ /**
3
+ * Versioned extension API contract.
4
+ *
5
+ * Bumping the major version means breaking changes for installed
6
+ * extensions — extensions declare which version they target in their
7
+ * manifest and the loader rejects mismatches.
8
+ */
9
+ export declare const VIEWER_API_VERSION: "1";
10
+ export type ViewerApiVersion = "1";
11
+ /**
12
+ * Events an extension can subscribe to via `viewer.on(event, cb)`.
13
+ *
14
+ * - `graph-changed`: the active graph data was mutated (any source)
15
+ * - `graph-switched`: the user switched to a different active graph
16
+ * - `selection-changed`: the set of selected node ids changed
17
+ * - `focus-changed`: focus mode was entered, exited, or modified
18
+ */
19
+ export type ViewerEvent = "graph-changed" | "graph-switched" | "selection-changed" | "focus-changed";
20
+ /** Snapshot of the viewer's current focus state. */
21
+ export interface ViewerFocusSnapshot {
22
+ seedNodeIds: string[];
23
+ hops: number;
24
+ totalNodes: number;
25
+ }
26
+ /** Public extension API surface. */
27
+ export interface ViewerExtensionAPI {
28
+ readonly name: string;
29
+ readonly viewerApiVersion: ViewerApiVersion;
30
+ getGraph(): LearningGraphData | null;
31
+ getGraphName(): string;
32
+ getSelection(): string[];
33
+ getFocus(): ViewerFocusSnapshot | null;
34
+ on(event: ViewerEvent, callback: () => void): () => void;
35
+ addNode(type: string, properties: Record<string, unknown>): Promise<string>;
36
+ updateNode(nodeId: string, properties: Record<string, unknown>): Promise<void>;
37
+ removeNode(nodeId: string): Promise<void>;
38
+ addEdge(sourceId: string, targetId: string, type: string): Promise<string>;
39
+ removeEdge(edgeId: string): Promise<void>;
40
+ panToNode(nodeId: string): void;
41
+ focusNodes(nodeIds: string[], hops: number): void;
42
+ exitFocus(): void;
43
+ registerTaskbarIcon(opts: TaskbarIconOptions): () => void;
44
+ mountPanel(element: HTMLElement, opts?: MountPanelOptions): MountedPanel;
45
+ settings: ExtensionSettingsAPI;
46
+ fetch(url: string, init?: RequestInit): Promise<Response>;
47
+ }
48
+ export type TaskbarPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
49
+ export interface TaskbarIconOptions {
50
+ /** Visible button text and accessible label */
51
+ label: string;
52
+ /** Optional leading symbol shown before the label (no SVG in v1) */
53
+ iconText?: string;
54
+ /**
55
+ * Where to place the icon. Top slots nest into the viewer's existing
56
+ * top bar (alongside zoom/theme controls); bottom slots float in the
57
+ * canvas corners. Default: "bottom-right".
58
+ *
59
+ * The bottom-center area is reserved for the viewer's path bar — no
60
+ * slot lives there to avoid overlap.
61
+ */
62
+ position?: TaskbarPosition;
63
+ /** Click handler — usually toggles a panel */
64
+ onClick: () => void;
65
+ }
66
+ /**
67
+ * A custom button rendered in a panel's header, to the left of the
68
+ * built-in fullscreen + close controls. Each button shares the same
69
+ * visual style as the built-ins so extension chrome reads as one row
70
+ * of controls.
71
+ */
72
+ export interface PanelHeaderButton {
73
+ label: string;
74
+ /** Optional short text shown as the button content (defaults to label). */
75
+ iconText?: string;
76
+ onClick: () => void;
77
+ disabled?: boolean;
78
+ }
79
+ export interface MountPanelOptions {
80
+ /** Panel header title */
81
+ title?: string;
82
+ /**
83
+ * Initial position relative to the canvas container (CSS pixels).
84
+ * Defaults to a sensible right-side spot. If a persisted position
85
+ * exists for this panel's persistence key, that wins over this
86
+ * default. The user can drag the panel by its title bar from there.
87
+ */
88
+ defaultPosition?: {
89
+ left: number;
90
+ top: number;
91
+ };
92
+ /** Custom buttons rendered before the built-in fullscreen + close. */
93
+ headerButtons?: PanelHeaderButton[];
94
+ /** Show the built-in fullscreen toggle button (default true). */
95
+ showFullscreenButton?: boolean;
96
+ /** Show the built-in close X button (default true). */
97
+ showCloseButton?: boolean;
98
+ /**
99
+ * If true, clicking the X button hides the panel via setVisible(false)
100
+ * instead of removing it from the DOM. Used by long-lived built-in
101
+ * panels (like info-panel) which are mounted once and reused for
102
+ * the lifetime of the viewer. Extensions typically leave this unset.
103
+ */
104
+ hideOnClose?: boolean;
105
+ /**
106
+ * Persistence key suffix. Position + fullscreen state are stored in
107
+ * localStorage under `backpack-viewer:panel:<key>`. Defaults to the
108
+ * extension/panel name passed to mount(). Set this if you need
109
+ * multiple panels for the same name.
110
+ */
111
+ persistKey?: string;
112
+ /**
113
+ * Called once after the panel is closed for any reason — user
114
+ * clicked the X button, the extension called `close()` itself, or
115
+ * the panel was destroyed because the viewer is reloading. For
116
+ * `hideOnClose` panels, this fires when the X is clicked too. The
117
+ * callback fires at most once per close.
118
+ */
119
+ onClose?: () => void;
120
+ /** Called whenever fullscreen state changes (button or programmatic). */
121
+ onFullscreenChange?: (fullscreen: boolean) => void;
122
+ }
123
+ export interface MountedPanel {
124
+ /** Remove the panel from the DOM (ignores hideOnClose). */
125
+ close(): void;
126
+ /** Toggle fullscreen state. */
127
+ setFullscreen(fullscreen: boolean): void;
128
+ /** Whether the panel is currently fullscreen. */
129
+ isFullscreen(): boolean;
130
+ /** Update the title shown in the header. */
131
+ setTitle(title: string): void;
132
+ /** Replace the custom header buttons. */
133
+ setHeaderButtons(buttons: PanelHeaderButton[]): void;
134
+ /** Show or hide the panel (without destroying it). */
135
+ setVisible(visible: boolean): void;
136
+ /** Whether the panel is currently visible. */
137
+ isVisible(): boolean;
138
+ /** Bring the panel to the front of the panel z-tier. */
139
+ bringToFront(): void;
140
+ /** The body element the extension owns (not the chrome). */
141
+ element: HTMLElement;
142
+ }
143
+ export interface ExtensionSettingsAPI {
144
+ get<T = unknown>(key: string): Promise<T | null>;
145
+ set(key: string, value: unknown): Promise<void>;
146
+ remove(key: string): Promise<void>;
147
+ }
148
+ /**
149
+ * Internal interface — what the extension API factory needs from main.ts
150
+ * to construct a per-extension API instance. main.ts creates a single
151
+ * "host" object and passes it to the API factory for each loaded
152
+ * extension. The host is the abstraction barrier between extension code
153
+ * and viewer internals.
154
+ */
155
+ export interface ViewerHost {
156
+ getGraph(): LearningGraphData | null;
157
+ getGraphName(): string;
158
+ getSelection(): string[];
159
+ getFocus(): ViewerFocusSnapshot | null;
160
+ /** Save current graph state. Caller is expected to mutate getGraph() in place first. */
161
+ saveCurrentGraph(): Promise<void>;
162
+ /** Push current graph onto undo stack. */
163
+ snapshotForUndo(): void;
164
+ /** Drive the canvas. */
165
+ panToNode(nodeId: string): void;
166
+ focusNodes(nodeIds: string[], hops: number): void;
167
+ exitFocus(): void;
168
+ /**
169
+ * Taskbar slot containers (one per supported position). The host
170
+ * creates these in main.ts; the extension API factory routes
171
+ * `registerTaskbarIcon` calls into the right one based on the
172
+ * extension's chosen position.
173
+ */
174
+ taskbarSlots: {
175
+ topLeft: HTMLElement;
176
+ topRight: HTMLElement;
177
+ bottomLeft: HTMLElement;
178
+ bottomRight: HTMLElement;
179
+ };
180
+ /** Subscribe to events emitted by the host. */
181
+ subscribe(event: ViewerEvent, cb: () => void): () => void;
182
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Versioned extension API contract.
3
+ *
4
+ * Bumping the major version means breaking changes for installed
5
+ * extensions — extensions declare which version they target in their
6
+ * manifest and the loader rejects mismatches.
7
+ */
8
+ export const VIEWER_API_VERSION = "1";
@@ -1,4 +1,5 @@
1
1
  import type { LearningGraphData } from "backpack-ontology";
2
+ import type { PanelMount } from "./extensions/panel-mount";
2
3
  export interface EditCallbacks {
3
4
  onUpdateNode(nodeId: string, properties: Record<string, unknown>): void;
4
5
  onChangeNodeType(nodeId: string, newType: string): void;
@@ -6,7 +7,7 @@ export interface EditCallbacks {
6
7
  onDeleteEdge(edgeId: string): void;
7
8
  onAddProperty(nodeId: string, key: string, value: string): void;
8
9
  }
9
- export declare function initInfoPanel(container: HTMLElement, callbacks?: EditCallbacks, onNavigateToNode?: (nodeId: string) => void, onFocus?: (nodeIds: string[]) => void): {
10
+ export declare function initInfoPanel(container: HTMLElement, panelMount: PanelMount, callbacks?: EditCallbacks, onNavigateToNode?: (nodeId: string) => void, onFocus?: (nodeIds: string[]) => void): {
10
11
  show(nodeIds: string[], data: LearningGraphData): void;
11
12
  hide: () => void;
12
13
  goBack: () => void;
@@ -8,13 +8,17 @@ function nodeLabel(node) {
8
8
  return node.id;
9
9
  }
10
10
  const EDIT_ICON = '\u270E'; // pencil
11
- export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
12
- const panel = document.createElement("div");
13
- panel.id = "info-panel";
14
- panel.className = "info-panel hidden";
15
- container.appendChild(panel);
11
+ export function initInfoPanel(container, panelMount, callbacks, onNavigateToNode, onFocus) {
12
+ // The body element is owned by info-panel and refilled on every
13
+ // show. Panel-mount wraps it with the standard chrome (title +
14
+ // header buttons + fullscreen + close). info-panel never destroys
15
+ // the panel — it uses setVisible(true/false) instead.
16
+ const bodyEl = document.createElement("div");
17
+ bodyEl.className = "info-panel-content";
18
+ container; // unused — kept in signature for backwards compat
19
+ // (the canvas container is no longer the direct parent; panel-mount
20
+ // handles DOM placement.)
16
21
  // --- State ---
17
- let maximized = false;
18
22
  let history = [];
19
23
  let historyIndex = -1;
20
24
  let navigatingHistory = false;
@@ -23,11 +27,23 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
23
27
  let focusDisabled = false;
24
28
  let connectionNodeIds = []; // other-end node IDs for each connection
25
29
  let activeConnectionIndex = -1;
30
+ // Mount the panel once with placeholder chrome. show*() updates the
31
+ // title + header buttons each time a node is selected.
32
+ const handle = panelMount.mount("info", bodyEl, {
33
+ title: "Node info",
34
+ persistKey: "info-panel",
35
+ hideOnClose: true,
36
+ onClose: () => {
37
+ // Reset history when the user explicitly closes — re-opening on
38
+ // a fresh node selection should start a new history chain.
39
+ history = [];
40
+ historyIndex = -1;
41
+ },
42
+ });
43
+ // Start hidden — info-panel only appears when a node is selected.
44
+ handle.setVisible(false);
26
45
  function hide() {
27
- panel.classList.add("hidden");
28
- panel.classList.remove("info-panel-maximized");
29
- panel.innerHTML = "";
30
- maximized = false;
46
+ handle.setVisible(false);
31
47
  history = [];
32
48
  historyIndex = -1;
33
49
  }
@@ -65,60 +81,38 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
65
81
  showSingle(nodeId, lastData);
66
82
  navigatingHistory = false;
67
83
  }
68
- function createToolbar() {
69
- const toolbar = document.createElement("div");
70
- toolbar.className = "info-panel-toolbar";
71
- // Back
72
- const backBtn = document.createElement("button");
73
- backBtn.className = "info-toolbar-btn";
74
- backBtn.textContent = "\u2190";
75
- backBtn.title = "Back";
76
- backBtn.disabled = historyIndex <= 0;
77
- backBtn.addEventListener("click", goBack);
78
- toolbar.appendChild(backBtn);
79
- // Forward
80
- const fwdBtn = document.createElement("button");
81
- fwdBtn.className = "info-toolbar-btn";
82
- fwdBtn.textContent = "\u2192";
83
- fwdBtn.title = "Forward";
84
- fwdBtn.disabled = historyIndex >= history.length - 1;
85
- fwdBtn.addEventListener("click", goForward);
86
- toolbar.appendChild(fwdBtn);
87
- // Focus
84
+ /**
85
+ * Build the header-button list reflecting current state. Called
86
+ * after every show() so the back/forward/focus enabled state stays
87
+ * in sync.
88
+ */
89
+ function refreshHeaderButtons() {
90
+ const buttons = [
91
+ {
92
+ label: "Back",
93
+ iconText: "\u2190",
94
+ onClick: goBack,
95
+ disabled: historyIndex <= 0,
96
+ },
97
+ {
98
+ label: "Forward",
99
+ iconText: "\u2192",
100
+ onClick: goForward,
101
+ disabled: historyIndex >= history.length - 1,
102
+ },
103
+ ];
88
104
  if (onFocus && currentNodeIds.length > 0) {
89
- const focusBtn = document.createElement("button");
90
- focusBtn.className = "info-toolbar-btn info-focus-btn";
91
- focusBtn.textContent = "\u25CE"; // bullseye
92
- focusBtn.title = "Focus on neighborhood (F)";
93
- focusBtn.disabled = focusDisabled;
94
- if (focusDisabled)
95
- focusBtn.style.opacity = "0.3";
96
- focusBtn.addEventListener("click", () => {
97
- if (!focusDisabled)
98
- onFocus(currentNodeIds);
105
+ buttons.push({
106
+ label: "Focus",
107
+ iconText: "\u25CE",
108
+ onClick: () => {
109
+ if (!focusDisabled)
110
+ onFocus(currentNodeIds);
111
+ },
112
+ disabled: focusDisabled,
99
113
  });
100
- toolbar.appendChild(focusBtn);
101
114
  }
102
- // Maximize/restore
103
- const maxBtn = document.createElement("button");
104
- maxBtn.className = "info-toolbar-btn";
105
- maxBtn.textContent = maximized ? "\u2398" : "\u26F6";
106
- maxBtn.title = maximized ? "Restore" : "Maximize";
107
- maxBtn.addEventListener("click", () => {
108
- maximized = !maximized;
109
- panel.classList.toggle("info-panel-maximized", maximized);
110
- maxBtn.textContent = maximized ? "\u2398" : "\u26F6";
111
- maxBtn.title = maximized ? "Restore" : "Maximize";
112
- });
113
- toolbar.appendChild(maxBtn);
114
- // Close
115
- const closeBtn = document.createElement("button");
116
- closeBtn.className = "info-toolbar-btn info-close-btn";
117
- closeBtn.textContent = "\u00d7";
118
- closeBtn.title = "Close";
119
- closeBtn.addEventListener("click", hide);
120
- toolbar.appendChild(closeBtn);
121
- return toolbar;
115
+ handle.setHeaderButtons(buttons);
122
116
  }
123
117
  function showSingle(nodeId, data) {
124
118
  const node = data.nodes.find((n) => n.id === nodeId);
@@ -128,15 +122,15 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
128
122
  // Store connection targets for keyboard cycling
129
123
  connectionNodeIds = connectedEdges.map((e) => e.sourceId === nodeId ? e.targetId : e.sourceId);
130
124
  activeConnectionIndex = -1;
131
- panel.innerHTML = "";
132
- panel.classList.remove("hidden");
133
- if (maximized)
134
- panel.classList.add("info-panel-maximized");
135
- // Pinned header area (toolbar + node identity)
125
+ bodyEl.replaceChildren();
126
+ // Update chrome with the node label as title + refresh header buttons
127
+ handle.setTitle(nodeLabel(node));
128
+ refreshHeaderButtons();
129
+ handle.setVisible(true);
130
+ // Pinned header area (type badge + node identity); the toolbar
131
+ // moved out of here into the panel chrome.
136
132
  const pinnedHeader = document.createElement("div");
137
133
  pinnedHeader.className = "info-panel-header";
138
- // Toolbar (back, forward, maximize, close)
139
- pinnedHeader.appendChild(createToolbar());
140
134
  // Header: type badge + label
141
135
  const header = document.createElement("div");
142
136
  header.className = "info-header";
@@ -191,7 +185,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
191
185
  header.appendChild(label);
192
186
  header.appendChild(nodeIdEl);
193
187
  pinnedHeader.appendChild(header);
194
- panel.appendChild(pinnedHeader);
188
+ bodyEl.appendChild(pinnedHeader);
195
189
  // Scrollable body for properties, connections, timestamps
196
190
  const body = document.createElement("div");
197
191
  body.className = "info-panel-body";
@@ -382,7 +376,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
382
376
  deleteSection.appendChild(deleteBtn);
383
377
  body.appendChild(deleteSection);
384
378
  }
385
- panel.appendChild(body);
379
+ bodyEl.appendChild(body);
386
380
  }
387
381
  function showMulti(nodeIds, data) {
388
382
  const selectedSet = new Set(nodeIds);
@@ -390,12 +384,11 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
390
384
  if (nodes.length === 0)
391
385
  return;
392
386
  const sharedEdges = data.edges.filter((e) => selectedSet.has(e.sourceId) && selectedSet.has(e.targetId));
393
- panel.innerHTML = "";
394
- panel.classList.remove("hidden");
395
- if (maximized)
396
- panel.classList.add("info-panel-maximized");
397
- // Toolbar
398
- panel.appendChild(createToolbar());
387
+ bodyEl.replaceChildren();
388
+ // Update chrome with multi-selection title + refresh header buttons
389
+ handle.setTitle(`${nodes.length} nodes selected`);
390
+ refreshHeaderButtons();
391
+ handle.setVisible(true);
399
392
  const header = document.createElement("div");
400
393
  header.className = "info-header";
401
394
  const label = document.createElement("h3");
@@ -416,7 +409,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
416
409
  badgeRow.appendChild(badge);
417
410
  }
418
411
  header.appendChild(badgeRow);
419
- panel.appendChild(header);
412
+ bodyEl.appendChild(header);
420
413
  const nodesSection = createSection("Selected Nodes");
421
414
  const nodesList = document.createElement("ul");
422
415
  nodesList.className = "info-connections";
@@ -445,7 +438,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
445
438
  nodesList.appendChild(li);
446
439
  }
447
440
  nodesSection.appendChild(nodesList);
448
- panel.appendChild(nodesSection);
441
+ bodyEl.appendChild(nodesSection);
449
442
  const connSection = createSection(sharedEdges.length > 0
450
443
  ? `Connections Between Selected (${sharedEdges.length})`
451
444
  : "Connections Between Selected");
@@ -517,7 +510,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
517
510
  }
518
511
  connSection.appendChild(list);
519
512
  }
520
- panel.appendChild(connSection);
513
+ bodyEl.appendChild(connSection);
521
514
  }
522
515
  return {
523
516
  show(nodeIds, data) {
@@ -558,8 +551,8 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
558
551
  if (activeConnectionIndex < 0)
559
552
  activeConnectionIndex = connectionNodeIds.length - 1;
560
553
  }
561
- // Highlight active row in the panel
562
- const items = panel.querySelectorAll(".info-connection");
554
+ // Highlight active row in the panel body
555
+ const items = bodyEl.querySelectorAll(".info-connection");
563
556
  items.forEach((el, i) => {
564
557
  el.classList.toggle("info-connection-active", i === activeConnectionIndex);
565
558
  });
@@ -570,14 +563,12 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
570
563
  },
571
564
  setFocusDisabled(disabled) {
572
565
  focusDisabled = disabled;
573
- const btn = panel.querySelector(".info-focus-btn");
574
- if (btn) {
575
- btn.disabled = disabled;
576
- btn.style.opacity = disabled ? "0.3" : "";
577
- }
566
+ // Refresh the chrome's header buttons to pick up the new
567
+ // disabled state on the focus button (if currently shown).
568
+ refreshHeaderButtons();
578
569
  },
579
570
  get visible() {
580
- return !panel.classList.contains("hidden");
571
+ return handle.isVisible();
581
572
  },
582
573
  };
583
574
  }
@@ -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" | "walkMode" | "walkIsolate";
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" | "resetPins";
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;
@@ -65,5 +65,6 @@ export function actionDescriptions() {
65
65
  toggleSidebar: "Toggle sidebar",
66
66
  walkMode: "Toggle walk mode (in focus)",
67
67
  walkIsolate: "Isolate walk trail nodes",
68
+ resetPins: "Release all pinned nodes (reset manual layout)",
68
69
  };
69
70
  }
@@ -7,8 +7,11 @@
7
7
  * Protocol:
8
8
  * Main → Worker:
9
9
  * { type: 'start', nodes, edges, params } — begin simulation
10
- * { type: 'stop' } — halt simulation
10
+ * { type: 'stop' } — halt simulation (pauses)
11
+ * { type: 'resume', alpha? } — resume after a stop
11
12
  * { type: 'params', params } — update layout params + reheat
13
+ * { type: 'pin', updates: [{id, x, y}] } — mark node(s) as pinned at given pos
14
+ * { type: 'unpin', ids: string[] | 'all' } — release pins; 'all' clears every pin
12
15
  *
13
16
  * Worker → Main:
14
17
  * { type: 'tick', positions: Float64Array, alpha } — position update per tick
@@ -7,8 +7,11 @@
7
7
  * Protocol:
8
8
  * Main → Worker:
9
9
  * { type: 'start', nodes, edges, params } — begin simulation
10
- * { type: 'stop' } — halt simulation
10
+ * { type: 'stop' } — halt simulation (pauses)
11
+ * { type: 'resume', alpha? } — resume after a stop
11
12
  * { type: 'params', params } — update layout params + reheat
13
+ * { type: 'pin', updates: [{id, x, y}] } — mark node(s) as pinned at given pos
14
+ * { type: 'unpin', ids: string[] | 'all' } — release pins; 'all' clears every pin
12
15
  *
13
16
  * Worker → Main:
14
17
  * { type: 'tick', positions: Float64Array, alpha } — position update per tick
@@ -66,6 +69,53 @@ self.onmessage = (e) => {
66
69
  if (msg.type === "stop") {
67
70
  running = false;
68
71
  }
72
+ if (msg.type === "resume") {
73
+ if (!running && state) {
74
+ alpha = Math.max(alpha, typeof msg.alpha === "number" ? msg.alpha : 0.5);
75
+ running = true;
76
+ runLoop();
77
+ }
78
+ }
79
+ if (msg.type === "pin" && state) {
80
+ // updates: [{ id, x, y }] — apply to the worker's state copy. Pinned
81
+ // nodes are treated as fixed points by tick() in layout.ts.
82
+ const updates = msg.updates;
83
+ for (const u of updates) {
84
+ const node = state.nodeMap.get(u.id);
85
+ if (node) {
86
+ node.x = u.x;
87
+ node.y = u.y;
88
+ node.vx = 0;
89
+ node.vy = 0;
90
+ node.pinned = true;
91
+ }
92
+ }
93
+ // Nudge simulation so neighbors react to the new pin location
94
+ alpha = Math.max(alpha, 0.3);
95
+ if (!running) {
96
+ running = true;
97
+ runLoop();
98
+ }
99
+ }
100
+ if (msg.type === "unpin" && state) {
101
+ const ids = msg.ids;
102
+ if (ids === "all") {
103
+ for (const node of state.nodes)
104
+ node.pinned = false;
105
+ }
106
+ else if (Array.isArray(ids)) {
107
+ for (const id of ids) {
108
+ const node = state.nodeMap.get(id);
109
+ if (node)
110
+ node.pinned = false;
111
+ }
112
+ }
113
+ alpha = Math.max(alpha, 0.5);
114
+ if (!running) {
115
+ running = true;
116
+ runLoop();
117
+ }
118
+ }
69
119
  if (msg.type === "params") {
70
120
  setLayoutParams(msg.params);
71
121
  // Reheat simulation
package/dist/layout.d.ts CHANGED
@@ -7,6 +7,14 @@ export interface LayoutNode {
7
7
  vy: number;
8
8
  label: string;
9
9
  type: string;
10
+ /**
11
+ * When true, the simulation treats this node as a fixed point:
12
+ * it still contributes forces to neighbors (its x/y is read for
13
+ * repulsion and attraction calculations) but its own position is
14
+ * not updated by the integration step. Set by the viewer's drag
15
+ * handler to temporarily pin a node at a user-chosen location.
16
+ */
17
+ pinned?: boolean;
10
18
  }
11
19
  export interface LayoutEdge {
12
20
  sourceId: string;
package/dist/layout.js CHANGED
@@ -229,8 +229,15 @@ export function tick(state, alpha) {
229
229
  node.vx += (c.x - node.x) * params.clusterStrength * alpha;
230
230
  node.vy += (c.y - node.y) * params.clusterStrength * alpha;
231
231
  }
232
- // Integrate — update positions, apply damping, clamp velocity
232
+ // Integrate — update positions, apply damping, clamp velocity.
233
+ // Pinned nodes keep their x/y and have velocity zeroed so they
234
+ // don't drift when released later with pending momentum.
233
235
  for (const node of nodes) {
236
+ if (node.pinned) {
237
+ node.vx = 0;
238
+ node.vy = 0;
239
+ continue;
240
+ }
234
241
  node.vx *= DAMPING;
235
242
  node.vy *= DAMPING;
236
243
  const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy);