backpack-viewer 0.2.16 → 0.2.19

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/dist/dialog.js ADDED
@@ -0,0 +1,119 @@
1
+ /** Lightweight inline dialog system — replaces native alert/confirm/prompt. */
2
+ const DIALOG_CSS_CLASS = "bp-dialog-overlay";
3
+ function createOverlay() {
4
+ // Remove any existing dialog
5
+ document.querySelector(`.${DIALOG_CSS_CLASS}`)?.remove();
6
+ const overlay = document.createElement("div");
7
+ overlay.className = DIALOG_CSS_CLASS;
8
+ document.body.appendChild(overlay);
9
+ return overlay;
10
+ }
11
+ function createModal(overlay, title) {
12
+ const modal = document.createElement("div");
13
+ modal.className = "bp-dialog";
14
+ const heading = document.createElement("h4");
15
+ heading.className = "bp-dialog-title";
16
+ heading.textContent = title;
17
+ modal.appendChild(heading);
18
+ overlay.appendChild(modal);
19
+ overlay.addEventListener("click", (e) => {
20
+ if (e.target === overlay)
21
+ overlay.remove();
22
+ });
23
+ return modal;
24
+ }
25
+ function addButtons(modal, buttons) {
26
+ const row = document.createElement("div");
27
+ row.className = "bp-dialog-buttons";
28
+ for (const btn of buttons) {
29
+ const el = document.createElement("button");
30
+ el.className = "bp-dialog-btn";
31
+ if (btn.accent)
32
+ el.classList.add("bp-dialog-btn-accent");
33
+ if (btn.danger)
34
+ el.classList.add("bp-dialog-btn-danger");
35
+ el.textContent = btn.label;
36
+ el.addEventListener("click", btn.onClick);
37
+ row.appendChild(el);
38
+ }
39
+ modal.appendChild(row);
40
+ }
41
+ /** Show a confirmation dialog. Returns a promise that resolves to true/false. */
42
+ export function showConfirm(title, message) {
43
+ return new Promise((resolve) => {
44
+ const overlay = createOverlay();
45
+ const modal = createModal(overlay, title);
46
+ const msg = document.createElement("p");
47
+ msg.className = "bp-dialog-message";
48
+ msg.textContent = message;
49
+ modal.appendChild(msg);
50
+ addButtons(modal, [
51
+ { label: "Cancel", onClick: () => { overlay.remove(); resolve(false); } },
52
+ { label: "Confirm", accent: true, onClick: () => { overlay.remove(); resolve(true); } },
53
+ ]);
54
+ // Focus the confirm button
55
+ modal.querySelector(".bp-dialog-btn-accent")?.focus();
56
+ });
57
+ }
58
+ /** Show a prompt dialog with an input field. Returns null if cancelled. */
59
+ export function showPrompt(title, placeholder, defaultValue) {
60
+ return new Promise((resolve) => {
61
+ const overlay = createOverlay();
62
+ const modal = createModal(overlay, title);
63
+ const input = document.createElement("input");
64
+ input.type = "text";
65
+ input.className = "bp-dialog-input";
66
+ input.placeholder = placeholder ?? "";
67
+ input.value = defaultValue ?? "";
68
+ modal.appendChild(input);
69
+ const submit = () => {
70
+ const val = input.value.trim();
71
+ overlay.remove();
72
+ resolve(val || null);
73
+ };
74
+ input.addEventListener("keydown", (e) => {
75
+ if (e.key === "Enter")
76
+ submit();
77
+ if (e.key === "Escape") {
78
+ overlay.remove();
79
+ resolve(null);
80
+ }
81
+ });
82
+ addButtons(modal, [
83
+ { label: "Cancel", onClick: () => { overlay.remove(); resolve(null); } },
84
+ { label: "OK", accent: true, onClick: submit },
85
+ ]);
86
+ input.focus();
87
+ input.select();
88
+ });
89
+ }
90
+ /** Show a danger confirmation (for destructive actions). */
91
+ export function showDangerConfirm(title, message) {
92
+ return new Promise((resolve) => {
93
+ const overlay = createOverlay();
94
+ const modal = createModal(overlay, title);
95
+ const msg = document.createElement("p");
96
+ msg.className = "bp-dialog-message";
97
+ msg.textContent = message;
98
+ modal.appendChild(msg);
99
+ addButtons(modal, [
100
+ { label: "Cancel", onClick: () => { overlay.remove(); resolve(false); } },
101
+ { label: "Delete", danger: true, onClick: () => { overlay.remove(); resolve(true); } },
102
+ ]);
103
+ });
104
+ }
105
+ /** Show a brief toast notification. */
106
+ export function showToast(message, durationMs = 3000) {
107
+ const existing = document.querySelector(".bp-toast");
108
+ if (existing)
109
+ existing.remove();
110
+ const toast = document.createElement("div");
111
+ toast.className = "bp-toast";
112
+ toast.textContent = message;
113
+ document.body.appendChild(toast);
114
+ setTimeout(() => toast.classList.add("bp-toast-visible"), 10);
115
+ setTimeout(() => {
116
+ toast.classList.remove("bp-toast-visible");
117
+ setTimeout(() => toast.remove(), 300);
118
+ }, durationMs);
119
+ }
@@ -9,5 +9,9 @@ export interface EditCallbacks {
9
9
  export declare function initInfoPanel(container: HTMLElement, callbacks?: EditCallbacks, onNavigateToNode?: (nodeId: string) => void, onFocus?: (nodeIds: string[]) => void): {
10
10
  show(nodeIds: string[], data: LearningGraphData): void;
11
11
  hide: () => void;
12
+ goBack: () => void;
13
+ goForward: () => void;
14
+ cycleConnection(direction: 1 | -1): string | null;
15
+ setFocusDisabled(disabled: boolean): void;
12
16
  readonly visible: boolean;
13
17
  };
@@ -20,6 +20,9 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
20
20
  let navigatingHistory = false;
21
21
  let lastData = null;
22
22
  let currentNodeIds = [];
23
+ let focusDisabled = false;
24
+ let connectionNodeIds = []; // other-end node IDs for each connection
25
+ let activeConnectionIndex = -1;
23
26
  function hide() {
24
27
  panel.classList.add("hidden");
25
28
  panel.classList.remove("info-panel-maximized");
@@ -43,19 +46,23 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
43
46
  navigatingHistory = false;
44
47
  }
45
48
  function goBack() {
46
- if (historyIndex <= 0 || !lastData || !onNavigateToNode)
49
+ if (historyIndex <= 0 || !lastData)
47
50
  return;
48
51
  historyIndex--;
49
52
  navigatingHistory = true;
50
- onNavigateToNode(history[historyIndex]);
53
+ const nodeId = history[historyIndex];
54
+ onNavigateToNode?.(nodeId);
55
+ showSingle(nodeId, lastData);
51
56
  navigatingHistory = false;
52
57
  }
53
58
  function goForward() {
54
- if (historyIndex >= history.length - 1 || !lastData || !onNavigateToNode)
59
+ if (historyIndex >= history.length - 1 || !lastData)
55
60
  return;
56
61
  historyIndex++;
57
62
  navigatingHistory = true;
58
- onNavigateToNode(history[historyIndex]);
63
+ const nodeId = history[historyIndex];
64
+ onNavigateToNode?.(nodeId);
65
+ showSingle(nodeId, lastData);
59
66
  navigatingHistory = false;
60
67
  }
61
68
  function createToolbar() {
@@ -83,8 +90,12 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
83
90
  focusBtn.className = "info-toolbar-btn info-focus-btn";
84
91
  focusBtn.textContent = "\u25CE"; // bullseye
85
92
  focusBtn.title = "Focus on neighborhood (F)";
93
+ focusBtn.disabled = focusDisabled;
94
+ if (focusDisabled)
95
+ focusBtn.style.opacity = "0.3";
86
96
  focusBtn.addEventListener("click", () => {
87
- onFocus(currentNodeIds);
97
+ if (!focusDisabled)
98
+ onFocus(currentNodeIds);
88
99
  });
89
100
  toolbar.appendChild(focusBtn);
90
101
  }
@@ -114,12 +125,18 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
114
125
  if (!node)
115
126
  return;
116
127
  const connectedEdges = data.edges.filter((e) => e.sourceId === nodeId || e.targetId === nodeId);
128
+ // Store connection targets for keyboard cycling
129
+ connectionNodeIds = connectedEdges.map((e) => e.sourceId === nodeId ? e.targetId : e.sourceId);
130
+ activeConnectionIndex = -1;
117
131
  panel.innerHTML = "";
118
132
  panel.classList.remove("hidden");
119
133
  if (maximized)
120
134
  panel.classList.add("info-panel-maximized");
135
+ // Pinned header area (toolbar + node identity)
136
+ const pinnedHeader = document.createElement("div");
137
+ pinnedHeader.className = "info-panel-header";
121
138
  // Toolbar (back, forward, maximize, close)
122
- panel.appendChild(createToolbar());
139
+ pinnedHeader.appendChild(createToolbar());
123
140
  // Header: type badge + label
124
141
  const header = document.createElement("div");
125
142
  header.className = "info-header";
@@ -173,7 +190,11 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
173
190
  header.appendChild(typeBadge);
174
191
  header.appendChild(label);
175
192
  header.appendChild(nodeIdEl);
176
- panel.appendChild(header);
193
+ pinnedHeader.appendChild(header);
194
+ panel.appendChild(pinnedHeader);
195
+ // Scrollable body for properties, connections, timestamps
196
+ const body = document.createElement("div");
197
+ body.className = "info-panel-body";
177
198
  // Properties section (editable)
178
199
  const propKeys = Object.keys(node.properties);
179
200
  const propSection = createSection("Properties");
@@ -258,7 +279,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
258
279
  });
