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.
- package/README.md +94 -0
- package/bin/serve.js +19 -0
- package/index.html +17 -0
- package/package.json +39 -0
- package/src/api.ts +13 -0
- package/src/canvas.ts +409 -0
- package/src/colors.ts +40 -0
- package/src/info-panel.ts +230 -0
- package/src/layout.ts +138 -0
- package/src/main.ts +68 -0
- package/src/sidebar.ts +80 -0
- package/src/style.css +337 -0
- package/tsconfig.json +15 -0
- package/tsconfig.node.json +13 -0
- package/vite.config.ts +75 -0
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Backpack Ontology Viewer
|
|
2
|
+
|
|
3
|
+
A web-based graph visualizer for [backpack-ontology](../backpack-ontology). Renders ontology graphs on a Canvas 2D surface with force-directed layout, pan/zoom navigation, node inspection, and live reload when data changes.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Build the ontology engine first (required — viewer depends on it)
|
|
9
|
+
cd ../backpack-ontology && npm run build
|
|
10
|
+
|
|
11
|
+
# Install and start the viewer
|
|
12
|
+
cd ../backpack-viewer
|
|
13
|
+
npm install
|
|
14
|
+
npm run dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Open [http://localhost:5173](http://localhost:5173). The sidebar lists all ontologies stored by backpack-ontology. Click one to visualize it.
|
|
18
|
+
|
|
19
|
+
## How It Works
|
|
20
|
+
|
|
21
|
+
### Architecture
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
Claude (MCP tools) ──writes──> StorageBackend ──persists──> ontology data
|
|
25
|
+
│
|
|
26
|
+
Viewer (Vite plugin) ──reads via─────┘
|
|
27
|
+
│
|
|
28
|
+
HTTP API ──> Browser ──> Canvas 2D
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The viewer connects to backpack-ontology through the `StorageBackend` interface — the same abstraction the engine uses for persistence. It calls two methods:
|
|
32
|
+
|
|
33
|
+
- `listOntologies()` — returns names, descriptions, and counts
|
|
34
|
+
- `loadOntology(name)` — returns the full graph (nodes + edges)
|
|
35
|
+
|
|
36
|
+
This means the viewer works with **any** storage backend (JSON files, SQLite, remote API) without code changes.
|
|
37
|
+
|
|
38
|
+
### Live Reload
|
|
39
|
+
|
|
40
|
+
The Vite dev server watches the ontologies directory for file changes. When Claude adds or modifies nodes via MCP tools, the viewer automatically re-fetches and re-renders the active graph.
|
|
41
|
+
|
|
42
|
+
### Rendering
|
|
43
|
+
|
|
44
|
+
- **Layout**: Custom force-directed algorithm (repulsion + spring attraction + centering gravity). Nodes start in a circle and settle over ~200 frames.
|
|
45
|
+
- **Nodes**: Colored circles. Colors are deterministic by node type (hash → palette). Label below, type badge above.
|
|
46
|
+
- **Edges**: Straight lines with arrowheads. Edge type label at midpoint. Self-loops rendered as small circles.
|
|
47
|
+
- **Navigation**: Mouse drag to pan. Scroll wheel to zoom. Trackpad pinch to zoom. Touch drag/pinch on mobile.
|
|
48
|
+
- **Node inspection**: Click any node to open a detail panel showing all properties, connections, and timestamps. Selected nodes glow and highlight their connected edges. Non-connected nodes dim to focus attention.
|
|
49
|
+
|
|
50
|
+
### Data Handling
|
|
51
|
+
|
|
52
|
+
Ontology schemas are freeform — LLMs generate arbitrary node types and property shapes. The viewer handles this defensively:
|
|
53
|
+
|
|
54
|
+
- **Labels**: First string value in `node.properties`, fallback to `node.id`
|
|
55
|
+
- **Colors**: Deterministic hash of `node.type` into a 16-color palette — no hardcoded type lists
|
|
56
|
+
- **Properties**: Iterated dynamically, never assumed to have specific keys
|
|
57
|
+
- **Edge cases**: Self-loops, multiple edges between same pair, nodes with no string properties, empty edge properties
|
|
58
|
+
|
|
59
|
+
## API Endpoints
|
|
60
|
+
|
|
61
|
+
The Vite dev server exposes two endpoints (served by the ontology-api plugin):
|
|
62
|
+
|
|
63
|
+
| Endpoint | Returns |
|
|
64
|
+
|----------|---------|
|
|
65
|
+
| `GET /api/ontologies` | `OntologySummary[]` — name, description, nodeCount, edgeCount |
|
|
66
|
+
| `GET /api/ontologies/:name` | `OntologyData` — full graph with all nodes and edges |
|
|
67
|
+
|
|
68
|
+
## Project Structure
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
backpack-viewer/
|
|
72
|
+
├── vite.config.ts # Vite plugin: StorageBackend → HTTP API + file watcher
|
|
73
|
+
├── index.html # Single page shell
|
|
74
|
+
└── src/
|
|
75
|
+
├── main.ts # Entry point, wires sidebar + canvas + live reload
|
|
76
|
+
├── api.ts # fetch() wrappers returning backpack-ontology types
|
|
77
|
+
├── sidebar.ts # Ontology list with text filter
|
|
78
|
+
├── canvas.ts # Canvas 2D rendering + pan/zoom/pinch + node selection
|
|
79
|
+
├── info-panel.ts # Node detail panel (properties, connections, timestamps)
|
|
80
|
+
├── layout.ts # Force-directed graph layout algorithm
|
|
81
|
+
├── colors.ts # Deterministic type → color mapping
|
|
82
|
+
└── style.css # Dark theme
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Dependencies
|
|
86
|
+
|
|
87
|
+
- **Runtime**: `backpack-ontology` (local sibling — `StorageBackend` interface + types)
|
|
88
|
+
- **Dev**: `vite`, `typescript`
|
|
89
|
+
|
|
90
|
+
No frameworks. No UI libraries. Pure TypeScript + Canvas 2D.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
Apache-2.0
|
package/bin/serve.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createServer } from "vite";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const root = path.resolve(__dirname, "..");
|
|
9
|
+
|
|
10
|
+
const port = parseInt(process.env.PORT || "5173", 10);
|
|
11
|
+
|
|
12
|
+
const server = await createServer({
|
|
13
|
+
root,
|
|
14
|
+
configFile: path.resolve(root, "vite.config.ts"),
|
|
15
|
+
server: { port, open: true },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
await server.listen();
|
|
19
|
+
server.printUrls();
|
package/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Backpack Ontology Viewer</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app">
|
|
10
|
+
<aside id="sidebar"></aside>
|
|
11
|
+
<div id="canvas-container">
|
|
12
|
+
<canvas id="graph-canvas"></canvas>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
<script type="module" src="/src/main.ts"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "backpack-viewer",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Web-based graph visualizer for backpack-ontology — Canvas 2D, force-directed layout, live reload",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Noah Irzinger",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"backpack-viewer": "./bin/serve.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"index.html",
|
|
15
|
+
"vite.config.ts",
|
|
16
|
+
"tsconfig.json",
|
|
17
|
+
"tsconfig.node.json"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"dev": "vite",
|
|
21
|
+
"build": "tsc && vite build",
|
|
22
|
+
"preview": "vite preview",
|
|
23
|
+
"serve": "node bin/serve.js",
|
|
24
|
+
"release:patch": "npm version patch && git push && git push --tags",
|
|
25
|
+
"release:minor": "npm version minor && git push && git push --tags",
|
|
26
|
+
"release:major": "npm version major && git push && git push --tags"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"backpack-ontology": "^0.1.3",
|
|
30
|
+
"vite": "^6.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^25.5.0",
|
|
34
|
+
"typescript": "^5.8.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { OntologyData, OntologySummary } from "backpack-ontology";
|
|
2
|
+
|
|
3
|
+
export async function listOntologies(): Promise<OntologySummary[]> {
|
|
4
|
+
const res = await fetch("/api/ontologies");
|
|
5
|
+
if (!res.ok) return [];
|
|
6
|
+
return res.json();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function loadOntology(name: string): Promise<OntologyData> {
|
|
10
|
+
const res = await fetch(`/api/ontologies/${encodeURIComponent(name)}`);
|
|
11
|
+
if (!res.ok) throw new Error(`Failed to load ontology: ${name}`);
|
|
12
|
+
return res.json();
|
|
13
|
+
}
|
package/src/canvas.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import type { OntologyData } from "backpack-ontology";
|
|
2
|
+
import { createLayout, tick, type LayoutState, type LayoutNode } from "./layout";
|
|
3
|
+
import { getColor } from "./colors";
|
|
4
|
+
|
|
5
|
+
interface Camera {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
scale: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const NODE_RADIUS = 20;
|
|
12
|
+
const ALPHA_MIN = 0.001;
|
|
13
|
+
|
|
14
|
+
export function initCanvas(
|
|
15
|
+
container: HTMLElement,
|
|
16
|
+
onNodeClick?: (nodeId: string | null) => void
|
|
17
|
+
) {
|
|
18
|
+
const canvas = container.querySelector("canvas") as HTMLCanvasElement;
|
|
19
|
+
const ctx = canvas.getContext("2d")!;
|
|
20
|
+
const dpr = window.devicePixelRatio || 1;
|
|
21
|
+
|
|
22
|
+
let camera: Camera = { x: 0, y: 0, scale: 1 };
|
|
23
|
+
let state: LayoutState | null = null;
|
|
24
|
+
let alpha = 1;
|
|
25
|
+
let animFrame = 0;
|
|
26
|
+
let selectedNodeId: string | null = null;
|
|
27
|
+
|
|
28
|
+
// --- Sizing ---
|
|
29
|
+
|
|
30
|
+
function resize() {
|
|
31
|
+
canvas.width = canvas.clientWidth * dpr;
|
|
32
|
+
canvas.height = canvas.clientHeight * dpr;
|
|
33
|
+
render();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const observer = new ResizeObserver(resize);
|
|
37
|
+
observer.observe(container);
|
|
38
|
+
resize();
|
|
39
|
+
|
|
40
|
+
// --- Coordinate transforms ---
|
|
41
|
+
|
|
42
|
+
function screenToWorld(sx: number, sy: number): [number, number] {
|
|
43
|
+
return [
|
|
44
|
+
sx / camera.scale + camera.x,
|
|
45
|
+
sy / camera.scale + camera.y,
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Hit testing ---
|
|
50
|
+
|
|
51
|
+
function nodeAtScreen(sx: number, sy: number): LayoutNode | null {
|
|
52
|
+
if (!state) return null;
|
|
53
|
+
const [wx, wy] = screenToWorld(sx, sy);
|
|
54
|
+
// Iterate in reverse so topmost (last drawn) nodes are hit first
|
|
55
|
+
for (let i = state.nodes.length - 1; i >= 0; i--) {
|
|
56
|
+
const node = state.nodes[i];
|
|
57
|
+
const dx = wx - node.x;
|
|
58
|
+
const dy = wy - node.y;
|
|
59
|
+
if (dx * dx + dy * dy <= NODE_RADIUS * NODE_RADIUS) {
|
|
60
|
+
return node;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Rendering ---
|
|
67
|
+
|
|
68
|
+
function render() {
|
|
69
|
+
if (!state) {
|
|
70
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
ctx.save();
|
|
75
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
76
|
+
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
|
|
77
|
+
|
|
78
|
+
ctx.save();
|
|
79
|
+
ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
|
|
80
|
+
ctx.scale(camera.scale, camera.scale);
|
|
81
|
+
|
|
82
|
+
// Draw edges
|
|
83
|
+
for (const edge of state.edges) {
|
|
84
|
+
const source = state.nodeMap.get(edge.sourceId);
|
|
85
|
+
const target = state.nodeMap.get(edge.targetId);
|
|
86
|
+
if (!source || !target) continue;
|
|
87
|
+
|
|
88
|
+
const isConnected =
|
|
89
|
+
selectedNodeId !== null &&
|
|
90
|
+
(edge.sourceId === selectedNodeId || edge.targetId === selectedNodeId);
|
|
91
|
+
|
|
92
|
+
// Self-loop
|
|
93
|
+
if (edge.sourceId === edge.targetId) {
|
|
94
|
+
drawSelfLoop(source, edge.type, isConnected);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Line
|
|
99
|
+
ctx.beginPath();
|
|
100
|
+
ctx.moveTo(source.x, source.y);
|
|
101
|
+
ctx.lineTo(target.x, target.y);
|
|
102
|
+
ctx.strokeStyle = isConnected
|
|
103
|
+
? "rgba(212, 162, 127, 0.5)"
|
|
104
|
+
: "rgba(255, 255, 255, 0.08)";
|
|
105
|
+
ctx.lineWidth = isConnected ? 2.5 : 1.5;
|
|
106
|
+
ctx.stroke();
|
|
107
|
+
|
|
108
|
+
// Arrowhead
|
|
109
|
+
drawArrowhead(source.x, source.y, target.x, target.y, isConnected);
|
|
110
|
+
|
|
111
|
+
// Edge label at midpoint
|
|
112
|
+
const mx = (source.x + target.x) / 2;
|
|
113
|
+
const my = (source.y + target.y) / 2;
|
|
114
|
+
ctx.fillStyle = isConnected
|
|
115
|
+
? "rgba(212, 162, 127, 0.7)"
|
|
116
|
+
: "rgba(255, 255, 255, 0.2)";
|
|
117
|
+
ctx.font = "9px system-ui, sans-serif";
|
|
118
|
+
ctx.textAlign = "center";
|
|
119
|
+
ctx.textBaseline = "bottom";
|
|
120
|
+
ctx.fillText(edge.type, mx, my - 4);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Draw nodes
|
|
124
|
+
for (const node of state.nodes) {
|
|
125
|
+
const color = getColor(node.type);
|
|
126
|
+
const isSelected = node.id === selectedNodeId;
|
|
127
|
+
const isNeighbor =
|
|
128
|
+
selectedNodeId !== null &&
|
|
129
|
+
state.edges.some(
|
|
130
|
+
(e) =>
|
|
131
|
+
(e.sourceId === selectedNodeId && e.targetId === node.id) ||
|
|
132
|
+
(e.targetId === selectedNodeId && e.sourceId === node.id)
|
|
133
|
+
);
|
|
134
|
+
const dimmed =
|
|
135
|
+
selectedNodeId !== null && !isSelected && !isNeighbor;
|
|
136
|
+
|
|
137
|
+
// Glow for selected node
|
|
138
|
+
if (isSelected) {
|
|
139
|
+
ctx.save();
|
|
140
|
+
ctx.shadowColor = color;
|
|
141
|
+
ctx.shadowBlur = 20;
|
|
142
|
+
ctx.beginPath();
|
|
143
|
+
ctx.arc(node.x, node.y, NODE_RADIUS + 3, 0, Math.PI * 2);
|
|
144
|
+
ctx.fillStyle = color;
|
|
145
|
+
ctx.globalAlpha = 0.3;
|
|
146
|
+
ctx.fill();
|
|
147
|
+
ctx.restore();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Circle
|
|
151
|
+
ctx.beginPath();
|
|
152
|
+
ctx.arc(node.x, node.y, NODE_RADIUS, 0, Math.PI * 2);
|
|
153
|
+
ctx.fillStyle = color;
|
|
154
|
+
ctx.globalAlpha = dimmed ? 0.3 : 1;
|
|
155
|
+
ctx.fill();
|
|
156
|
+
ctx.strokeStyle = isSelected
|
|
157
|
+
? "#d4d4d4"
|
|
158
|
+
: "rgba(255, 255, 255, 0.15)";
|
|
159
|
+
ctx.lineWidth = isSelected ? 3 : 1.5;
|
|
160
|
+
ctx.stroke();
|
|
161
|
+
|
|
162
|
+
// Label below
|
|
163
|
+
const label =
|
|
164
|
+
node.label.length > 24 ? node.label.slice(0, 22) + "..." : node.label;
|
|
165
|
+
ctx.fillStyle = dimmed
|
|
166
|
+
? "rgba(212, 212, 212, 0.2)"
|
|
167
|
+
: "#a3a3a3";
|
|
168
|
+
ctx.font = "11px system-ui, sans-serif";
|
|
169
|
+
ctx.textAlign = "center";
|
|
170
|
+
ctx.textBaseline = "top";
|
|
171
|
+
ctx.fillText(label, node.x, node.y + NODE_RADIUS + 4);
|
|
172
|
+
|
|
173
|
+
// Type badge above
|
|
174
|
+
ctx.fillStyle = dimmed
|
|
175
|
+
? "rgba(115, 115, 115, 0.15)"
|
|
176
|
+
: "rgba(115, 115, 115, 0.5)";
|
|
177
|
+
ctx.font = "9px system-ui, sans-serif";
|
|
178
|
+
ctx.textBaseline = "bottom";
|
|
179
|
+
ctx.fillText(node.type, node.x, node.y - NODE_RADIUS - 3);
|
|
180
|
+
|
|
181
|
+
ctx.globalAlpha = 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
ctx.restore();
|
|
185
|
+
ctx.restore();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function drawArrowhead(
|
|
189
|
+
sx: number,
|
|
190
|
+
sy: number,
|
|
191
|
+
tx: number,
|
|
192
|
+
ty: number,
|
|
193
|
+
highlighted: boolean
|
|
194
|
+
) {
|
|
195
|
+
const angle = Math.atan2(ty - sy, tx - sx);
|
|
196
|
+
const tipX = tx - Math.cos(angle) * NODE_RADIUS;
|
|
197
|
+
const tipY = ty - Math.sin(angle) * NODE_RADIUS;
|
|
198
|
+
const size = 8;
|
|
199
|
+
|
|
200
|
+
ctx.beginPath();
|
|
201
|
+
ctx.moveTo(tipX, tipY);
|
|
202
|
+
ctx.lineTo(
|
|
203
|
+
tipX - size * Math.cos(angle - 0.4),
|
|
204
|
+
tipY - size * Math.sin(angle - 0.4)
|
|
205
|
+
);
|
|
206
|
+
ctx.lineTo(
|
|
207
|
+
tipX - size * Math.cos(angle + 0.4),
|
|
208
|
+
tipY - size * Math.sin(angle + 0.4)
|
|
209
|
+
);
|
|
210
|
+
ctx.closePath();
|
|
211
|
+
ctx.fillStyle = highlighted
|
|
212
|
+
? "rgba(212, 162, 127, 0.5)"
|
|
213
|
+
: "rgba(255, 255, 255, 0.12)";
|
|
214
|
+
ctx.fill();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function drawSelfLoop(
|
|
218
|
+
node: LayoutNode,
|
|
219
|
+
type: string,
|
|
220
|
+
highlighted: boolean
|
|
221
|
+
) {
|
|
222
|
+
const cx = node.x + NODE_RADIUS + 15;
|
|
223
|
+
const cy = node.y - NODE_RADIUS - 15;
|
|
224
|
+
ctx.beginPath();
|
|
225
|
+
ctx.arc(cx, cy, 15, 0, Math.PI * 2);
|
|
226
|
+
ctx.strokeStyle = highlighted
|
|
227
|
+
? "rgba(212, 162, 127, 0.5)"
|
|
228
|
+
: "rgba(255, 255, 255, 0.08)";
|
|
229
|
+
ctx.lineWidth = highlighted ? 2.5 : 1.5;
|
|
230
|
+
ctx.stroke();
|
|
231
|
+
|
|
232
|
+
ctx.fillStyle = highlighted
|
|
233
|
+
? "rgba(212, 162, 127, 0.7)"
|
|
234
|
+
: "rgba(255, 255, 255, 0.2)";
|
|
235
|
+
ctx.font = "9px system-ui, sans-serif";
|
|
236
|
+
ctx.textAlign = "center";
|
|
237
|
+
ctx.fillText(type, cx, cy - 18);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- Simulation loop ---
|
|
241
|
+
|
|
242
|
+
function simulate() {
|
|
243
|
+
if (!state || alpha < ALPHA_MIN) return;
|
|
244
|
+
alpha = tick(state, alpha);
|
|
245
|
+
render();
|
|
246
|
+
animFrame = requestAnimationFrame(simulate);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- Interaction: Pan + Click ---
|
|
250
|
+
|
|
251
|
+
let dragging = false;
|
|
252
|
+
let didDrag = false;
|
|
253
|
+
let lastX = 0;
|
|
254
|
+
let lastY = 0;
|
|
255
|
+
|
|
256
|
+
canvas.addEventListener("mousedown", (e) => {
|
|
257
|
+
dragging = true;
|
|
258
|
+
didDrag = false;
|
|
259
|
+
lastX = e.clientX;
|
|
260
|
+
lastY = e.clientY;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
canvas.addEventListener("mousemove", (e) => {
|
|
264
|
+
if (!dragging) return;
|
|
265
|
+
const dx = e.clientX - lastX;
|
|
266
|
+
const dy = e.clientY - lastY;
|
|
267
|
+
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) didDrag = true;
|
|
268
|
+
camera.x -= dx / camera.scale;
|
|
269
|
+
camera.y -= dy / camera.scale;
|
|
270
|
+
lastX = e.clientX;
|
|
271
|
+
lastY = e.clientY;
|
|
272
|
+
render();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
canvas.addEventListener("mouseup", (e) => {
|
|
276
|
+
dragging = false;
|
|
277
|
+
if (didDrag) return;
|
|
278
|
+
|
|
279
|
+
// Click — hit test for node selection
|
|
280
|
+
const rect = canvas.getBoundingClientRect();
|
|
281
|
+
const mx = e.clientX - rect.left;
|
|
282
|
+
const my = e.clientY - rect.top;
|
|
283
|
+
const hit = nodeAtScreen(mx, my);
|
|
284
|
+
|
|
285
|
+
if (hit) {
|
|
286
|
+
selectedNodeId = hit.id;
|
|
287
|
+
onNodeClick?.(hit.id);
|
|
288
|
+
} else {
|
|
289
|
+
selectedNodeId = null;
|
|
290
|
+
onNodeClick?.(null);
|
|
291
|
+
}
|
|
292
|
+
render();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
canvas.addEventListener("mouseleave", () => {
|
|
296
|
+
dragging = false;
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// --- Interaction: Zoom (wheel + pinch) ---
|
|
300
|
+
|
|
301
|
+
canvas.addEventListener(
|
|
302
|
+
"wheel",
|
|
303
|
+
(e) => {
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
|
|
306
|
+
const rect = canvas.getBoundingClientRect();
|
|
307
|
+
const mx = e.clientX - rect.left;
|
|
308
|
+
const my = e.clientY - rect.top;
|
|
309
|
+
|
|
310
|
+
const [wx, wy] = screenToWorld(mx, my);
|
|
311
|
+
|
|
312
|
+
const factor = e.ctrlKey
|
|
313
|
+
? 1 - e.deltaY * 0.01
|
|
314
|
+
: e.deltaY > 0
|
|
315
|
+
? 0.9
|
|
316
|
+
: 1.1;
|
|
317
|
+
|
|
318
|
+
camera.scale = Math.max(0.05, Math.min(10, camera.scale * factor));
|
|
319
|
+
|
|
320
|
+
camera.x = wx - mx / camera.scale;
|
|
321
|
+
camera.y = wy - my / camera.scale;
|
|
322
|
+
|
|
323
|
+
render();
|
|
324
|
+
},
|
|
325
|
+
{ passive: false }
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// --- Interaction: Touch (pinch zoom + drag) ---
|
|
329
|
+
|
|
330
|
+
let touches: Touch[] = [];
|
|
331
|
+
let initialPinchDist = 0;
|
|
332
|
+
let initialPinchScale = 1;
|
|
333
|
+
|
|
334
|
+
canvas.addEventListener("touchstart", (e) => {
|
|
335
|
+
e.preventDefault();
|
|
336
|
+
touches = Array.from(e.touches);
|
|
337
|
+
if (touches.length === 2) {
|
|
338
|
+
initialPinchDist = touchDistance(touches[0], touches[1]);
|
|
339
|
+
initialPinchScale = camera.scale;
|
|
340
|
+
} else if (touches.length === 1) {
|
|
341
|
+
lastX = touches[0].clientX;
|
|
342
|
+
lastY = touches[0].clientY;
|
|
343
|
+
}
|
|
344
|
+
}, { passive: false });
|
|
345
|
+
|
|
346
|
+
canvas.addEventListener("touchmove", (e) => {
|
|
347
|
+
e.preventDefault();
|
|
348
|
+
const current = Array.from(e.touches);
|
|
349
|
+
|
|
350
|
+
if (current.length === 2 && touches.length === 2) {
|
|
351
|
+
const dist = touchDistance(current[0], current[1]);
|
|
352
|
+
const ratio = dist / initialPinchDist;
|
|
353
|
+
camera.scale = Math.max(0.05, Math.min(10, initialPinchScale * ratio));
|
|
354
|
+
render();
|
|
355
|
+
} else if (current.length === 1) {
|
|
356
|
+
const dx = current[0].clientX - lastX;
|
|
357
|
+
const dy = current[0].clientY - lastY;
|
|
358
|
+
camera.x -= dx / camera.scale;
|
|
359
|
+
camera.y -= dy / camera.scale;
|
|
360
|
+
lastX = current[0].clientX;
|
|
361
|
+
lastY = current[0].clientY;
|
|
362
|
+
render();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
touches = current;
|
|
366
|
+
}, { passive: false });
|
|
367
|
+
|
|
368
|
+
function touchDistance(a: Touch, b: Touch): number {
|
|
369
|
+
const dx = a.clientX - b.clientX;
|
|
370
|
+
const dy = a.clientY - b.clientY;
|
|
371
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// --- Public API ---
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
loadGraph(data: OntologyData) {
|
|
378
|
+
cancelAnimationFrame(animFrame);
|
|
379
|
+
state = createLayout(data);
|
|
380
|
+
alpha = 1;
|
|
381
|
+
selectedNodeId = null;
|
|
382
|
+
|
|
383
|
+
// Center camera on the graph
|
|
384
|
+
camera = { x: 0, y: 0, scale: 1 };
|
|
385
|
+
if (state.nodes.length > 0) {
|
|
386
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
387
|
+
for (const n of state.nodes) {
|
|
388
|
+
if (n.x < minX) minX = n.x;
|
|
389
|
+
if (n.y < minY) minY = n.y;
|
|
390
|
+
if (n.x > maxX) maxX = n.x;
|
|
391
|
+
if (n.y > maxY) maxY = n.y;
|
|
392
|
+
}
|
|
393
|
+
const cx = (minX + maxX) / 2;
|
|
394
|
+
const cy = (minY + maxY) / 2;
|
|
395
|
+
const w = canvas.clientWidth;
|
|
396
|
+
const h = canvas.clientHeight;
|
|
397
|
+
camera.x = cx - w / 2;
|
|
398
|
+
camera.y = cy - h / 2;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
simulate();
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
destroy() {
|
|
405
|
+
cancelAnimationFrame(animFrame);
|
|
406
|
+
observer.disconnect();
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
package/src/colors.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic type → color mapping.
|
|
3
|
+
* Earth-tone accent palette on a neutral gray UI.
|
|
4
|
+
* These are the only warm colors in the interface —
|
|
5
|
+
* everything else is grayscale.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const PALETTE = [
|
|
9
|
+
"#d4a27f", // warm tan
|
|
10
|
+
"#c17856", // terracotta
|
|
11
|
+
"#b07a5e", // sienna
|
|
12
|
+
"#d4956b", // burnt amber
|
|
13
|
+
"#a67c5a", // walnut
|
|
14
|
+
"#cc9e7c", // copper
|
|
15
|
+
"#c4866a", // clay
|
|
16
|
+
"#cb8e6c", // apricot
|
|
17
|
+
"#b8956e", // wheat
|
|
18
|
+
"#a88a70", // driftwood
|
|
19
|
+
"#d9b08c", // caramel
|
|
20
|
+
"#c4a882", // sand
|
|
21
|
+
"#e8b898", // peach
|
|
22
|
+
"#b5927a", // dusty rose
|
|
23
|
+
"#a8886e", // muted brown
|
|
24
|
+
"#d1a990", // blush tan
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const cache = new Map<string, string>();
|
|
28
|
+
|
|
29
|
+
export function getColor(type: string): string {
|
|
30
|
+
const cached = cache.get(type);
|
|
31
|
+
if (cached) return cached;
|
|
32
|
+
|
|
33
|
+
let hash = 0;
|
|
34
|
+
for (let i = 0; i < type.length; i++) {
|
|
35
|
+
hash = ((hash << 5) - hash + type.charCodeAt(i)) | 0;
|
|
36
|
+
}
|
|
37
|
+
const color = PALETTE[Math.abs(hash) % PALETTE.length];
|
|
38
|
+
cache.set(type, color);
|
|
39
|
+
return color;
|
|
40
|
+
}
|