backpack-viewer 0.2.17 → 0.2.20

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/canvas.js CHANGED
@@ -6,19 +6,18 @@ function cssVar(name) {
6
6
  }
7
7
  const NODE_RADIUS = 20;
8
8
  const ALPHA_MIN = 0.001;
9
- // Level-of-detail thresholds based on camera scale
10
- const LOD_HIDE_BADGES = 0.4; // hide type badges above nodes
11
- const LOD_HIDE_LABELS = 0.25; // hide node labels below nodes
12
- const LOD_HIDE_EDGE_LABELS = 0.35; // hide edge labels even if enabled
13
- const LOD_SMALL_NODES = 0.2; // shrink nodes to half size
14
- const LOD_HIDE_ARROWS = 0.15; // hide arrowheads, draw 1px edges
9
+ // Defaults overridden per-instance via config
10
+ const LOD_DEFAULTS = { hideBadges: 0.4, hideLabels: 0.25, hideEdgeLabels: 0.35, smallNodes: 0.2, hideArrows: 0.15 };
11
+ const NAV_DEFAULTS = { zoomFactor: 1.3, zoomMin: 0.05, zoomMax: 10, panAnimationMs: 300 };
15
12
  /** Check if a point is within the visible viewport (with padding). */
16
13
  function isInViewport(x, y, camera, canvasW, canvasH, pad = 100) {
17
14
  const sx = (x - camera.x) * camera.scale;
18
15
  const sy = (y - camera.y) * camera.scale;
19
16
  return sx >= -pad && sx <= canvasW + pad && sy >= -pad && sy <= canvasH + pad;
20
17
  }