259
280
  propSection.appendChild(addBtn);
260
281
  }
261
- panel.appendChild(propSection);
282
+ body.appendChild(propSection);
262
283
  // Connections section
263
284
  if (connectedEdges.length > 0) {
264
285
  const section = createSection(`Connections (${connectedEdges.length})`);
@@ -327,7 +348,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
327
348
  list.appendChild(li);
328
349
  }
329
350
  section.appendChild(list);
330
- panel.appendChild(section);
351
+ body.appendChild(section);
331
352
  }
332
353
  // Timestamps
333
354
  const tsSection = createSection("Timestamps");
@@ -346,7 +367,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
346
367
  timestamps.appendChild(updatedDt);
347
368
  timestamps.appendChild(updatedDd);
348
369
  tsSection.appendChild(timestamps);
349
- panel.appendChild(tsSection);
370
+ body.appendChild(tsSection);
350
371
  // Delete node button
351
372
  if (callbacks) {
352
373
  const deleteSection = document.createElement("div");
@@ -359,8 +380,9 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
359
380
  hide();
360
381
  });
361
382
  deleteSection.appendChild(deleteBtn);
362
- panel.appendChild(deleteSection);
383
+ body.appendChild(deleteSection);
363
384
  }
385
+ panel.appendChild(body);
364
386
  }
