backpack-viewer 0.2.13 → 0.2.14

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/layout.js CHANGED
@@ -1,10 +1,27 @@
1
+ export const DEFAULT_LAYOUT_PARAMS = {
2
+ clusterStrength: 0.05,
3
+ spacing: 1,
4
+ };
1
5
  const REPULSION = 5000;
6
+ const CROSS_TYPE_REPULSION_BASE = 8000;
2
7
  const ATTRACTION = 0.005;
3
- const REST_LENGTH = 150;
8
+ const REST_LENGTH_SAME_BASE = 100;
9
+ const REST_LENGTH_CROSS_BASE = 250;
4
10
  const DAMPING = 0.9;
5
11
  const CENTER_GRAVITY = 0.01;
6
12
  const MIN_DISTANCE = 30;
7
13
  const MAX_VELOCITY = 50;
14
+ // Active params — mutated by setLayoutParams()
15
+ let params = { ...DEFAULT_LAYOUT_PARAMS };
16
+ export function setLayoutParams(p) {
17
+ if (p.clusterStrength !== undefined)
18
+ params.clusterStrength = p.clusterStrength;
19
+ if (p.spacing !== undefined)
20
+ params.spacing = p.spacing;
21
+ }
22
+ export function getLayoutParams() {
23
+ return { ...params };
24
+ }
8
25
  /** Extract a display label from a node — first string property value, fallback to id. */