21
- export function initCanvas(container, onNodeClick, onFocusChange) {
18
+ export function initCanvas(container, onNodeClick, onFocusChange, config) {
19
+ const lod = { ...LOD_DEFAULTS, ...(config?.lod ?? {}) };
20
+ const nav = { ...NAV_DEFAULTS, ...(config?.navigation ?? {}) };
22
21
  const canvas = container.querySelector("canvas");
23
22
  const ctx = canvas.getContext("2d");
24
23
  const dpr = window.devicePixelRatio || 1;
@@ -41,7 +40,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
41
40
  // Pan animation state
42
41
  let panTarget = null;
43
42
  let panStart = null;
44
- const PAN_DURATION = 300;
43
+ const PAN_DURATION = nav.panAnimationMs;
45
44
  // --- Sizing ---
46
45
  function resize() {
47
46
  canvas.width = canvas.clientWidth * dpr;
@@ -102,7 +101,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
102
101
  ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
103
102
  ctx.scale(camera.scale, camera.scale);
104
103
  // Draw type hulls (shaded regions behind same-type nodes)
105
- if (showTypeHulls && camera.scale >= LOD_SMALL_NODES) {
104
+ if (showTypeHulls && camera.scale >= lod.smallNodes) {
106
105
  const typeGroups = new Map();
107
106
  for (const node of state.nodes) {
108
107
  if (filteredNodeIds !== null && !filteredNodeIds.has(node.id))
@@ -181,14 +180,14 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
181
180
  : edgeDimmed
182
181
  ? edgeDimColor
183
182
  : edgeColor;
184
- ctx.lineWidth = camera.scale < LOD_HIDE_ARROWS ? 1 : highlighted ? 2.5 : 1.5;
183
+ ctx.lineWidth = camera.scale < lod.hideArrows ? 1 : highlighted ? 2.5 : 1.5;
185
184
  ctx.stroke();
186
185
  // Arrowhead
187
- if (camera.scale >= LOD_HIDE_ARROWS) {
186
+ if (camera.scale >= lod.hideArrows) {
188
187
  drawArrowhead(source.x, source.y, target.x, target.y, highlighted, arrowColor, arrowHighlight);
189
188
  }
190
189
  // Edge label at midpoint
191
- if (showEdgeLabels && camera.scale >= LOD_HIDE_EDGE_LABELS) {
190
+ if (showEdgeLabels && camera.scale >= lod.hideEdgeLabels) {
192
191
  const mx = (source.x + target.x) / 2;
193
192
  const my = (source.y + target.y) / 2;
194
193
  ctx.fillStyle = highlighted
@@ -215,7 +214,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
215
214
  const filteredOut = filteredNodeIds !== null && !filteredNodeIds.has(node.id);
216
215
  const dimmed = filteredOut ||
217
216
  (selectedNodeIds.size > 0 && !isSelected && !isNeighbor);
218
- const r = camera.scale < LOD_SMALL_NODES ? NODE_RADIUS * 0.5 : NODE_RADIUS;
217
+ const r = camera.scale < lod.smallNodes ? NODE_RADIUS * 0.5 : NODE_RADIUS;
219
218
  // Glow for selected node
220
219
  if (isSelected) {
221
220
  ctx.save();
@@ -238,7 +237,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
238
237
  ctx.lineWidth = isSelected ? 3 : 1.5;
239
238
  ctx.stroke();
240
239
  // Label below
241
- if (camera.scale >= LOD_HIDE_LABELS) {
240
+ if (camera.scale >= lod.hideLabels) {
242
241
  const label = node.label.length > 24 ? node.label.slice(0, 22) + "..." : node.label;
243
242
  ctx.fillStyle = dimmed ? nodeLabelDim : nodeLabel;
244
243
  ctx.font = "11px system-ui, sans-serif";
@@ -247,7 +246,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
247
246
  ctx.fillText(label, node.x, node.y + r + 4);
248
247
  }
249
248
  // Type badge above
250
- if (camera.scale >= LOD_HIDE_BADGES) {
249
+ if (camera.scale >= lod.hideBadges) {
251
250
  ctx.fillStyle = dimmed ? typeBadgeDim : typeBadge;
252
251
  ctx.font = "9px system-ui, sans-serif";
253
252
  ctx.textBaseline = "bottom";
@@ -471,7 +470,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
471
470
  : e.deltaY > 0
472
471
  ? 0.9
473
472
  : 1.1;
474
- camera.scale = Math.max(0.05, Math.min(10, camera.scale * factor));
473
+ camera.scale = Math.max(nav.zoomMin, Math.min(nav.zoomMax, camera.scale * factor));
475
474
  camera.x = wx - mx / camera.scale;
476
475
  camera.y = wy - my / camera.scale;
477
476
  render();
@@ -504,7 +503,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
504
503
  if (current.length === 2 && touches.length === 2) {
505
504
  const dist = touchDistance(current[0], current[1]);
506
505
  const ratio = dist / initialPinchDist;
507
- camera.scale = Math.max(0.05, Math.min(10, initialPinchScale * ratio));
506
+ camera.scale = Math.max(nav.zoomMin, Math.min(nav.zoomMax, initialPinchScale * ratio));
508
507
  render();
509
508
  }
510
509
  else if (current.length === 1) {
@@ -567,7 +566,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
567
566
  const cx = canvas.clientWidth / 2;
568
567
  const cy = canvas.clientHeight / 2;
569
568
  const [wx, wy] = screenToWorld(cx, cy);
570
- camera.scale = Math.min(10, camera.scale * 1.3);
569
+ camera.scale = Math.min(nav.zoomMax, camera.scale * nav.zoomFactor);
571
570
  camera.x = wx - cx / camera.scale;
572
571
  camera.y = wy - cy / camera.scale;
573
572
  render();
@@ -580,7 +579,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
580
579
  const cx = canvas.clientWidth / 2;
581
580
  const cy = canvas.clientHeight / 2;
582
581
  const [wx, wy] = screenToWorld(cx, cy);
583
- camera.scale = Math.max(0.05, camera.scale / 1.3);
582
+ camera.scale = Math.max(nav.zoomMin, camera.scale / nav.zoomFactor);
584
583
  camera.x = wx - cx / camera.scale;
585
584
  camera.y = wy - cy / camera.scale;
586
585
  render();
@@ -750,7 +749,7 @@ export function initCanvas(container, onNodeClick, onFocusChange) {
750
749
  const cx = canvas.clientWidth / 2;
751
750
  const cy = canvas.clientHeight / 2;
752
751
  const [wx, wy] = screenToWorld(cx, cy);
753
- camera.scale = Math.max(0.05, Math.min(10, camera.scale * factor));
752
+ camera.scale = Math.max(nav.zoomMin, Math.min(nav.zoomMax, camera.scale * factor));
754
753
  camera.x = wx - cx / camera.scale;
755
754
  camera.y = wy - cy / camera.scale;
756
755
  render();
package/dist/config.js CHANGED
@@ -16,13 +16,14 @@ export function loadViewerConfig() {
16
16
  const filePath = viewerConfigFile();
17
17
  try {
18
18
  const raw = fs.readFileSync(filePath, "utf-8");
19
- const userConfig = JSON.parse(raw);
19
+ const user = JSON.parse(raw);
20
20
  return {
21
- ...defaultConfig,
22
- keybindings: {
23
- ...defaultConfig.keybindings,
24
- ...(userConfig.keybindings ?? {}),
25
- },
21
+ keybindings: { ...defaultConfig.keybindings, ...(user.keybindings ?? {}) },
22
+ display: { ...defaultConfig.display, ...(user.display ?? {}) },
23
+ layout: { ...defaultConfig.layout, ...(user.layout ?? {}) },
24
+ navigation: { ...defaultConfig.navigation, ...(user.navigation ?? {}) },
25
+ lod: { ...defaultConfig.lod, ...(user.lod ?? {}) },
26
+ limits: { ...defaultConfig.limits, ...(user.limits ?? {}) },
26
27
  };
27
28
  }
28
29
  catch {
@@ -28,6 +28,39 @@
28
28
  "spacingDecrease": "[",
29
29
  "spacingIncrease": "]",
30
30
  "clusteringDecrease": "{",
31
- "clusteringIncrease": "}"
31
+ "clusteringIncrease": "}",
32
+ "toggleSidebar": "Tab"
33
+ },
34
+ "display": {
35
+ "edges": true,
36
+ "edgeLabels": true,
37
+ "typeHulls": true,
38
+ "minimap": true,
39
+ "theme": "system"
40
+ },
41
+ "layout": {
42
+ "spacing": 1.5,
43
+ "clustering": 0.08
44
+ },
45
+ "navigation": {
46
+ "panSpeed": 60,
47
+ "panFastMultiplier": 3,
48
+ "zoomFactor": 1.3,
49
+ "zoomMin": 0.05,
50
+ "zoomMax": 10,
51
+ "panAnimationMs": 300
52
+ },
53
+ "lod": {
54
+ "hideBadges": 0.4,
55
+ "hideLabels": 0.25,
56
+ "hideEdgeLabels": 0.35,
57
+ "smallNodes": 0.2,
58
+ "hideArrows": 0.15
59
+ },
60
+ "limits": {
61
+ "maxSearchResults": 8,
62
+ "maxQualityItems": 5,
63
+ "maxMostConnected": 5,
64
+ "searchDebounceMs": 150
32
65
  }
33
66
  }
@@ -0,0 +1,9 @@
1
+ /** Lightweight inline dialog system — replaces native alert/confirm/prompt. */
2
+ /** Show a confirmation dialog. Returns a promise that resolves to true/false. */
3
+ export declare function showConfirm(title: string, message: string): Promise<boolean>;
4
+ /** Show a prompt dialog with an input field. Returns null if cancelled. */
5
+ export declare function showPrompt(title: string, placeholder?: string, defaultValue?: string): Promise<string | null>;
6
+ /** Show a danger confirmation (for destructive actions). */
7
+ export declare function showDangerConfirm(title: string, message: string): Promise<boolean>;
8
+ /** Show a brief toast notification. */
9
+ export declare function showToast(message: string, durationMs?: number): void;
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
+ }
@@ -132,8 +132,11 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
132
132
  panel.classList.remove("hidden");
133
133
  if (maximized)
134
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";
135
138
  // Toolbar (back, forward, maximize, close)
136
- panel.appendChild(createToolbar());
139
+ pinnedHeader.appendChild(createToolbar());
137
140
  // Header: type badge + label
138
141
  const header = document.createElement("div");
139
142
  header.className = "info-header";
@@ -187,7 +190,11 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
187
190
  header.appendChild(typeBadge);
188
191
  header.appendChild(label);
189
192
  header.appendChild(nodeIdEl);
190
- 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";
191
198
  // Properties section (editable)
192
199
  const propKeys = Object.keys(node.properties);
193
200
  const propSection = createSection("Properties");
@@ -272,7 +279,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
272
279
  });
273
280
  propSection.appendChild(addBtn);
274
281
  }
275
- panel.appendChild(propSection);
282
+ body.appendChild(propSection);
276
283
  // Connections section
277
284
  if (connectedEdges.length > 0) {
278
285
  const section = createSection(`Connections (${connectedEdges.length})`);
@@ -341,7 +348,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
341
348
  list.appendChild(li);
342
349
  }
343
350
  section.appendChild(list);
344
- panel.appendChild(section);
351
+ body.appendChild(section);
345
352
  }
346
353
  // Timestamps
347
354
  const tsSection = createSection("Timestamps");
@@ -360,7 +367,7 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
360
367
  timestamps.appendChild(updatedDt);
361
368
  timestamps.appendChild(updatedDd);
362
369
  tsSection.appendChild(timestamps);
363
- panel.appendChild(tsSection);
370
+ body.appendChild(tsSection);
364
371
  // Delete node button
365
372
  if (callbacks) {
366
373
  const deleteSection = document.createElement("div");
@@ -373,8 +380,9 @@ export function initInfoPanel(container, callbacks, onNavigateToNode, onFocus) {
373
380
  hide();
374
381
  });
375
382
  deleteSection.appendChild(deleteBtn);
376
- panel.appendChild(deleteSection);
383
+ body.appendChild(deleteSection);
377
384
  }
385
+ panel.appendChild(body);
378
386
  }
379
387
  function showMulti(nodeIds, data) {
380
388
  const selectedSet = new Set(nodeIds);
@@ -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";
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
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;
@@ -1,26 +1,34 @@
1
1
  /** Parse a binding string like "ctrl+shift+z" and check if it matches a KeyboardEvent. */
2
2
  export function matchKey(e, binding) {
3
- const parts = binding.toLowerCase().split("+");
3
+ const parts = binding.split("+");
4
4
  const key = parts.pop();
5
- const needCtrl = parts.includes("ctrl") || parts.includes("cmd") || parts.includes("meta");
6
- const needShift = parts.includes("shift");
7
- const needAlt = parts.includes("alt");
8
- // Modifier checks
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
9
10
  if (needCtrl !== (e.ctrlKey || e.metaKey))
10
11
  return false;
11
- if (needShift !== e.shiftKey)
12
+ if (!needCtrl && (e.ctrlKey || e.metaKey))
12
13
  return false;
13
- if (needAlt !== e.altKey)
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)
14
17
  return false;
15
- // For plain keys (no modifiers required), reject if ctrl/meta is held
16
- if (!needCtrl && (e.ctrlKey || e.metaKey))
18
+ // Alt check
19
+ if (needAlt !== e.altKey)
17
20
  return false;
18
- // Key match — case-sensitive for single chars, case-insensitive for named keys
19
- if (key === "escape")
21
+ // Named keys
22
+ if (key.toLowerCase() === "escape")
20
23
  return e.key === "Escape";
21
- if (key.length === 1)
22
- return e.key === binding.split("+").pop(); // preserve original case
23
- return e.key.toLowerCase() === key;
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;
24
32
  }
25
33
  /** Build a reverse map: for each action, store its binding string. Used by the help modal. */
26
34
  export function actionDescriptions() {
@@ -54,5 +62,6 @@ export function actionDescriptions() {
54
62
  spacingIncrease: "Increase spacing",
55
63
  clusteringDecrease: "Decrease clustering",
56
64
  clusteringIncrease: "Increase clustering",
65
+ toggleSidebar: "Toggle sidebar",
57
66
  };
58
67
  }
package/dist/main.js CHANGED
@@ -1,4 +1,4 @@
1
- import { listOntologies, loadOntology, saveOntology, renameOntology } from "./api";
1
+ import { listOntologies, loadOntology, saveOntology, renameOntology, listBranches, createBranch, switchBranch, deleteBranch, listSnapshots, createSnapshot, rollbackSnapshot, } from "./api";
2
2
  import { initSidebar } from "./sidebar";
3
3
  import { initCanvas } from "./canvas";
4
4
  import { initInfoPanel } from "./info-panel";
@@ -15,20 +15,29 @@ let activeOntology = "";
15
15
  let currentData = null;
16
16
  async function main() {
17
17
  const canvasContainer = document.getElementById("canvas-container");
18
- // --- Load config (keybindings) ---
19
- let bindings = defaultConfig.keybindings;
18
+ // --- Load config ---
19
+ const cfg = { ...defaultConfig };
20
20
  try {
21
21
  const res = await fetch("/api/config");
22
22
  if (res.ok) {
23
- const config = await res.json();
24
- bindings = { ...bindings, ...(config.keybindings ?? {}) };
23
+ const user = await res.json();
24
+ Object.assign(cfg.keybindings, user.keybindings ?? {});
25
+ Object.assign(cfg.display, user.display ?? {});
26
+ Object.assign(cfg.layout, user.layout ?? {});
27
+ Object.assign(cfg.navigation, user.navigation ?? {});
28
+ Object.assign(cfg.lod, user.lod ?? {});
29
+ Object.assign(cfg.limits, user.limits ?? {});
25
30
  }
26
31
  }
27
32
  catch { /* use defaults */ }
33
+ const bindings = cfg.keybindings;
28
34
  // --- Theme toggle (top-right of canvas) ---
29
35
  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
36
+ const themeDefault = cfg.display.theme === "system"
37
+ ? (prefersDark.matches ? "dark" : "light")
38
+ : cfg.display.theme;
30
39
  const stored = localStorage.getItem("backpack-theme");
31
- const initial = stored ?? (prefersDark.matches ? "dark" : "light");
40
+ const initial = stored ?? themeDefault;
32
41
  document.documentElement.setAttribute("data-theme", initial);
33
42
  const themeBtn = document.createElement("button");
34
43
  themeBtn.className = "theme-toggle";
@@ -138,8 +147,8 @@ async function main() {
138
147
  const mobileQuery = window.matchMedia("(max-width: 768px)");
139
148
  // Track current selection for keyboard shortcuts
140
149
  let currentSelection = [];
141
- let edgesVisible = true;
142
- let panSpeed = 60;
150
+ let edgesVisible = cfg.display.edges;
151
+ let panSpeed = cfg.navigation.panSpeed;
143
152
  let viewCycleIndex = -1;
144
153
  // --- Focus indicator (top bar pill) ---
145
154
  let focusIndicator = null;
@@ -215,8 +224,11 @@ async function main() {
215
224
  if (activeOntology)
216
225
  updateUrl(activeOntology);
217
226
  }
227
+ }, { lod: cfg.lod, navigation: cfg.navigation });
228
+ const search = initSearch(canvasContainer, {
229
+ maxResults: cfg.limits.maxSearchResults,
230
+ debounceMs: cfg.limits.searchDebounceMs,
218
231
  });
219
- const search = initSearch(canvasContainer);
220
232
  const toolsPane = initToolsPane(canvasContainer, {
221
233
  onFilterByType(type) {
222
234
  if (!currentData)
@@ -293,6 +305,22 @@ async function main() {
293
305
  link.href = dataUrl;
294
306
  link.click();
295
307
  },
308
+ onSnapshot: async (label) => {
309
+ if (!activeOntology)
310
+ return;
311
+ await createSnapshot(activeOntology, label);
312
+ await refreshSnapshots(activeOntology);
313
+ },
314
+ onRollback: async (version) => {
315
+ if (!activeOntology)
316
+ return;
317
+ await rollbackSnapshot(activeOntology, version);
318
+ currentData = await loadOntology(activeOntology);
319
+ canvas.loadGraph(currentData);
320
+ search.setLearningGraphData(currentData);
321
+ toolsPane.setData(currentData);
322
+ await refreshSnapshots(activeOntology);
323
+ },
296
324
  onOpen() {
297
325
  if (mobileQuery.matches)
298
326
  infoPanel.hide();
@@ -354,9 +382,46 @@ async function main() {
354
382
  toolsPane.setData(currentData);
355
383
  }
356
384
  },
385
+ onBranchSwitch: async (graphName, branchName) => {
386
+ await switchBranch(graphName, branchName);
387
+ await refreshBranches(graphName);
388
+ currentData = await loadOntology(graphName);
389
+ canvas.loadGraph(currentData);
390
+ search.setLearningGraphData(currentData);
391
+ toolsPane.setData(currentData);
392
+ await refreshSnapshots(graphName);
393
+ },
394
+ onBranchCreate: async (graphName, branchName) => {
395
+ await createBranch(graphName, branchName);
396
+ await refreshBranches(graphName);
397
+ },
398
+ onBranchDelete: async (graphName, branchName) => {
399
+ await deleteBranch(graphName, branchName);
400
+ await refreshBranches(graphName);
401
+ },
357
402
  });
403
+ async function refreshBranches(graphName) {
404
+ const branches = await listBranches(graphName);
405
+ const active = branches.find((b) => b.active);
406
+ if (active) {
407
+ sidebar.setActiveBranch(graphName, active.name, branches);
408
+ }
409
+ }
410
+ async function refreshSnapshots(graphName) {
411
+ const snaps = await listSnapshots(graphName);
412
+ toolsPane.setSnapshots(snaps);
413
+ }
358
414
  const shortcuts = initShortcuts(canvasContainer, bindings);
359
415
  const emptyState = initEmptyState(canvasContainer);
416
+ // Apply display defaults from config
417
+ if (!cfg.display.edges)
418
+ canvas.setEdges(false);
419
+ if (!cfg.display.edgeLabels)
420
+ canvas.setEdgeLabels(false);
421
+ if (!cfg.display.typeHulls)
422
+ canvas.setTypeHulls(false);
423
+ if (!cfg.display.minimap)
424
+ canvas.setMinimap(false);
360
425
  // --- URL deep linking ---
361
426
  function updateUrl(name, nodeIds) {
362
427
  const parts = [];
@@ -403,12 +468,19 @@ async function main() {
403
468
  search.clear();
404
469
  undoHistory.clear();
405
470
  currentData = await loadOntology(name);
406
- setLayoutParams(autoLayoutParams(currentData.nodes.length));
471
+ const autoParams = autoLayoutParams(currentData.nodes.length);
472
+ setLayoutParams({
473
+ spacing: Math.max(cfg.layout.spacing, autoParams.spacing),
474
+ clusterStrength: Math.max(cfg.layout.clustering, autoParams.clusterStrength),
475
+ });
407
476
  canvas.loadGraph(currentData);
408
477
  search.setLearningGraphData(currentData);
409
478
  toolsPane.setData(currentData);
410
479
  emptyState.hide();
411
480
  updateUrl(name);
481
+ // Load branches and snapshots
482
+ await refreshBranches(name);
483
+ await refreshSnapshots(name);
412
484
  // Restore focus mode if requested
413
485
  if (focusSeedIds?.length && currentData) {
414
486
  const validFocus = focusSeedIds.filter((id) => currentData.nodes.some((n) => n.id === id));
@@ -504,15 +576,16 @@ async function main() {
504
576
  panDown() { canvas.panBy(0, panSpeed); },
505
577
  panUp() { canvas.panBy(0, -panSpeed); },
506
578
  panRight() { canvas.panBy(panSpeed, 0); },
507
- panFastLeft() { canvas.panBy(-panSpeed * 3, 0); },
508
- zoomOut() { canvas.zoomBy(0.8); },
509
- zoomIn() { canvas.zoomBy(1.25); },
510
- panFastRight() { canvas.panBy(panSpeed * 3, 0); },
579
+ panFastLeft() { canvas.panBy(-panSpeed * cfg.navigation.panFastMultiplier, 0); },
580
+ zoomOut() { canvas.zoomBy(1 / cfg.navigation.zoomFactor); },
581
+ zoomIn() { canvas.zoomBy(cfg.navigation.zoomFactor); },
582
+ panFastRight() { canvas.panBy(panSpeed * cfg.navigation.panFastMultiplier, 0); },
511
583
  spacingDecrease() { const p = getLayoutParams(); setLayoutParams({ spacing: Math.max(0.5, p.spacing - 0.5) }); canvas.reheat(); },
512
584
  spacingIncrease() { const p = getLayoutParams(); setLayoutParams({ spacing: Math.min(20, p.spacing + 0.5) }); canvas.reheat(); },
513
585
  clusteringDecrease() { const p = getLayoutParams(); setLayoutParams({ clusterStrength: Math.max(0, p.clusterStrength - 0.03) }); canvas.reheat(); },
514
586
  clusteringIncrease() { const p = getLayoutParams(); setLayoutParams({ clusterStrength: Math.min(1, p.clusterStrength + 0.03) }); canvas.reheat(); },
515
587
  help() { shortcuts.toggle(); },
588
+ toggleSidebar() { sidebar.toggle(); },
516
589
  escape() { if (canvas.isFocused()) {
517
590
  toolsPane.clearFocusSet();
518
591
  }
@@ -525,7 +598,7 @@ async function main() {
525
598
  return;
526
599
  for (const [action, binding] of Object.entries(bindings)) {
527
600
  if (matchKey(e, binding)) {
528
- const needsPrevent = action === "search" || action === "searchAlt" || action === "undo" || action === "redo";
601
+ const needsPrevent = action === "search" || action === "searchAlt" || action === "undo" || action === "redo" || action === "toggleSidebar";
529
602
  if (needsPrevent)
530
603
  e.preventDefault();
531
604
  actions[action]?.();
package/dist/search.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { LearningGraphData } from "backpack-ontology";
2
- export declare function initSearch(container: HTMLElement): {
2
+ export interface SearchConfig {
3
+ maxResults?: number;
4
+ debounceMs?: number;
5
+ }
6
+ export declare function initSearch(container: HTMLElement, config?: SearchConfig): {
3
7
  setLearningGraphData(newData: LearningGraphData | null): void;
4
8
  onFilterChange(cb: (ids: Set<string> | null) => void): void;
5
9
  onNodeSelect(cb: (nodeId: string) => void): void;