365
387
  function showMulti(nodeIds, data) {
366
388
  const selectedSet = new Set(nodeIds);
@@ -521,6 +543,39 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
521
543
  }
522
544
  },
523
545
  hide,
546
+ goBack,
547
+ goForward,
548
+ cycleConnection(direction) {
549
+ if (connectionNodeIds.length === 0)
550
+ return null;
551
+ if (activeConnectionIndex === -1) {
552
+ activeConnectionIndex = direction === 1 ? 0 : connectionNodeIds.length - 1;
553
+ }
554
+ else {
555
+ activeConnectionIndex += direction;
556
+ if (activeConnectionIndex >= connectionNodeIds.length)
557
+ activeConnectionIndex = 0;
558
+ if (activeConnectionIndex < 0)
559
+ activeConnectionIndex = connectionNodeIds.length - 1;
560
+ }
561
+ // Highlight active row in the panel
562
+ const items = panel.querySelectorAll(".info-connection");
563
+ items.forEach((el, i) => {
564
+ el.classList.toggle("info-connection-active", i === activeConnectionIndex);
565
+ });
566
+ if (activeConnectionIndex >= 0 && items[activeConnectionIndex]) {
567
+ items[activeConnectionIndex].scrollIntoView({ block: "nearest" });
568
+ }
569
+ return connectionNodeIds[activeConnectionIndex] ?? null;
570
+ },
571
+ setFocusDisabled(disabled) {
572
+ 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
+ }
578
+ },
524
579
  get visible() {
525
580
  return !panel.classList.contains("hidden");
526
581
  },