9
26
  function nodeLabel(properties, id) {
10
27
  for (const value of Object.values(properties)) {
@@ -13,16 +30,31 @@ function nodeLabel(properties, id) {
13
30
  }
14
31
  return id;
15
32
  }
16
- /** Create a layout state from ontology data. Nodes start in a circle. */
33
+ /** Create a layout state from ontology data. Nodes start grouped by type. */
17
34
  export function createLayout(data) {
18
- const radius = Math.sqrt(data.nodes.length) * REST_LENGTH * 0.5;
19
35
  const nodeMap = new Map();
20
- const nodes = data.nodes.map((n, i) => {
21
- const angle = (2 * Math.PI * i) / data.nodes.length;
36
+ // Group nodes by type for initial placement
37
+ const types = [...new Set(data.nodes.map((n) => n.type))];
38
+ const typeRadius = Math.sqrt(types.length) * REST_LENGTH_CROSS_BASE * 0.6;
39
+ const typeCounters = new Map();
40
+ const typeSizes = new Map();
41
+ for (const n of data.nodes) {
42
+ typeSizes.set(n.type, (typeSizes.get(n.type) ?? 0) + 1);
43
+ }
44
+ const nodes = data.nodes.map((n) => {
45
+ const ti = types.indexOf(n.type);
46
+ const typeAngle = (2 * Math.PI * ti) / Math.max(types.length, 1);
47
+ const cx = Math.cos(typeAngle) * typeRadius;
48
+ const cy = Math.sin(typeAngle) * typeRadius;
49
+ const ni = typeCounters.get(n.type) ?? 0;
50
+ typeCounters.set(n.type, ni + 1);
51
+ const groupSize = typeSizes.get(n.type) ?? 1;
52
+ const nodeAngle = (2 * Math.PI * ni) / groupSize;
53
+ const nodeRadius = REST_LENGTH_SAME_BASE * 0.6;
22
54
  const node = {
23
55
  id: n.id,
24
- x: Math.cos(angle) * radius,
25
- y: Math.sin(angle) * radius,
56
+ x: cx + Math.cos(nodeAngle) * nodeRadius,
57
+ y: cy + Math.sin(nodeAngle) * nodeRadius,
26
58
  vx: 0,
27
59
  vy: 0,
28
60
  label: nodeLabel(n.properties, n.id),
@@ -41,7 +73,7 @@ export function createLayout(data) {
41
73
  /** Run one tick of the force simulation. Returns new alpha. */
42
74
  export function tick(state, alpha) {
43
75
  const { nodes, edges, nodeMap } = state;
44
- // Repulsion — all pairs
76
+ // Repulsion — all pairs (stronger between different types)
45
77
  for (let i = 0; i < nodes.length; i++) {
46
78
  for (let j = i + 1; j < nodes.length; j++) {
47
79
  const a = nodes[i];
@@ -51,7 +83,8 @@ export function tick(state, alpha) {
51
83
  let dist = Math.sqrt(dx * dx + dy * dy);
52
84
  if (dist < MIN_DISTANCE)
53
85
  dist = MIN_DISTANCE;
54
- const force = (REPULSION * alpha) / (dist * dist);
86
+ const rep = a.type === b.type ? REPULSION : CROSS_TYPE_REPULSION_BASE * params.spacing;
87
+ const force = (rep * alpha) / (dist * dist);
55
88
  const fx = (dx / dist) * force;
56
89
  const fy = (dy / dist) * force;
57
90
  a.vx -= fx;
@@ -60,7 +93,7 @@ export function tick(state, alpha) {
60
93
  b.vy += fy;
61
94
  }
62
95
  }
63
- // Attraction — along edges
96
+ // Attraction — along edges (shorter rest length within same type)
64
97
  for (const edge of edges) {
65
98
  const source = nodeMap.get(edge.sourceId);
66
99
  const target = nodeMap.get(edge.targetId);
@@ -71,7 +104,10 @@ export function tick(state, alpha) {
71
104
  const dist = Math.sqrt(dx * dx + dy * dy);
72
105
  if (dist === 0)
73
106
  continue;
74
- const force = ATTRACTION * (dist - REST_LENGTH) * alpha;
107
+ const restLen = source.type === target.type
108
+ ? REST_LENGTH_SAME_BASE * params.spacing
109
+ : REST_LENGTH_CROSS_BASE * params.spacing;
110
+ const force = ATTRACTION * (dist - restLen) * alpha;
75
111
  const fx = (dx / dist) * force;
76
112
  const fy = (dy / dist) * force;
77
113
  source.vx += fx;
@@ -84,6 +120,24 @@ export function tick(state, alpha) {
84
120
  node.vx -= node.x * CENTER_GRAVITY * alpha;
85
121
  node.vy -= node.y * CENTER_GRAVITY * alpha;
86
122
  }
123
+ // Cluster force — pull nodes toward their type centroid
124
+ const centroids = new Map();
125
+ for (const node of nodes) {
126
+ const c = centroids.get(node.type) ?? { x: 0, y: 0, count: 0 };
127
+ c.x += node.x;
128
+ c.y += node.y;
129
+ c.count++;
130
+ centroids.set(node.type, c);
131
+ }
132
+ for (const c of centroids.values()) {
133
+ c.x /= c.count;
134
+ c.y /= c.count;
135
+ }
136
+ for (const node of nodes) {
137
+ const c = centroids.get(node.type);
138
+ node.vx += (c.x - node.x) * params.clusterStrength * alpha;
139
+ node.vy += (c.y - node.y) * params.clusterStrength * alpha;
140
+ }
87
141
  // Integrate — update positions, apply damping, clamp velocity
88
142
  for (const node of nodes) {
89
143
  node.vx *= DAMPING;
package/dist/main.js CHANGED
@@ -3,6 +3,11 @@ import { initSidebar } from "./sidebar";
3
3
  import { initCanvas } from "./canvas";
4
4
  import { initInfoPanel } from "./info-panel";
5
5
  import { initSearch } from "./search";
6
+ import { initToolsPane } from "./tools-pane";
7
+ import { setLayoutParams } from "./layout";
8
+ import { initShortcuts } from "./shortcuts";
9
+ import { initEmptyState } from "./empty-state";
10
+ import { createHistory } from "./history";
6
11
  import "./style.css";
7
12
  let activeOntology = "";
8
13
  let currentData = null;
@@ -25,6 +30,8 @@ async function main() {
25
30
  themeBtn.textContent = next === "light" ? "\u263E" : "\u263C";
26
31
  });
27
32
  canvasContainer.appendChild(themeBtn);
33
+ // --- Undo/redo ---
34
+ const undoHistory = createHistory();
28
35
  // --- Save and re-render helper ---
29
36
  async function save() {
30
37
  if (!activeOntology || !currentData)
@@ -33,10 +40,26 @@ async function main() {
33
40
  await saveOntology(activeOntology, currentData);
34
41
  canvas.loadGraph(currentData);
35
42
  search.setLearningGraphData(currentData);
43
+ toolsPane.setData(currentData);
36
44
  // Refresh sidebar counts
37
45
  const updated = await listOntologies();
38
46
  sidebar.setSummaries(updated);
39
47
  }
48
+ /** Snapshot current state, then save. Call this instead of save() for undoable actions. */
49
+ async function undoableSave() {
50
+ if (currentData)
51
+ undoHistory.push(currentData);
52
+ await save();
53
+ }
54
+ async function applyState(data) {
55
+ currentData = data;
56
+ await saveOntology(activeOntology, currentData);
57
+ canvas.loadGraph(currentData);
58
+ search.setLearningGraphData(currentData);
59
+ toolsPane.setData(currentData);
60
+ const updated = await listOntologies();
61
+ sidebar.setSummaries(updated);
62
+ }
40
63
  // --- Info panel with edit callbacks ---
41
64
  // canvas is used inside the navigate callback but declared below —
42
65
  // that's fine because the callback is only invoked after setup completes.
@@ -45,6 +68,7 @@ async function main() {
45
68
  onUpdateNode(nodeId, properties) {
46
69
  if (!currentData)
47
70
  return;
71
+ undoHistory.push(currentData);
48
72
  const node = currentData.nodes.find((n) => n.id === nodeId);
49
73
  if (!node)
50
74
  return;
@@ -55,6 +79,7 @@ async function main() {
55
79
  onChangeNodeType(nodeId, newType) {
56
80
  if (!currentData)
57
81
  return;
82
+ undoHistory.push(currentData);
58
83
  const node = currentData.nodes.find((n) => n.id === nodeId);
59
84
  if (!node)
60
85
  return;
@@ -65,6 +90,7 @@ async function main() {
65
90
  onDeleteNode(nodeId) {
66
91
  if (!currentData)
67
92
  return;
93
+ undoHistory.push(currentData);
68
94
  currentData.nodes = currentData.nodes.filter((n) => n.id !== nodeId);
69
95
  currentData.edges = currentData.edges.filter((e) => e.sourceId !== nodeId && e.targetId !== nodeId);
70
96
  save();
@@ -72,6 +98,7 @@ async function main() {
72
98
  onDeleteEdge(edgeId) {
73
99
  if (!currentData)
74
100
  return;
101
+ undoHistory.push(currentData);
75
102
  const selectedNodeId = currentData.edges.find((e) => e.id === edgeId)?.sourceId;
76
103
  currentData.edges = currentData.edges.filter((e) => e.id !== edgeId);
77
104
  save().then(() => {
@@ -83,6 +110,7 @@ async function main() {
83
110
  onAddProperty(nodeId, key, value) {
84
111
  if (!currentData)
85
112
  return;
113
+ undoHistory.push(currentData);
86
114
  const node = currentData.nodes.find((n) => n.id === nodeId);
87
115
  if (!node)
88
116
  return;
@@ -93,15 +121,116 @@ async function main() {
93
121
  }, (nodeId) => {
94
122
  canvas.panToNode(nodeId);
95
123
  });
124
+ const mobileQuery = window.matchMedia("(max-width: 768px)");
96
125
  canvas = initCanvas(canvasContainer, (nodeIds) => {
97
126
  if (nodeIds && nodeIds.length > 0 && currentData) {
98
127
  infoPanel.show(nodeIds, currentData);
128
+ if (mobileQuery.matches)
129
+ toolsPane.collapse();
130
+ updateUrl(activeOntology, nodeIds);
99
131
  }
100
132
  else {
101
133
  infoPanel.hide();
134
+ if (activeOntology)
135
+ updateUrl(activeOntology);
102
136
  }
103
137
  });
104
138
  const search = initSearch(canvasContainer);
139
+ const toolsPane = initToolsPane(canvasContainer, {
140
+ onFilterByType(type) {
141
+ if (!currentData)
142
+ return;
143
+ if (type === null) {
144
+ canvas.setFilteredNodeIds(null);
145
+ }
146
+ else {
147
+ const ids = new Set((currentData?.nodes ?? [])
148
+ .filter((n) => n.type === type)
149
+ .map((n) => n.id));
150
+ canvas.setFilteredNodeIds(ids);
151
+ }
152
+ },
153
+ onNavigateToNode(nodeId) {
154
+ canvas.panToNode(nodeId);
155
+ if (currentData)
156
+ infoPanel.show([nodeId], currentData);
157
+ },
158
+ onRenameNodeType(oldType, newType) {
159
+ if (!currentData)
160
+ return;
161
+ undoHistory.push(currentData);
162
+ for (const node of currentData.nodes) {
163
+ if (node.type === oldType) {
164
+ node.type = newType;
165
+ node.updatedAt = new Date().toISOString();
166
+ }
167
+ }
168
+ save();
169
+ },
170
+ onRenameEdgeType(oldType, newType) {
171
+ if (!currentData)
172
+ return;
173
+ undoHistory.push(currentData);
174
+ for (const edge of currentData.edges) {
175
+ if (edge.type === oldType) {
176
+ edge.type = newType;
177
+ }
178
+ }
179
+ save();
180
+ },
181
+ onToggleEdgeLabels(visible) {
182
+ canvas.setEdgeLabels(visible);
183
+ },
184
+ onToggleTypeHulls(visible) {
185
+ canvas.setTypeHulls(visible);
186
+ },
187
+ onToggleMinimap(visible) {
188
+ canvas.setMinimap(visible);
189
+ },
190
+ onLayoutChange(param, value) {
191
+ setLayoutParams({ [param]: value });
192
+ canvas.reheat();
193
+ },
194
+ onExport(format) {
195
+ const dataUrl = canvas.exportImage(format);
196
+ if (!dataUrl)
197
+ return;
198
+ const link = document.createElement("a");
199
+ link.download = `${activeOntology || "graph"}.${format}`;
200
+ link.href = dataUrl;
201
+ link.click();
202
+ },
203
+ onOpen() {
204
+ if (mobileQuery.matches)
205
+ infoPanel.hide();
206
+ },
207
+ });
208
+ // --- Top bar: flex container for all top controls ---
209
+ const topBar = document.createElement("div");
210
+ topBar.className = "canvas-top-bar";
211
+ const topLeft = document.createElement("div");
212
+ topLeft.className = "canvas-top-left";
213
+ const topCenter = document.createElement("div");
214
+ topCenter.className = "canvas-top-center";
215
+ const topRight = document.createElement("div");
216
+ topRight.className = "canvas-top-right";
217
+ // Move tools toggle into left slot
218
+ const toolsToggle = canvasContainer.querySelector(".tools-pane-toggle");
219
+ if (toolsToggle)
220
+ topLeft.appendChild(toolsToggle);
221
+ // Move search overlay into center slot
222
+ const searchOverlay = canvasContainer.querySelector(".search-overlay");
223
+ if (searchOverlay)
224
+ topCenter.appendChild(searchOverlay);
225
+ // Move zoom controls and theme toggle into right slot
226
+ const zoomControls = canvasContainer.querySelector(".zoom-controls");
227
+ if (zoomControls)
228
+ topRight.appendChild(zoomControls);
229
+ topRight.appendChild(themeBtn);
230
+ topBar.appendChild(topLeft);
231
+ topBar.appendChild(topCenter);
232
+ topBar.appendChild(topRight);
233
+ canvasContainer.appendChild(topBar);
105
234
  search.onFilterChange((ids) => {
106
235
  canvas.setFilteredNodeIds(ids);
107
236
  });
@@ -112,15 +241,7 @@ async function main() {
112
241
  }
113
242
  });
114
243
  const sidebar = initSidebar(document.getElementById("sidebar"), {
115
- onSelect: async (name) => {
116
- activeOntology = name;
117
- sidebar.setActive(name);
118
- infoPanel.hide();
119
- search.clear();
120
- currentData = await loadOntology(name);
121
- canvas.loadGraph(currentData);
122
- search.setLearningGraphData(currentData);
123
- },
244
+ onSelect: (name) => selectGraph(name),
124
245
  onRename: async (oldName, newName) => {
125
246
  await renameOntology(oldName, newName);
126
247
  if (activeOntology === oldName) {
@@ -133,21 +254,75 @@ async function main() {
133
254
  currentData = await loadOntology(newName);
134
255
  canvas.loadGraph(currentData);
135
256
  search.setLearningGraphData(currentData);
257
+ toolsPane.setData(currentData);
136
258
  }
137
259
  },
138
260
  });
261
+ const shortcuts = initShortcuts(canvasContainer);
262
+ const emptyState = initEmptyState(canvasContainer);
263
+ // --- URL deep linking ---
264
+ function updateUrl(name, nodeIds) {
265
+ const hash = "#" + encodeURIComponent(name) +
266
+ (nodeIds?.length ? "?node=" + nodeIds.map(encodeURIComponent).join(",") : "");
267
+ history.replaceState(null, "", hash);
268
+ }
269
+ function parseUrl() {
270
+ const hash = window.location.hash.slice(1);
271
+ if (!hash)
272
+ return { graph: null, nodes: [] };
273
+ const [graphPart, queryPart] = hash.split("?");
274
+ const graph = graphPart ? decodeURIComponent(graphPart) : null;
275
+ let nodes = [];
276
+ if (queryPart) {
277
+ const params = new URLSearchParams(queryPart);
278
+ const nodeParam = params.get("node");
279
+ if (nodeParam)
280
+ nodes = nodeParam.split(",").map(decodeURIComponent);
281
+ }
282
+ return { graph, nodes };
283
+ }
284
+ async function selectGraph(name, focusNodeIds) {
285
+ activeOntology = name;
286
+ sidebar.setActive(name);
287
+ infoPanel.hide();
288
+ search.clear();
289
+ undoHistory.clear();
290
+ currentData = await loadOntology(name);
291
+ canvas.loadGraph(currentData);
292
+ search.setLearningGraphData(currentData);
293
+ toolsPane.setData(currentData);
294
+ emptyState.hide();
295
+ updateUrl(name);
296
+ // Focus on specific nodes if requested
297
+ if (focusNodeIds?.length && currentData) {
298
+ const validIds = focusNodeIds.filter((id) => currentData.nodes.some((n) => n.id === id));
299
+ if (validIds.length) {
300
+ setTimeout(() => {
301
+ canvas.panToNodes(validIds);
302
+ if (currentData)
303
+ infoPanel.show(validIds, currentData);
304
+ updateUrl(name, validIds);
305
+ }, 500);
306
+ }
307
+ }
308
+ }
139
309
  // Load ontology list
140
310
  const summaries = await listOntologies();
141
311
  sidebar.setSummaries(summaries);
142
- // Auto-load first ontology
143
- if (summaries.length > 0) {
144
- activeOntology = summaries[0].name;
145
- sidebar.setActive(activeOntology);
146
- currentData = await loadOntology(activeOntology);
147
- canvas.loadGraph(currentData);
148
- search.setLearningGraphData(currentData);
312
+ // Auto-load from URL hash, or first graph
313
+ const initialUrl = parseUrl();
314
+ const initialName = initialUrl.graph && summaries.some((s) => s.name === initialUrl.graph)
315
+ ? initialUrl.graph
316
+ : summaries.length > 0
317
+ ? summaries[0].name
318
+ : null;
319
+ if (initialName) {
320
+ await selectGraph(initialName, initialUrl.nodes.length ? initialUrl.nodes : undefined);
321
+ }
322
+ else {
323
+ emptyState.show();
149
324
  }
150
- // Keyboard shortcut: / or Ctrl+K to focus search
325
+ // Keyboard shortcuts
151
326
  document.addEventListener("keydown", (e) => {
152
327
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
153
328
  return;
@@ -155,22 +330,69 @@ async function main() {
155
330
  e.preventDefault();
156
331
  search.focus();
157
332
  }
333
+ else if (e.key === "z" && (e.metaKey || e.ctrlKey) && e.shiftKey) {
334
+ e.preventDefault();
335
+ if (currentData) {
336
+ const restored = undoHistory.redo(currentData);
337
+ if (restored)
338
+ applyState(restored);
339
+ }
340
+ }
341
+ else if (e.key === "z" && (e.metaKey || e.ctrlKey)) {
342
+ e.preventDefault();
343
+ if (currentData) {
344
+ const restored = undoHistory.undo(currentData);
345
+ if (restored)
346
+ applyState(restored);
347
+ }
348
+ }
349
+ else if (e.key === "?") {
350
+ shortcuts.show();
351
+ }
352
+ else if (e.key === "Escape") {
353
+ shortcuts.hide();
354
+ }
355
+ });
356
+ // Handle browser back/forward
357
+ window.addEventListener("hashchange", () => {
358
+ const url = parseUrl();
359
+ if (url.graph && url.graph !== activeOntology) {
360
+ selectGraph(url.graph, url.nodes.length ? url.nodes : undefined);
361
+ }
362
+ else if (url.graph && url.nodes.length && currentData) {
363
+ const validIds = url.nodes.filter((id) => currentData.nodes.some((n) => n.id === id));
364
+ if (validIds.length) {
365
+ canvas.panToNodes(validIds);
366
+ infoPanel.show(validIds, currentData);
367
+ }
368
+ }
158
369
  });
159
370
  // Live reload — when Claude adds nodes via MCP, re-fetch and re-render
160
371
  if (import.meta.hot) {
161
372
  import.meta.hot.on("ontology-change", async () => {
162
373
  const updated = await listOntologies();
163
374
  sidebar.setSummaries(updated);
375
+ if (updated.length > 0)
376
+ emptyState.hide();
164
377
  if (activeOntology) {
165
378
  try {
166
379
  currentData = await loadOntology(activeOntology);
167
380
  canvas.loadGraph(currentData);
168
381
  search.setLearningGraphData(currentData);
382
+ toolsPane.setData(currentData);
169
383
  }
170
384
  catch {
171
385
  // Ontology may have been deleted
172
386
  }
173
387
  }
388
+ else if (updated.length > 0) {
389
+ activeOntology = updated[0].name;
390
+ sidebar.setActive(activeOntology);
391
+ currentData = await loadOntology(activeOntology);
392
+ canvas.loadGraph(currentData);
393
+ search.setLearningGraphData(currentData);
394
+ toolsPane.setData(currentData);
395
+ }
174
396
  });
175
397
  }
176
398
  }
@@ -0,0 +1,4 @@
1
+ export declare function initShortcuts(container: HTMLElement): {
2
+ show: () => void;
3
+ hide: () => void;
4
+ };
@@ -0,0 +1,66 @@
1
+ const SHORTCUTS = [
2
+ { key: "/", alt: "Ctrl+K", description: "Focus search" },
3
+ { key: "Ctrl+Z", description: "Undo" },
4
+ { key: "Ctrl+Shift+Z", description: "Redo" },
5
+ { key: "?", description: "Show this help" },
6
+ { key: "Esc", description: "Close panel / clear search" },
7
+ { key: "Click", description: "Select node" },
8
+ { key: "Ctrl+Click", description: "Multi-select nodes" },
9
+ { key: "Drag", description: "Pan canvas" },
10
+ { key: "Scroll", description: "Zoom in/out" },
11
+ ];
12
+ export function initShortcuts(container) {
13
+ const overlay = document.createElement("div");
14
+ overlay.className = "shortcuts-overlay hidden";
15
+ const modal = document.createElement("div");
16
+ modal.className = "shortcuts-modal";
17
+ const title = document.createElement("h3");
18
+ title.className = "shortcuts-title";
19
+ title.textContent = "Keyboard Shortcuts";
20
+ const list = document.createElement("div");
21
+ list.className = "shortcuts-list";
22
+ for (const s of SHORTCUTS) {
23
+ const row = document.createElement("div");
24
+ row.className = "shortcuts-row";
25
+ const keys = document.createElement("div");
26
+ keys.className = "shortcuts-keys";
27
+ const kbd = document.createElement("kbd");
28
+ kbd.textContent = s.key;
29
+ keys.appendChild(kbd);
30
+ if (s.alt) {
31
+ const or = document.createElement("span");
32
+ or.className = "shortcuts-or";
33
+ or.textContent = "or";
34
+ keys.appendChild(or);
35
+ const kbd2 = document.createElement("kbd");
36
+ kbd2.textContent = s.alt;
37
+ keys.appendChild(kbd2);
38
+ }
39
+ const desc = document.createElement("span");
40
+ desc.className = "shortcuts-desc";
41
+ desc.textContent = s.description;
42
+ row.appendChild(keys);
43
+ row.appendChild(desc);
44
+ list.appendChild(row);
45
+ }
46
+ const closeBtn = document.createElement("button");
47
+ closeBtn.className = "shortcuts-close";
48
+ closeBtn.textContent = "\u00d7";
49
+ modal.appendChild(closeBtn);
50
+ modal.appendChild(title);
51
+ modal.appendChild(list);
52
+ overlay.appendChild(modal);
53
+ container.appendChild(overlay);
54
+ function show() {
55
+ overlay.classList.remove("hidden");
56
+ }
57
+ function hide() {
58
+ overlay.classList.add("hidden");
59
+ }
60
+ closeBtn.addEventListener("click", hide);
61
+ overlay.addEventListener("click", (e) => {
62
+ if (e.target === overlay)
63
+ hide();
64
+ });
65
+ return { show, hide };
66
+ }