backpack-viewer 0.2.7 → 0.2.8
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/bin/serve.js +1 -1
- package/dist/api.d.ts +5 -0
- package/dist/api.js +30 -0
- package/dist/app/assets/index-CLyb9OCm.css +1 -0
- package/dist/app/assets/index-Ev20LdMk.js +1 -0
- package/dist/{index.html → app/index.html} +2 -2
- package/dist/canvas.d.ts +7 -0
- package/dist/canvas.js +442 -0
- package/dist/colors.d.ts +7 -0
- package/dist/colors.js +37 -0
- package/dist/info-panel.d.ts +13 -0
- package/dist/info-panel.js +579 -0
- package/dist/layout.d.ts +24 -0
- package/dist/layout.js +100 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +177 -0
- package/dist/search.d.ts +8 -0
- package/dist/search.js +229 -0
- package/dist/sidebar.d.ts +9 -0
- package/dist/sidebar.js +103 -0
- package/dist/style.css +914 -0
- package/package.json +3 -3
- package/dist/assets/index-Bm7EACmJ.css +0 -1
- package/dist/assets/index-CCGK0wTJ.js +0 -1
package/dist/layout.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const REPULSION = 5000;
|
|
2
|
+
const ATTRACTION = 0.005;
|
|
3
|
+
const REST_LENGTH = 150;
|
|
4
|
+
const DAMPING = 0.9;
|
|
5
|
+
const CENTER_GRAVITY = 0.01;
|
|
6
|
+
const MIN_DISTANCE = 30;
|
|
7
|
+
const MAX_VELOCITY = 50;
|
|
8
|
+
/** Extract a display label from a node — first string property value, fallback to id. */
|
|
9
|
+
function nodeLabel(properties, id) {
|
|
10
|
+
for (const value of Object.values(properties)) {
|
|
11
|
+
if (typeof value === "string")
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
return id;
|
|
15
|
+
}
|
|
16
|
+
/** Create a layout state from ontology data. Nodes start in a circle. */
|
|
17
|
+
export function createLayout(data) {
|
|
18
|
+
const radius = Math.sqrt(data.nodes.length) * REST_LENGTH * 0.5;
|
|
19
|
+
const nodeMap = new Map();
|
|
20
|
+
const nodes = data.nodes.map((n, i) => {
|
|
21
|
+
const angle = (2 * Math.PI * i) / data.nodes.length;
|
|
22
|
+
const node = {
|
|
23
|
+
id: n.id,
|
|
24
|
+
x: Math.cos(angle) * radius,
|
|
25
|
+
y: Math.sin(angle) * radius,
|
|
26
|
+
vx: 0,
|
|
27
|
+
vy: 0,
|
|
28
|
+
label: nodeLabel(n.properties, n.id),
|
|
29
|
+
type: n.type,
|
|
30
|
+
};
|
|
31
|
+
nodeMap.set(n.id, node);
|
|
32
|
+
return node;
|
|
33
|
+
});
|
|
34
|
+
const edges = data.edges.map((e) => ({
|
|
35
|
+
sourceId: e.sourceId,
|
|
36
|
+
targetId: e.targetId,
|
|
37
|
+
type: e.type,
|
|
38
|
+
}));
|
|
39
|
+
return { nodes, edges, nodeMap };
|
|
40
|
+
}
|
|
41
|
+
/** Run one tick of the force simulation. Returns new alpha. */
|
|
42
|
+
export function tick(state, alpha) {
|
|
43
|
+
const { nodes, edges, nodeMap } = state;
|
|
44
|
+
// Repulsion — all pairs
|
|
45
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
46
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
47
|
+
const a = nodes[i];
|
|
48
|
+
const b = nodes[j];
|
|
49
|
+
let dx = b.x - a.x;
|
|
50
|
+
let dy = b.y - a.y;
|
|
51
|
+
let dist = Math.sqrt(dx * dx + dy * dy);
|
|
52
|
+
if (dist < MIN_DISTANCE)
|
|
53
|
+
dist = MIN_DISTANCE;
|
|
54
|
+
const force = (REPULSION * alpha) / (dist * dist);
|
|
55
|
+
const fx = (dx / dist) * force;
|
|
56
|
+
const fy = (dy / dist) * force;
|
|
57
|
+
a.vx -= fx;
|
|
58
|
+
a.vy -= fy;
|
|
59
|
+
b.vx += fx;
|
|
60
|
+
b.vy += fy;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Attraction — along edges
|
|
64
|
+
for (const edge of edges) {
|
|
65
|
+
const source = nodeMap.get(edge.sourceId);
|
|
66
|
+
const target = nodeMap.get(edge.targetId);
|
|
67
|
+
if (!source || !target)
|
|
68
|
+
continue;
|
|
69
|
+
const dx = target.x - source.x;
|
|
70
|
+
const dy = target.y - source.y;
|
|
71
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
72
|
+
if (dist === 0)
|
|
73
|
+
continue;
|
|
74
|
+
const force = ATTRACTION * (dist - REST_LENGTH) * alpha;
|
|
75
|
+
const fx = (dx / dist) * force;
|
|
76
|
+
const fy = (dy / dist) * force;
|
|
77
|
+
source.vx += fx;
|
|
78
|
+
source.vy += fy;
|
|
79
|
+
target.vx -= fx;
|
|
80
|
+
target.vy -= fy;
|
|
81
|
+
}
|
|
82
|
+
// Centering gravity
|
|
83
|
+
for (const node of nodes) {
|
|
84
|
+
node.vx -= node.x * CENTER_GRAVITY * alpha;
|
|
85
|
+
node.vy -= node.y * CENTER_GRAVITY * alpha;
|
|
86
|
+
}
|
|
87
|
+
// Integrate — update positions, apply damping, clamp velocity
|
|
88
|
+
for (const node of nodes) {
|
|
89
|
+
node.vx *= DAMPING;
|
|
90
|
+
node.vy *= DAMPING;
|
|
91
|
+
const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy);
|
|
92
|
+
if (speed > MAX_VELOCITY) {
|
|
93
|
+
node.vx = (node.vx / speed) * MAX_VELOCITY;
|
|
94
|
+
node.vy = (node.vy / speed) * MAX_VELOCITY;
|
|
95
|
+
}
|
|
96
|
+
node.x += node.vx;
|
|
97
|
+
node.y += node.vy;
|
|
98
|
+
}
|
|
99
|
+
return alpha * 0.995;
|
|
100
|
+
}
|
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "./style.css";
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { listOntologies, loadOntology, saveOntology, renameOntology } from "./api";
|
|
2
|
+
import { initSidebar } from "./sidebar";
|
|
3
|
+
import { initCanvas } from "./canvas";
|
|
4
|
+
import { initInfoPanel } from "./info-panel";
|
|
5
|
+
import { initSearch } from "./search";
|
|
6
|
+
import "./style.css";
|
|
7
|
+
let activeOntology = "";
|
|
8
|
+
let currentData = null;
|
|
9
|
+
async function main() {
|
|
10
|
+
const canvasContainer = document.getElementById("canvas-container");
|
|
11
|
+
// --- Theme toggle (top-right of canvas) ---
|
|
12
|
+
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
|
|
13
|
+
const stored = localStorage.getItem("backpack-theme");
|
|
14
|
+
const initial = stored ?? (prefersDark.matches ? "dark" : "light");
|
|
15
|
+
document.documentElement.setAttribute("data-theme", initial);
|
|
16
|
+
const themeBtn = document.createElement("button");
|
|
17
|
+
themeBtn.className = "theme-toggle";
|
|
18
|
+
themeBtn.textContent = initial === "light" ? "\u263E" : "\u263C";
|
|
19
|
+
themeBtn.title = "Toggle light/dark mode";
|
|
20
|
+
themeBtn.addEventListener("click", () => {
|
|
21
|
+
const current = document.documentElement.getAttribute("data-theme");
|
|
22
|
+
const next = current === "light" ? "dark" : "light";
|
|
23
|
+
document.documentElement.setAttribute("data-theme", next);
|
|
24
|
+
localStorage.setItem("backpack-theme", next);
|
|
25
|
+
themeBtn.textContent = next === "light" ? "\u263E" : "\u263C";
|
|
26
|
+
});
|
|
27
|
+
canvasContainer.appendChild(themeBtn);
|
|
28
|
+
// --- Save and re-render helper ---
|
|
29
|
+
async function save() {
|
|
30
|
+
if (!activeOntology || !currentData)
|
|
31
|
+
return;
|
|
32
|
+
currentData.metadata.updatedAt = new Date().toISOString();
|
|
33
|
+
await saveOntology(activeOntology, currentData);
|
|
34
|
+
canvas.loadGraph(currentData);
|
|
35
|
+
search.setOntologyData(currentData);
|
|
36
|
+
// Refresh sidebar counts
|
|
37
|
+
const updated = await listOntologies();
|
|
38
|
+
sidebar.setSummaries(updated);
|
|
39
|
+
}
|
|
40
|
+
// --- Info panel with edit callbacks ---
|
|
41
|
+
// canvas is used inside the navigate callback but declared below —
|
|
42
|
+
// that's fine because the callback is only invoked after setup completes.
|
|
43
|
+
let canvas;
|
|
44
|
+
const infoPanel = initInfoPanel(canvasContainer, {
|
|
45
|
+
onUpdateNode(nodeId, properties) {
|
|
46
|
+
if (!currentData)
|
|
47
|
+
return;
|
|
48
|
+
const node = currentData.nodes.find((n) => n.id === nodeId);
|
|
49
|
+
if (!node)
|
|
50
|
+
return;
|
|
51
|
+
node.properties = { ...node.properties, ...properties };
|
|
52
|
+
node.updatedAt = new Date().toISOString();
|
|
53
|
+
save().then(() => infoPanel.show([nodeId], currentData));
|
|
54
|
+
},
|
|
55
|
+
onChangeNodeType(nodeId, newType) {
|
|
56
|
+
if (!currentData)
|
|
57
|
+
return;
|
|
58
|
+
const node = currentData.nodes.find((n) => n.id === nodeId);
|
|
59
|
+
if (!node)
|
|
60
|
+
return;
|
|
61
|
+
node.type = newType;
|
|
62
|
+
node.updatedAt = new Date().toISOString();
|
|
63
|
+
save().then(() => infoPanel.show([nodeId], currentData));
|
|
64
|
+
},
|
|
65
|
+
onDeleteNode(nodeId) {
|
|
66
|
+
if (!currentData)
|
|
67
|
+
return;
|
|
68
|
+
currentData.nodes = currentData.nodes.filter((n) => n.id !== nodeId);
|
|
69
|
+
currentData.edges = currentData.edges.filter((e) => e.sourceId !== nodeId && e.targetId !== nodeId);
|
|
70
|
+
save();
|
|
71
|
+
},
|
|
72
|
+
onDeleteEdge(edgeId) {
|
|
73
|
+
if (!currentData)
|
|
74
|
+
return;
|
|
75
|
+
const selectedNodeId = currentData.edges.find((e) => e.id === edgeId)?.sourceId;
|
|
76
|
+
currentData.edges = currentData.edges.filter((e) => e.id !== edgeId);
|
|
77
|
+
save().then(() => {
|
|
78
|
+
if (selectedNodeId && currentData) {
|
|
79
|
+
infoPanel.show([selectedNodeId], currentData);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
onAddProperty(nodeId, key, value) {
|
|
84
|
+
if (!currentData)
|
|
85
|
+
return;
|
|
86
|
+
const node = currentData.nodes.find((n) => n.id === nodeId);
|
|
87
|
+
if (!node)
|
|
88
|
+
return;
|
|
89
|
+
node.properties[key] = value;
|
|
90
|
+
node.updatedAt = new Date().toISOString();
|
|
91
|
+
save().then(() => infoPanel.show([nodeId], currentData));
|
|
92
|
+
},
|
|
93
|
+
}, (nodeId) => {
|
|
94
|
+
canvas.panToNode(nodeId);
|
|
95
|
+
});
|
|
96
|
+
canvas = initCanvas(canvasContainer, (nodeIds) => {
|
|
97
|
+
if (nodeIds && nodeIds.length > 0 && currentData) {
|
|
98
|
+
infoPanel.show(nodeIds, currentData);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
infoPanel.hide();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
const search = initSearch(canvasContainer);
|
|
105
|
+
search.onFilterChange((ids) => {
|
|
106
|
+
canvas.setFilteredNodeIds(ids);
|
|
107
|
+
});
|
|
108
|
+
search.onNodeSelect((nodeId) => {
|
|
109
|
+
canvas.panToNode(nodeId);
|
|
110
|
+
if (currentData) {
|
|
111
|
+
infoPanel.show([nodeId], currentData);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
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.setOntologyData(currentData);
|
|
123
|
+
},
|
|
124
|
+
onRename: async (oldName, newName) => {
|
|
125
|
+
await renameOntology(oldName, newName);
|
|
126
|
+
if (activeOntology === oldName) {
|
|
127
|
+
activeOntology = newName;
|
|
128
|
+
}
|
|
129
|
+
const updated = await listOntologies();
|
|
130
|
+
sidebar.setSummaries(updated);
|
|
131
|
+
sidebar.setActive(activeOntology);
|
|
132
|
+
if (activeOntology === newName) {
|
|
133
|
+
currentData = await loadOntology(newName);
|
|
134
|
+
canvas.loadGraph(currentData);
|
|
135
|
+
search.setOntologyData(currentData);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
// Load ontology list
|
|
140
|
+
const summaries = await listOntologies();
|
|
141
|
+
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.setOntologyData(currentData);
|
|
149
|
+
}
|
|
150
|
+
// Keyboard shortcut: / or Ctrl+K to focus search
|
|
151
|
+
document.addEventListener("keydown", (e) => {
|
|
152
|
+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
|
153
|
+
return;
|
|
154
|
+
if (e.key === "/" || (e.key === "k" && (e.metaKey || e.ctrlKey))) {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
search.focus();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
// Live reload — when Claude adds nodes via MCP, re-fetch and re-render
|
|
160
|
+
if (import.meta.hot) {
|
|
161
|
+
import.meta.hot.on("ontology-change", async () => {
|
|
162
|
+
const updated = await listOntologies();
|
|
163
|
+
sidebar.setSummaries(updated);
|
|
164
|
+
if (activeOntology) {
|
|
165
|
+
try {
|
|
166
|
+
currentData = await loadOntology(activeOntology);
|
|
167
|
+
canvas.loadGraph(currentData);
|
|
168
|
+
search.setOntologyData(currentData);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Ontology may have been deleted
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
main();
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { OntologyData } from "backpack-ontology";
|
|
2
|
+
export declare function initSearch(container: HTMLElement): {
|
|
3
|
+
setOntologyData(newData: OntologyData | null): void;
|
|
4
|
+
onFilterChange(cb: (ids: Set<string> | null) => void): void;
|
|
5
|
+
onNodeSelect(cb: (nodeId: string) => void): void;
|
|
6
|
+
clear(): void;
|
|
7
|
+
focus(): void;
|
|
8
|
+
};
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { getColor } from "./colors";
|
|
2
|
+
/** Extract a display label from a node — first string property value, fallback to id. */
|
|
3
|
+
function nodeLabel(node) {
|
|
4
|
+
for (const value of Object.values(node.properties)) {
|
|
5
|
+
if (typeof value === "string")
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
return node.id;
|
|
9
|
+
}
|
|
10
|
+
/** Check if a node matches a search query (case-insensitive across label + all string properties). */
|
|
11
|
+
function matchesQuery(node, query) {
|
|
12
|
+
const q = query.toLowerCase();
|
|
13
|
+
// Check label
|
|
14
|
+
if (nodeLabel(node).toLowerCase().includes(q))
|
|
15
|
+
return true;
|
|
16
|
+
// Check type
|
|
17
|
+
if (node.type.toLowerCase().includes(q))
|
|
18
|
+
return true;
|
|
19
|
+
// Check all string property values
|
|
20
|
+
for (const value of Object.values(node.properties)) {
|
|
21
|
+
if (typeof value === "string" && value.toLowerCase().includes(q))
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
export function initSearch(container) {
|
|
27
|
+
let data = null;
|
|
28
|
+
let filterCallback = null;
|
|
29
|
+
let selectCallback = null;
|
|
30
|
+
let activeTypes = new Set();
|
|
31
|
+
let debounceTimer = null;
|
|
32
|
+
// --- DOM ---
|
|
33
|
+
const overlay = document.createElement("div");
|
|
34
|
+
overlay.className = "search-overlay hidden";
|
|
35
|
+
const inputWrap = document.createElement("div");
|
|
36
|
+
inputWrap.className = "search-input-wrap";
|
|
37
|
+
const input = document.createElement("input");
|
|
38
|
+
input.className = "search-input";
|
|
39
|
+
input.type = "text";
|
|
40
|
+
input.placeholder = "Search nodes...";
|
|
41
|
+
input.setAttribute("autocomplete", "off");
|
|
42
|
+
input.setAttribute("spellcheck", "false");
|
|
43
|
+
const kbd = document.createElement("kbd");
|
|
44
|
+
kbd.className = "search-kbd";
|
|
45
|
+
kbd.textContent = "/";
|
|
46
|
+
inputWrap.appendChild(input);
|
|
47
|
+
inputWrap.appendChild(kbd);
|
|
48
|
+
const results = document.createElement("ul");
|
|
49
|
+
results.className = "search-results hidden";
|
|
50
|
+
const chips = document.createElement("div");
|
|
51
|
+
chips.className = "type-chips";
|
|
52
|
+
overlay.appendChild(inputWrap);
|
|
53
|
+
overlay.appendChild(results);
|
|
54
|
+
overlay.appendChild(chips);
|
|
55
|
+
container.appendChild(overlay);
|
|
56
|
+
// --- Type chips ---
|
|
57
|
+
function buildChips() {
|
|
58
|
+
chips.innerHTML = "";
|
|
59
|
+
if (!data)
|
|
60
|
+
return;
|
|
61
|
+
// Count nodes per type
|
|
62
|
+
const typeCounts = new Map();
|
|
63
|
+
for (const node of data.nodes) {
|
|
64
|
+
typeCounts.set(node.type, (typeCounts.get(node.type) ?? 0) + 1);
|
|
65
|
+
}
|
|
66
|
+
// Sort alphabetically
|
|
67
|
+
const types = [...typeCounts.keys()].sort();
|
|
68
|
+
activeTypes = new Set(); // None selected = show all
|
|
69
|
+
for (const type of types) {
|
|
70
|
+
const chip = document.createElement("button");
|
|
71
|
+
chip.className = "type-chip";
|
|
72
|
+
chip.dataset.type = type;
|
|
73
|
+
const dot = document.createElement("span");
|
|
74
|
+
dot.className = "type-chip-dot";
|
|
75
|
+
dot.style.backgroundColor = getColor(type);
|
|
76
|
+
const label = document.createElement("span");
|
|
77
|
+
label.textContent = `${type} (${typeCounts.get(type)})`;
|
|
78
|
+
chip.appendChild(dot);
|
|
79
|
+
chip.appendChild(label);
|
|
80
|
+
chip.addEventListener("click", () => {
|
|
81
|
+
if (activeTypes.has(type)) {
|
|
82
|
+
activeTypes.delete(type);
|
|
83
|
+
chip.classList.remove("active");
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
activeTypes.add(type);
|
|
87
|
+
chip.classList.add("active");
|
|
88
|
+
}
|
|
89
|
+
applyFilter();
|
|
90
|
+
});
|
|
91
|
+
chips.appendChild(chip);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// --- Search + filter logic ---
|
|
95
|
+
function getMatchingIds() {
|
|
96
|
+
if (!data)
|
|
97
|
+
return null;
|
|
98
|
+
const query = input.value.trim();
|
|
99
|
+
const noChipsSelected = activeTypes.size === 0;
|
|
100
|
+
const noQuery = query.length === 0;
|
|
101
|
+
// No filter active — return null (show all)
|
|
102
|
+
if (noQuery && noChipsSelected)
|
|
103
|
+
return null;
|
|
104
|
+
const ids = new Set();
|
|
105
|
+
for (const node of data.nodes) {
|
|
106
|
+
// If chips are selected, only include those types
|
|
107
|
+
if (!noChipsSelected && !activeTypes.has(node.type))
|
|
108
|
+
continue;
|
|
109
|
+
if (noQuery || matchesQuery(node, query)) {
|
|
110
|
+
ids.add(node.id);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return ids;
|
|
114
|
+
}
|
|
115
|
+
function applyFilter() {
|
|
116
|
+
const ids = getMatchingIds();
|
|
117
|
+
filterCallback?.(ids);
|
|
118
|
+
updateResults();
|
|
119
|
+
}
|
|
120
|
+
function updateResults() {
|
|
121
|
+
results.innerHTML = "";
|
|
122
|
+
const query = input.value.trim();
|
|
123
|
+
if (!data || query.length === 0) {
|
|
124
|
+
results.classList.add("hidden");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const noChipsSelected = activeTypes.size === 0;
|
|
128
|
+
const matches = [];
|
|
129
|
+
for (const node of data.nodes) {
|
|
130
|
+
if (!noChipsSelected && !activeTypes.has(node.type))
|
|
131
|
+
continue;
|
|
132
|
+
if (matchesQuery(node, query)) {
|
|
133
|
+
matches.push(node);
|
|
134
|
+
if (matches.length >= 8)
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (matches.length === 0) {
|
|
139
|
+
results.classList.add("hidden");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
for (const node of matches) {
|
|
143
|
+
const li = document.createElement("li");
|
|
144
|
+
li.className = "search-result-item";
|
|
145
|
+
const dot = document.createElement("span");
|
|
146
|
+
dot.className = "search-result-dot";
|
|
147
|
+
dot.style.backgroundColor = getColor(node.type);
|
|
148
|
+
const label = document.createElement("span");
|
|
149
|
+
label.className = "search-result-label";
|
|
150
|
+
const text = nodeLabel(node);
|
|
151
|
+
label.textContent = text.length > 36 ? text.slice(0, 34) + "..." : text;
|
|
152
|
+
const type = document.createElement("span");
|
|
153
|
+
type.className = "search-result-type";
|
|
154
|
+
type.textContent = node.type;
|
|
155
|
+
li.appendChild(dot);
|
|
156
|
+
li.appendChild(label);
|
|
157
|
+
li.appendChild(type);
|
|
158
|
+
li.addEventListener("click", () => {
|
|
159
|
+
selectCallback?.(node.id);
|
|
160
|
+
input.value = "";
|
|
161
|
+
results.classList.add("hidden");
|
|
162
|
+
applyFilter();
|
|
163
|
+
});
|
|
164
|
+
results.appendChild(li);
|
|
165
|
+
}
|
|
166
|
+
results.classList.remove("hidden");
|
|
167
|
+
}
|
|
168
|
+
// --- Input events ---
|
|
169
|
+
input.addEventListener("input", () => {
|
|
170
|
+
if (debounceTimer)
|
|
171
|
+
clearTimeout(debounceTimer);
|
|
172
|
+
debounceTimer = setTimeout(applyFilter, 150);
|
|
173
|
+
});
|
|
174
|
+
input.addEventListener("keydown", (e) => {
|
|
175
|
+
if (e.key === "Escape") {
|
|
176
|
+
input.value = "";
|
|
177
|
+
input.blur();
|
|
178
|
+
results.classList.add("hidden");
|
|
179
|
+
applyFilter();
|
|
180
|
+
}
|
|
181
|
+
else if (e.key === "Enter") {
|
|
182
|
+
// Select first result
|
|
183
|
+
const first = results.querySelector(".search-result-item");
|
|
184
|
+
first?.click();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
// Close results when clicking outside
|
|
188
|
+
document.addEventListener("click", (e) => {
|
|
189
|
+
if (!overlay.contains(e.target)) {
|
|
190
|
+
results.classList.add("hidden");
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
// Hide kbd hint when focused
|
|
194
|
+
input.addEventListener("focus", () => kbd.classList.add("hidden"));
|
|
195
|
+
input.addEventListener("blur", () => {
|
|
196
|
+
if (input.value.length === 0)
|
|
197
|
+
kbd.classList.remove("hidden");
|
|
198
|
+
});
|
|
199
|
+
// --- Public API ---
|
|
200
|
+
return {
|
|
201
|
+
setOntologyData(newData) {
|
|
202
|
+
data = newData;
|
|
203
|
+
input.value = "";
|
|
204
|
+
results.classList.add("hidden");
|
|
205
|
+
if (data && data.nodes.length > 0) {
|
|
206
|
+
overlay.classList.remove("hidden");
|
|
207
|
+
buildChips();
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
overlay.classList.add("hidden");
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
onFilterChange(cb) {
|
|
214
|
+
filterCallback = cb;
|
|
215
|
+
},
|
|
216
|
+
onNodeSelect(cb) {
|
|
217
|
+
selectCallback = cb;
|
|
218
|
+
},
|
|
219
|
+
clear() {
|
|
220
|
+
input.value = "";
|
|
221
|
+
results.classList.add("hidden");
|
|
222
|
+
activeTypes.clear();
|
|
223
|
+
filterCallback?.(null);
|
|
224
|
+
},
|
|
225
|
+
focus() {
|
|
226
|
+
input.focus();
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { OntologySummary } from "backpack-ontology";
|
|
2
|
+
export interface SidebarCallbacks {
|
|
3
|
+
onSelect: (name: string) => void;
|
|
4
|
+
onRename?: (oldName: string, newName: string) => void;
|
|
5
|
+
}
|
|
6
|
+
export declare function initSidebar(container: HTMLElement, onSelectOrCallbacks: ((name: string) => void) | SidebarCallbacks): {
|
|
7
|
+
setSummaries(summaries: OntologySummary[]): void;
|
|
8
|
+
setActive(name: string): void;
|
|
9
|
+
};
|
package/dist/sidebar.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export function initSidebar(container, onSelectOrCallbacks) {
|
|
2
|
+
const cbs = typeof onSelectOrCallbacks === "function"
|
|
3
|
+
? { onSelect: onSelectOrCallbacks }
|
|
4
|
+
: onSelectOrCallbacks;
|
|
5
|
+
// Build DOM
|
|
6
|
+
const heading = document.createElement("h2");
|
|
7
|
+
heading.textContent = "Backpack Ontology Viewer";
|
|
8
|
+
const input = document.createElement("input");
|
|
9
|
+
input.type = "text";
|
|
10
|
+
input.placeholder = "Filter...";
|
|
11
|
+
input.id = "filter";
|
|
12
|
+
const list = document.createElement("ul");
|
|
13
|
+
list.id = "ontology-list";
|
|
14
|
+
const footer = document.createElement("div");
|
|
15
|
+
footer.className = "sidebar-footer";
|
|
16
|
+
footer.innerHTML =
|
|
17
|
+
'<a href="mailto:support@backpackontology.com">support@backpackontology.com</a>' +
|
|
18
|
+
"<span>Feedback & support</span>";
|
|
19
|
+
container.appendChild(heading);
|
|
20
|
+
container.appendChild(input);
|
|
21
|
+
container.appendChild(list);
|
|
22
|
+
container.appendChild(footer);
|
|
23
|
+
let items = [];
|
|
24
|
+
let activeName = "";
|
|
25
|
+
// Filter
|
|
26
|
+
input.addEventListener("input", () => {
|
|
27
|
+
const query = input.value.toLowerCase();
|
|
28
|
+
for (const item of items) {
|
|
29
|
+
const name = item.dataset.name ?? "";
|
|
30
|
+
item.style.display = name.includes(query) ? "" : "none";
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
setSummaries(summaries) {
|
|
35
|
+
list.innerHTML = "";
|
|
36
|
+
items = summaries.map((s) => {
|
|
37
|
+
const li = document.createElement("li");
|
|
38
|
+
li.className = "ontology-item";
|
|
39
|
+
li.dataset.name = s.name;
|
|
40
|
+
const nameSpan = document.createElement("span");
|
|
41
|
+
nameSpan.className = "name";
|
|
42
|
+
nameSpan.textContent = s.name;
|
|
43
|
+
const statsSpan = document.createElement("span");
|
|
44
|
+
statsSpan.className = "stats";
|
|
45
|
+
statsSpan.textContent = `${s.nodeCount} nodes, ${s.edgeCount} edges`;
|
|
46
|
+
li.appendChild(nameSpan);
|
|
47
|
+
li.appendChild(statsSpan);
|
|
48
|
+
if (cbs.onRename) {
|
|
49
|
+
const editBtn = document.createElement("button");
|
|
50
|
+
editBtn.className = "sidebar-edit-btn";
|
|
51
|
+
editBtn.textContent = "\u270E";
|
|
52
|
+
editBtn.title = "Rename";
|
|
53
|
+
const renameCb = cbs.onRename;
|
|
54
|
+
editBtn.addEventListener("click", (e) => {
|
|
55
|
+
e.stopPropagation();
|
|
56
|
+
const input = document.createElement("input");
|
|
57
|
+
input.type = "text";
|
|
58
|
+
input.className = "sidebar-rename-input";
|
|
59
|
+
input.value = s.name;
|
|
60
|
+
nameSpan.textContent = "";
|
|
61
|
+
nameSpan.appendChild(input);
|
|
62
|
+
editBtn.style.display = "none";
|
|
63
|
+
input.focus();
|
|
64
|
+
input.select();
|
|
65
|
+
const finish = () => {
|
|
66
|
+
const val = input.value.trim();
|
|
67
|
+
if (val && val !== s.name) {
|
|
68
|
+
renameCb(s.name, val);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
nameSpan.textContent = s.name;
|
|
72
|
+
editBtn.style.display = "";
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
input.addEventListener("blur", finish);
|
|
76
|
+
input.addEventListener("keydown", (ke) => {
|
|
77
|
+
if (ke.key === "Enter")
|
|
78
|
+
input.blur();
|
|
79
|
+
if (ke.key === "Escape") {
|
|
80
|
+
input.value = s.name;
|
|
81
|
+
input.blur();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
li.appendChild(editBtn);
|
|
86
|
+
}
|
|
87
|
+
li.addEventListener("click", () => cbs.onSelect(s.name));
|
|
88
|
+
list.appendChild(li);
|
|
89
|
+
return li;
|
|
90
|
+
});
|
|
91
|
+
// Re-apply active state
|
|
92
|
+
if (activeName) {
|
|
93
|
+
this.setActive(activeName);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
setActive(name) {
|
|
97
|
+
activeName = name;
|
|
98
|
+
for (const item of items) {
|
|
99
|
+
item.classList.toggle("active", item.dataset.name === name);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|