backpack-viewer 0.1.3

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.
@@ -0,0 +1,230 @@
1
+ import type { Node, Edge, OntologyData } from "backpack-ontology";
2
+ import { getColor } from "./colors";
3
+
4
+ /** Extract a display label from a node — first string property, fallback to id. */
5
+ function nodeLabel(node: Node): string {
6
+ for (const value of Object.values(node.properties)) {
7
+ if (typeof value === "string") return value;
8
+ }
9
+ return node.id;
10
+ }
11
+
12
+ export function initInfoPanel(container: HTMLElement) {
13
+ const panel = document.createElement("div");
14
+ panel.id = "info-panel";
15
+ panel.className = "info-panel hidden";
16
+ container.appendChild(panel);
17
+
18
+ return {
19
+ show(nodeId: string, data: OntologyData) {
20
+ const node = data.nodes.find((n) => n.id === nodeId);
21
+ if (!node) return;
22
+
23
+ const connectedEdges = data.edges.filter(
24
+ (e) => e.sourceId === nodeId || e.targetId === nodeId
25
+ );
26
+
27
+ panel.innerHTML = "";
28
+ panel.classList.remove("hidden");
29
+
30
+ // Close button
31
+ const closeBtn = document.createElement("button");
32
+ closeBtn.className = "info-close";
33
+ closeBtn.textContent = "\u00d7";
34
+ closeBtn.addEventListener("click", () => this.hide());
35
+ panel.appendChild(closeBtn);
36
+
37
+ // Header: type badge + label
38
+ const header = document.createElement("div");
39
+ header.className = "info-header";
40
+
41
+ const typeBadge = document.createElement("span");
42
+ typeBadge.className = "info-type-badge";
43
+ typeBadge.textContent = node.type;
44
+ typeBadge.style.backgroundColor = getColor(node.type);
45
+
46
+ const label = document.createElement("h3");
47
+ label.className = "info-label";
48
+ label.textContent = nodeLabel(node);
49
+
50
+ const nodeIdEl = document.createElement("span");
51
+ nodeIdEl.className = "info-id";
52
+ nodeIdEl.textContent = node.id;
53
+
54
+ header.appendChild(typeBadge);
55
+ header.appendChild(label);
56
+ header.appendChild(nodeIdEl);
57
+ panel.appendChild(header);
58
+
59
+ // Properties section
60
+ const propKeys = Object.keys(node.properties);
61
+ if (propKeys.length > 0) {
62
+ const section = createSection("Properties");
63
+ const table = document.createElement("dl");
64
+ table.className = "info-props";
65
+
66
+ for (const key of propKeys) {
67
+ const dt = document.createElement("dt");
68
+ dt.textContent = key;
69
+
70
+ const dd = document.createElement("dd");
71
+ dd.appendChild(renderValue(node.properties[key]));
72
+
73
+ table.appendChild(dt);
74
+ table.appendChild(dd);
75
+ }
76
+
77
+ section.appendChild(table);
78
+ panel.appendChild(section);
79
+ }
80
+
81
+ // Connections section
82
+ if (connectedEdges.length > 0) {
83
+ const section = createSection(
84
+ `Connections (${connectedEdges.length})`
85
+ );
86
+ const list = document.createElement("ul");
87
+ list.className = "info-connections";
88
+
89
+ for (const edge of connectedEdges) {
90
+ const isOutgoing = edge.sourceId === nodeId;
91
+ const otherId = isOutgoing ? edge.targetId : edge.sourceId;
92
+ const otherNode = data.nodes.find((n) => n.id === otherId);
93
+ const otherLabel = otherNode ? nodeLabel(otherNode) : otherId;
94
+
95
+ const li = document.createElement("li");
96
+ li.className = "info-connection";
97
+
98
+ const arrow = document.createElement("span");
99
+ arrow.className = "info-arrow";
100
+ arrow.textContent = isOutgoing ? "\u2192" : "\u2190";
101
+
102
+ const edgeType = document.createElement("span");
103
+ edgeType.className = "info-edge-type";
104
+ edgeType.textContent = edge.type;
105
+
106
+ const target = document.createElement("span");
107
+ target.className = "info-target";
108
+ target.textContent = otherLabel;
109
+
110
+ if (otherNode) {
111
+ const dot = document.createElement("span");
112
+ dot.className = "info-target-dot";
113
+ dot.style.backgroundColor = getColor(otherNode.type);
114
+ li.appendChild(dot);
115
+ }
116
+
117
+ li.appendChild(arrow);
118
+ li.appendChild(edgeType);
119
+ li.appendChild(target);
120
+
121
+ // Edge properties (if any)
122
+ const edgePropKeys = Object.keys(edge.properties);
123
+ if (edgePropKeys.length > 0) {
124
+ const edgeProps = document.createElement("div");
125
+ edgeProps.className = "info-edge-props";
126
+ for (const key of edgePropKeys) {
127
+ const prop = document.createElement("span");
128
+ prop.className = "info-edge-prop";
129
+ prop.textContent = `${key}: ${formatValue(edge.properties[key])}`;
130
+ edgeProps.appendChild(prop);
131
+ }
132
+ li.appendChild(edgeProps);
133
+ }
134
+
135
+ list.appendChild(li);
136
+ }
137
+
138
+ section.appendChild(list);
139
+ panel.appendChild(section);
140
+ }
141
+
142
+ // Timestamps
143
+ const section = createSection("Timestamps");
144
+ const timestamps = document.createElement("dl");
145
+ timestamps.className = "info-props";
146
+
147
+ const createdDt = document.createElement("dt");
148
+ createdDt.textContent = "created";
149
+ const createdDd = document.createElement("dd");
150
+ createdDd.textContent = formatTimestamp(node.createdAt);
151
+
152
+ const updatedDt = document.createElement("dt");
153
+ updatedDt.textContent = "updated";
154
+ const updatedDd = document.createElement("dd");
155
+ updatedDd.textContent = formatTimestamp(node.updatedAt);
156
+
157
+ timestamps.appendChild(createdDt);
158
+ timestamps.appendChild(createdDd);
159
+ timestamps.appendChild(updatedDt);
160
+ timestamps.appendChild(updatedDd);
161
+ section.appendChild(timestamps);
162
+ panel.appendChild(section);
163
+ },
164
+
165
+ hide() {
166
+ panel.classList.add("hidden");
167
+ panel.innerHTML = "";
168
+ },
169
+
170
+ get visible() {
171
+ return !panel.classList.contains("hidden");
172
+ },
173
+ };
174
+ }
175
+
176
+ function createSection(title: string): HTMLElement {
177
+ const section = document.createElement("div");
178
+ section.className = "info-section";
179
+
180
+ const heading = document.createElement("h4");
181
+ heading.className = "info-section-title";
182
+ heading.textContent = title;
183
+ section.appendChild(heading);
184
+
185
+ return section;
186
+ }
187
+
188
+ /** Render any property value into a DOM element. Handles strings, arrays, numbers, objects. */
189
+ function renderValue(value: unknown): HTMLElement {
190
+ if (Array.isArray(value)) {
191
+ const container = document.createElement("div");
192
+ container.className = "info-array";
193
+ for (const item of value) {
194
+ const tag = document.createElement("span");
195
+ tag.className = "info-tag";
196
+ tag.textContent = String(item);
197
+ container.appendChild(tag);
198
+ }
199
+ return container;
200
+ }
201
+
202
+ if (value !== null && typeof value === "object") {
203
+ const pre = document.createElement("pre");
204
+ pre.className = "info-json";
205
+ pre.textContent = JSON.stringify(value, null, 2);
206
+ return pre;
207
+ }
208
+
209
+ const span = document.createElement("span");
210
+ span.className = "info-value";
211
+ span.textContent = String(value ?? "");
212
+ return span;
213
+ }
214
+
215
+ /** Format a value for inline display. */
216
+ function formatValue(value: unknown): string {
217
+ if (Array.isArray(value)) return value.map(String).join(", ");
218
+ if (value !== null && typeof value === "object")
219
+ return JSON.stringify(value);
220
+ return String(value ?? "");
221
+ }
222
+
223
+ function formatTimestamp(iso: string): string {
224
+ try {
225
+ const d = new Date(iso);
226
+ return d.toLocaleString();
227
+ } catch {
228
+ return iso;
229
+ }
230
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,138 @@
1
+ import type { OntologyData } from "backpack-ontology";
2
+
3
+ export interface LayoutNode {
4
+ id: string;
5
+ x: number;
6
+ y: number;
7
+ vx: number;
8
+ vy: number;
9
+ label: string;
10
+ type: string;
11
+ }
12
+
13
+ export interface LayoutEdge {
14
+ sourceId: string;
15
+ targetId: string;
16
+ type: string;
17
+ }
18
+
19
+ export interface LayoutState {
20
+ nodes: LayoutNode[];
21
+ edges: LayoutEdge[];
22
+ nodeMap: Map<string, LayoutNode>;
23
+ }
24
+
25
+ const REPULSION = 5000;
26
+ const ATTRACTION = 0.005;
27
+ const REST_LENGTH = 150;
28
+ const DAMPING = 0.9;
29
+ const CENTER_GRAVITY = 0.01;
30
+ const MIN_DISTANCE = 30;
31
+ const MAX_VELOCITY = 50;
32
+
33
+ /** Extract a display label from a node — first string property value, fallback to id. */
34
+ function nodeLabel(properties: Record<string, unknown>, id: string): string {
35
+ for (const value of Object.values(properties)) {
36
+ if (typeof value === "string") return value;
37
+ }
38
+ return id;
39
+ }
40
+
41
+ /** Create a layout state from ontology data. Nodes start in a circle. */
42
+ export function createLayout(data: OntologyData): LayoutState {
43
+ const radius = Math.sqrt(data.nodes.length) * REST_LENGTH * 0.5;
44
+ const nodeMap = new Map<string, LayoutNode>();
45
+
46
+ const nodes: LayoutNode[] = data.nodes.map((n, i) => {
47
+ const angle = (2 * Math.PI * i) / data.nodes.length;
48
+ const node: LayoutNode = {
49
+ id: n.id,
50
+ x: Math.cos(angle) * radius,
51
+ y: Math.sin(angle) * radius,
52
+ vx: 0,
53
+ vy: 0,
54
+ label: nodeLabel(n.properties, n.id),
55
+ type: n.type,
56
+ };
57
+ nodeMap.set(n.id, node);
58
+ return node;
59
+ });
60
+
61
+ const edges: LayoutEdge[] = data.edges.map((e) => ({
62
+ sourceId: e.sourceId,
63
+ targetId: e.targetId,
64
+ type: e.type,
65
+ }));
66
+
67
+ return { nodes, edges, nodeMap };
68
+ }
69
+
70
+ /** Run one tick of the force simulation. Returns new alpha. */
71
+ export function tick(state: LayoutState, alpha: number): number {
72
+ const { nodes, edges, nodeMap } = state;
73
+
74
+ // Repulsion — all pairs
75
+ for (let i = 0; i < nodes.length; i++) {
76
+ for (let j = i + 1; j < nodes.length; j++) {
77
+ const a = nodes[i];
78
+ const b = nodes[j];
79
+ let dx = b.x - a.x;
80
+ let dy = b.y - a.y;
81
+ let dist = Math.sqrt(dx * dx + dy * dy);
82
+ if (dist < MIN_DISTANCE) dist = MIN_DISTANCE;
83
+
84
+ const force = (REPULSION * alpha) / (dist * dist);
85
+ const fx = (dx / dist) * force;
86
+ const fy = (dy / dist) * force;
87
+
88
+ a.vx -= fx;
89
+ a.vy -= fy;
90
+ b.vx += fx;
91
+ b.vy += fy;
92
+ }
93
+ }
94
+
95
+ // Attraction — along edges
96
+ for (const edge of edges) {
97
+ const source = nodeMap.get(edge.sourceId);
98
+ const target = nodeMap.get(edge.targetId);
99
+ if (!source || !target) continue;
100
+
101
+ const dx = target.x - source.x;
102
+ const dy = target.y - source.y;
103
+ const dist = Math.sqrt(dx * dx + dy * dy);
104
+ if (dist === 0) continue;
105
+
106
+ const force = ATTRACTION * (dist - REST_LENGTH) * alpha;
107
+ const fx = (dx / dist) * force;
108
+ const fy = (dy / dist) * force;
109
+
110
+ source.vx += fx;
111
+ source.vy += fy;
112
+ target.vx -= fx;
113
+ target.vy -= fy;
114
+ }
115
+
116
+ // Centering gravity
117
+ for (const node of nodes) {
118
+ node.vx -= node.x * CENTER_GRAVITY * alpha;
119
+ node.vy -= node.y * CENTER_GRAVITY * alpha;
120
+ }
121
+
122
+ // Integrate — update positions, apply damping, clamp velocity
123
+ for (const node of nodes) {
124
+ node.vx *= DAMPING;
125
+ node.vy *= DAMPING;
126
+
127
+ const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy);
128
+ if (speed > MAX_VELOCITY) {
129
+ node.vx = (node.vx / speed) * MAX_VELOCITY;
130
+ node.vy = (node.vy / speed) * MAX_VELOCITY;
131
+ }
132
+
133
+ node.x += node.vx;
134
+ node.y += node.vy;
135
+ }
136
+
137
+ return alpha * 0.995;
138
+ }
package/src/main.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { OntologyData } from "backpack-ontology";
2
+ import { listOntologies, loadOntology } from "./api";
3
+ import { initSidebar } from "./sidebar";
4
+ import { initCanvas } from "./canvas";
5
+ import { initInfoPanel } from "./info-panel";
6
+ import "./style.css";
7
+
8
+ let activeOntology = "";
9
+ let currentData: OntologyData | null = null;
10
+
11
+ async function main() {
12
+ const infoPanel = initInfoPanel(
13
+ document.getElementById("canvas-container")!
14
+ );
15
+
16
+ const canvas = initCanvas(
17
+ document.getElementById("canvas-container")!,
18
+ (nodeId) => {
19
+ if (nodeId && currentData) {
20
+ infoPanel.show(nodeId, currentData);
21
+ } else {
22
+ infoPanel.hide();
23
+ }
24
+ }
25
+ );
26
+
27
+ const sidebar = initSidebar(
28
+ document.getElementById("sidebar")!,
29
+ async (name) => {
30
+ activeOntology = name;
31
+ sidebar.setActive(name);
32
+ infoPanel.hide();
33
+ currentData = await loadOntology(name);
34
+ canvas.loadGraph(currentData);
35
+ }
36
+ );
37
+
38
+ // Load ontology list
39
+ const summaries = await listOntologies();
40
+ sidebar.setSummaries(summaries);
41
+
42
+ // Auto-load first ontology
43
+ if (summaries.length > 0) {
44
+ activeOntology = summaries[0].name;
45
+ sidebar.setActive(activeOntology);
46
+ currentData = await loadOntology(activeOntology);
47
+ canvas.loadGraph(currentData);
48
+ }
49
+
50
+ // Live reload — when Claude adds nodes via MCP, re-fetch and re-render
51
+ if (import.meta.hot) {
52
+ import.meta.hot.on("ontology-change", async () => {
53
+ const updated = await listOntologies();
54
+ sidebar.setSummaries(updated);
55
+
56
+ if (activeOntology) {
57
+ try {
58
+ currentData = await loadOntology(activeOntology);
59
+ canvas.loadGraph(currentData);
60
+ } catch {
61
+ // Ontology may have been deleted
62
+ }
63
+ }
64
+ });
65
+ }
66
+ }
67
+
68
+ main();
package/src/sidebar.ts ADDED
@@ -0,0 +1,80 @@
1
+ import type { OntologySummary } from "backpack-ontology";
2
+
3
+ export function initSidebar(
4
+ container: HTMLElement,
5
+ onSelect: (name: string) => void
6
+ ) {
7
+ // Build DOM
8
+ const heading = document.createElement("h2");
9
+ heading.textContent = "Backpack Ontology Viewer";
10
+
11
+ const input = document.createElement("input");
12
+ input.type = "text";
13
+ input.placeholder = "Filter...";
14
+ input.id = "filter";
15
+
16
+ const list = document.createElement("ul");
17
+ list.id = "ontology-list";
18
+
19
+ const footer = document.createElement("div");
20
+ footer.className = "sidebar-footer";
21
+ footer.innerHTML =
22
+ '<a href="mailto:support@backpackontology.com">support@backpackontology.com</a>' +
23
+ "<span>Feedback, support & sponsorship</span>";
24
+
25
+ container.appendChild(heading);
26
+ container.appendChild(input);
27
+ container.appendChild(list);
28
+ container.appendChild(footer);
29
+
30
+ let items: HTMLLIElement[] = [];
31
+ let activeName = "";
32
+
33
+ // Filter
34
+ input.addEventListener("input", () => {
35
+ const query = input.value.toLowerCase();
36
+ for (const item of items) {
37
+ const name = item.dataset.name ?? "";
38
+ item.style.display = name.includes(query) ? "" : "none";
39
+ }
40
+ });
41
+
42
+ return {
43
+ setSummaries(summaries: OntologySummary[]) {
44
+ list.innerHTML = "";
45
+ items = summaries.map((s) => {
46
+ const li = document.createElement("li");
47
+ li.className = "ontology-item";
48
+ li.dataset.name = s.name;
49
+
50
+ const nameSpan = document.createElement("span");
51
+ nameSpan.className = "name";
52
+ nameSpan.textContent = s.name;
53
+
54
+ const statsSpan = document.createElement("span");
55
+ statsSpan.className = "stats";
56
+ statsSpan.textContent = `${s.nodeCount} nodes, ${s.edgeCount} edges`;
57
+
58
+ li.appendChild(nameSpan);
59
+ li.appendChild(statsSpan);
60
+
61
+ li.addEventListener("click", () => onSelect(s.name));
62
+
63
+ list.appendChild(li);
64
+ return li;
65
+ });
66
+
67
+ // Re-apply active state
68
+ if (activeName) {
69
+ this.setActive(activeName);
70
+ }
71
+ },
72
+
73
+ setActive(name: string) {
74
+ activeName = name;
75
+ for (const item of items) {
76
+ item.classList.toggle("active", item.dataset.name === name);
77
+ }
78
+ },
79
+ };
80
+ }