@@ -0,0 +1,6 @@
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";
2
+ export type KeybindingMap = Record<KeybindingAction, string>;
3
+ /** Parse a binding string like "ctrl+shift+z" and check if it matches a KeyboardEvent. */
4
+ export declare function matchKey(e: KeyboardEvent, binding: string): boolean;
5
+ /** Build a reverse map: for each action, store its binding string. Used by the help modal. */
6
+ export declare function actionDescriptions(): Record<KeybindingAction, string>;
@@ -0,0 +1,67 @@
1
+ /** Parse a binding string like "ctrl+shift+z" and check if it matches a KeyboardEvent. */
2
+ export function matchKey(e, binding) {
3
+ const parts = binding.split("+");
4
+ const key = parts.pop();
5
+ const modifiers = parts.map((p) => p.toLowerCase());
6
+ const needCtrl = modifiers.includes("ctrl") || modifiers.includes("cmd") || modifiers.includes("meta");
7
+ const explicitShift = modifiers.includes("shift");
8
+ const needAlt = modifiers.includes("alt");
9
+ // Ctrl/meta check
10
+ if (needCtrl !== (e.ctrlKey || e.metaKey))
11
+ return false;
12
+ if (!needCtrl && (e.ctrlKey || e.metaKey))
13
+ return false;
14
+ // Only enforce shift when explicitly in the binding (e.g. "ctrl+shift+z").
15
+ // Plain chars like "K", ">", "?" implicitly require shift via their character.
16
+ if (explicitShift && !e.shiftKey)
17
+ return false;
18
+ // Alt check
19
+ if (needAlt !== e.altKey)
20
+ return false;
21
+ // Named keys
22
+ if (key.toLowerCase() === "escape")
23
+ return e.key === "Escape";
24
+ if (key.toLowerCase() === "tab")
25
+ return e.key === "Tab";
26
+ // For modified bindings (ctrl+z, ctrl+shift+z), compare case-insensitively
27
+ // because browsers vary on e.key casing when modifiers are held.
28
+ if (modifiers.length > 0)
29
+ return e.key.toLowerCase() === key.toLowerCase();
30
+ // For plain keys, compare exactly — "k" vs "K" distinguishes shift state.
31
+ return e.key === key;
32
+ }
33
+ /** Build a reverse map: for each action, store its binding string. Used by the help modal. */
34
+ export function actionDescriptions() {
35
+ return {
36
+ search: "Focus search",
37
+ searchAlt: "Focus search (alt)",
38
+ undo: "Undo",
39
+ redo: "Redo",
40
+ help: "Toggle help",
41
+ escape: "Exit focus / close panel",
42
+ focus: "Focus on selected / exit focus",
43
+ toggleEdges: "Toggle edges on/off",
44
+ center: "Center view on graph",
45
+ nextNode: "Next node in view",
46
+ prevNode: "Previous node in view",
47
+ nextConnection: "Next connection",
48
+ prevConnection: "Previous connection",
49
+ historyBack: "Node history back",
50
+ historyForward: "Node history forward",
51
+ hopsIncrease: "Increase hops",
52
+ hopsDecrease: "Decrease hops",
53
+ panLeft: "Pan left",
54
+ panDown: "Pan down",
55
+ panUp: "Pan up",
56
+ panRight: "Pan right",
57
+ panFastLeft: "Pan fast left",
58
+ zoomOut: "Zoom out",
59
+ zoomIn: "Zoom in",
60
+ panFastRight: "Pan fast right",
61
+ spacingDecrease: "Decrease spacing",
62
+ spacingIncrease: "Increase spacing",
63
+ clusteringDecrease: "Decrease clustering",
64
+ clusteringIncrease: "Increase clustering",
65
+ toggleSidebar: "Toggle sidebar",
66
+ };
67
+ }
package/dist/layout.d.ts CHANGED
@@ -25,6 +25,8 @@ export interface LayoutParams {
25
25
  export declare const DEFAULT_LAYOUT_PARAMS: LayoutParams;
26
26
  export declare function setLayoutParams(p: Partial<LayoutParams>): void;
27
27
  export declare function getLayoutParams(): LayoutParams;
28
+ /** Compute sensible default layout params based on graph size. */
29
+ export declare function autoLayoutParams(nodeCount: number): LayoutParams;
28
30
  /** Extract the N-hop neighborhood of seed nodes as a new subgraph. */
29
31
  export declare function extractSubgraph(data: LearningGraphData, seedIds: string[], hops: number): LearningGraphData;
30
32
  /** Create a layout state from ontology data. Nodes start grouped by type. */
package/dist/layout.js CHANGED
@@ -1,12 +1,12 @@
1
1
  export const DEFAULT_LAYOUT_PARAMS = {
2
- clusterStrength: 0.05,
3
- spacing: 1,
2
+ clusterStrength: 0.08,
3
+ spacing: 1.5,
4
4
  };
5
- const REPULSION = 5000;
6
- const CROSS_TYPE_REPULSION_BASE = 8000;
7
- const ATTRACTION = 0.005;
8
- const REST_LENGTH_SAME_BASE = 100;
9
- const REST_LENGTH_CROSS_BASE = 250;
5
+ const REPULSION = 6000;
6
+ const CROSS_TYPE_REPULSION_BASE = 12000;
7
+ const ATTRACTION = 0.004;
8
+ const REST_LENGTH_SAME_BASE = 140;
9
+ const REST_LENGTH_CROSS_BASE = 350;
10
10
  const DAMPING = 0.9;
11
11
  const CENTER_GRAVITY = 0.01;
12
12
  const MIN_DISTANCE = 30;
@@ -22,6 +22,16 @@ export function setLayoutParams(p) {
22
22
  export function getLayoutParams() {
23
23
  return { ...params };
24
24
  }
25
+ /** Compute sensible default layout params based on graph size. */
26
+ export function autoLayoutParams(nodeCount) {
27
+ if (nodeCount <= 30)
28
+ return { ...DEFAULT_LAYOUT_PARAMS };
29
+ const scale = Math.log2(nodeCount / 30);
30
+ return {
31
+ clusterStrength: Math.min(0.5, 0.08 + 0.06 * scale),
32
+ spacing: Math.min(15, 1.5 + 1.2 * scale),
33
+ };
34
+ }
25
35
  /** Extract a display label from a node — first string property value, fallback to id. */
26
36
  function nodeLabel(properties, id) {
27
37
  for (const value of Object.values(properties)) {
@@ -61,7 +71,7 @@ export function createLayout(data) {
61
71
  const nodeMap = new Map();
62
72
  // Group nodes by type for initial placement
63
73
  const types = [...new Set(data.nodes.map((n) => n.type))];
64
- const typeRadius = Math.sqrt(types.length) * REST_LENGTH_CROSS_BASE * 0.6;
74
+ const typeRadius = Math.sqrt(types.length) * REST_LENGTH_CROSS_BASE * 0.6 * Math.max(1, params.spacing);
65
75
  const typeCounters = new Map();
66
76
  const typeSizes = new Map();
67
77
  for (const n of data.nodes) {