Inicia integrar Anzograph
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
.direnv/
|
.direnv/
|
||||||
.envrc
|
.envrc
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ COPY . .
|
|||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|
||||||
# Compute layout, then start the dev server with --host for external access
|
# Compute layout, then start the dev server with --host for external access
|
||||||
CMD ["sh", "-c", "npm run layout && npm run dev -- --host"]
|
CMD ["sh", "-c", "npm run dev -- --host"]
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
|
depends_on:
|
||||||
|
- anzograph
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
command: sh -c "npm run layout && npm run dev -- --host"
|
command: sh -c "npm run layout && npm run dev -- --host"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app:Z
|
||||||
- /app/node_modules # Prevents local node_modules from overwriting the container's
|
- /app/node_modules
|
||||||
|
|
||||||
|
anzograph:
|
||||||
|
image: cambridgesemantics/anzograph:latest
|
||||||
|
container_name: anzograph
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "8443:8443"
|
||||||
|
volumes:
|
||||||
|
- ./data:/opt/shared-files:Z
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"layout": "npx tsx scripts/generate_tree.ts && npx tsx scripts/compute_layout.ts"
|
"layout": "npx tsx scripts/fetch_from_db.ts && npx tsx scripts/compute_layout.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@webgpu/types": "^0.1.69",
|
"@webgpu/types": "^0.1.69",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1 @@
|
|||||||
source,target
|
source,target
|
||||||
1,0
|
|
||||||
2,0
|
|
||||||
3,0
|
|
||||||
4,0
|
|
||||||
5,1
|
|
||||||
6,1
|
|
||||||
7,1
|
|
||||||
8,1
|
|
||||||
9,2
|
|
||||||
10,3
|
|
||||||
11,3
|
|
||||||
12,4
|
|
||||||
13,4
|
|
||||||
14,5
|
|
||||||
15,5
|
|
||||||
16,5
|
|
||||||
17,6
|
|
||||||
18,7
|
|
||||||
19,8
|
|
||||||
20,8
|
|
||||||
21,8
|
|
||||||
22,8
|
|
||||||
23,9
|
|
||||||
24,9
|
|
||||||
25,10
|
|
||||||
26,11
|
|
||||||
27,11
|
|
||||||
28,11
|
|
||||||
29,12
|
|
||||||
30,12
|
|
||||||
31,12
|
|
||||||
32,12
|
|
||||||
33,13
|
|
||||||
34,13
|
|
||||||
35,13
|
|
||||||
|
|||||||
|
File diff suppressed because it is too large
Load Diff
1
public/uri_map.csv
Normal file
1
public/uri_map.csv
Normal file
@@ -0,0 +1 @@
|
|||||||
|
id,uri,label,isPrimary
|
||||||
|
@@ -1,12 +1,17 @@
|
|||||||
#!/usr/bin/env npx tsx
|
#!/usr/bin/env npx tsx
|
||||||
/**
|
/**
|
||||||
* Two-Phase Tree Layout
|
* Graph Layout
|
||||||
*
|
*
|
||||||
* Phase 1: Position a primary skeleton (nodes from primary_edges.csv)
|
* Computes a 2D layout for a general graph (not necessarily a tree).
|
||||||
* with generous spacing, then force-simulate the skeleton.
|
*
|
||||||
* Phase 2: Fill in remaining subtrees (secondary_edges.csv) within sectors.
|
* - Primary nodes (from primary_edges.csv) are placed first in a radial layout
|
||||||
|
* - Remaining nodes are placed near their connected primary neighbors
|
||||||
|
* - Barnes-Hut force simulation relaxes the layout
|
||||||
*
|
*
|
||||||
* Usage: npm run layout-only (after generating tree)
|
* Reads: primary_edges.csv, secondary_edges.csv
|
||||||
|
* Writes: node_positions.csv
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/compute_layout.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { writeFileSync, readFileSync, existsSync } from "fs";
|
import { writeFileSync, readFileSync, existsSync } from "fs";
|
||||||
@@ -20,28 +25,21 @@ const PUBLIC_DIR = join(__dirname, "..", "public");
|
|||||||
// Configuration
|
// Configuration
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const ENABLE_FORCE_SIM = true; // Set to false to skip force simulation
|
const ITERATIONS = 200; // Force iterations
|
||||||
const ITERATIONS = 100; // Force iterations
|
const REPULSION_K = 200; // Repulsion strength
|
||||||
const REPULSION_K = 80; // Repulsion strength
|
const EDGE_LENGTH = 80; // Desired edge rest length
|
||||||
const EDGE_LENGTH = 120; // Desired edge rest length
|
const ATTRACTION_K = 0.005; // Spring stiffness for edges
|
||||||
const ATTRACTION_K = 0.0002; // Spring stiffness for edges
|
const INITIAL_MAX_DISP = 20; // Starting max displacement
|
||||||
const INITIAL_MAX_DISP = 15; // Starting max displacement
|
const COOLING = 0.995; // Cooling per iteration
|
||||||
const COOLING = 0.998; // Cooling per iteration
|
|
||||||
const MIN_DIST = 0.5;
|
const MIN_DIST = 0.5;
|
||||||
const PRINT_EVERY = 10; // Print progress every N iterations
|
const PRINT_EVERY = 20; // Print progress every N iterations
|
||||||
|
const BH_THETA = 0.8; // Barnes-Hut opening angle
|
||||||
// Scale radius so the tree is nicely spread
|
|
||||||
const RADIUS_PER_DEPTH = EDGE_LENGTH * 1.2;
|
|
||||||
|
|
||||||
// How many times longer skeleton edges are vs. normal edges
|
|
||||||
const LONG_EDGE_MULTIPLIER = 39.0;
|
|
||||||
|
|
||||||
const SKELETON_STEP = RADIUS_PER_DEPTH * LONG_EDGE_MULTIPLIER;
|
|
||||||
|
|
||||||
|
|
||||||
|
// Primary node radial placement
|
||||||
|
const PRIMARY_RADIUS = 300; // Radius for primary node ring
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
// Read tree data from CSVs
|
// Read edge data from CSVs
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const primaryPath = join(PUBLIC_DIR, "primary_edges.csv");
|
const primaryPath = join(PUBLIC_DIR, "primary_edges.csv");
|
||||||
@@ -51,16 +49,14 @@ if (!existsSync(primaryPath) || !existsSync(secondaryPath)) {
|
|||||||
console.error(`Error: Missing input files!`);
|
console.error(`Error: Missing input files!`);
|
||||||
console.error(` Expected: ${primaryPath}`);
|
console.error(` Expected: ${primaryPath}`);
|
||||||
console.error(` Expected: ${secondaryPath}`);
|
console.error(` Expected: ${secondaryPath}`);
|
||||||
console.error(` Run 'npx tsx scripts/generate_tree.ts' first.`);
|
console.error(` Run 'npx tsx scripts/fetch_from_db.ts' first.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helper to parse CSV edge list ──
|
|
||||||
function parseEdges(path: string): Array<[number, number]> {
|
function parseEdges(path: string): Array<[number, number]> {
|
||||||
const content = readFileSync(path, "utf-8");
|
const content = readFileSync(path, "utf-8");
|
||||||
const lines = content.trim().split("\n");
|
const lines = content.trim().split("\n");
|
||||||
const edges: Array<[number, number]> = [];
|
const edges: Array<[number, number]> = [];
|
||||||
// Skip header "source,target"
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
const line = lines[i].trim();
|
const line = lines[i].trim();
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
@@ -76,409 +72,171 @@ const primaryEdges = parseEdges(primaryPath);
|
|||||||
const secondaryEdges = parseEdges(secondaryPath);
|
const secondaryEdges = parseEdges(secondaryPath);
|
||||||
const allEdges = [...primaryEdges, ...secondaryEdges];
|
const allEdges = [...primaryEdges, ...secondaryEdges];
|
||||||
|
|
||||||
// ── Reconstruct tree connectivity ──
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Build adjacency
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const childrenOf = new Map<number, number[]>();
|
|
||||||
const parentOf = new Map<number, number>();
|
|
||||||
const allNodes = new Set<number>();
|
const allNodes = new Set<number>();
|
||||||
const primaryNodes = new Set<number>(); // Nodes involved in primary edges
|
const primaryNodes = new Set<number>();
|
||||||
|
const neighbors = new Map<number, Set<number>>();
|
||||||
|
|
||||||
// Process primary edges first (to classify primary nodes)
|
function addNeighbor(a: number, b: number) {
|
||||||
for (const [child, parent] of primaryEdges) {
|
if (!neighbors.has(a)) neighbors.set(a, new Set());
|
||||||
allNodes.add(child);
|
neighbors.get(a)!.add(b);
|
||||||
allNodes.add(parent);
|
if (!neighbors.has(b)) neighbors.set(b, new Set());
|
||||||
primaryNodes.add(child);
|
neighbors.get(b)!.add(a);
|
||||||
primaryNodes.add(parent);
|
|
||||||
|
|
||||||
parentOf.set(child, parent);
|
|
||||||
if (!childrenOf.has(parent)) childrenOf.set(parent, []);
|
|
||||||
childrenOf.get(parent)!.push(child);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process secondary edges
|
for (const [src, dst] of primaryEdges) {
|
||||||
for (const [child, parent] of secondaryEdges) {
|
allNodes.add(src);
|
||||||
allNodes.add(child);
|
allNodes.add(dst);
|
||||||
allNodes.add(parent);
|
primaryNodes.add(src);
|
||||||
|
primaryNodes.add(dst);
|
||||||
|
addNeighbor(src, dst);
|
||||||
|
}
|
||||||
|
|
||||||
parentOf.set(child, parent);
|
for (const [src, dst] of secondaryEdges) {
|
||||||
if (!childrenOf.has(parent)) childrenOf.set(parent, []);
|
allNodes.add(src);
|
||||||
childrenOf.get(parent)!.push(child);
|
allNodes.add(dst);
|
||||||
|
addNeighbor(src, dst);
|
||||||
}
|
}
|
||||||
|
|
||||||
const N = allNodes.size;
|
const N = allNodes.size;
|
||||||
const nodeIds = Array.from(allNodes).sort((a, b) => a - b);
|
const nodeIds = Array.from(allNodes).sort((a, b) => a - b);
|
||||||
|
|
||||||
// Find root (node with no parent)
|
|
||||||
// Assuming single root for now. If multiple, pick smallest ID or error.
|
|
||||||
let root = -1;
|
|
||||||
for (const node of allNodes) {
|
|
||||||
if (!parentOf.has(node)) {
|
|
||||||
root = node;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (primaryNodes.size === 0 && N > 0) {
|
|
||||||
// Edge case: no primary edges?
|
|
||||||
root = nodeIds[0];
|
|
||||||
primaryNodes.add(root);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Read tree: ${N} nodes, ${allEdges.length} edges (P=${primaryEdges.length}, S=${secondaryEdges.length}), root=${root}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Compute full-tree subtree sizes
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const subtreeSize = new Map<number, number>();
|
|
||||||
for (const id of nodeIds) subtreeSize.set(id, 1);
|
|
||||||
|
|
||||||
{
|
|
||||||
// Post-order traversal to sum subtree sizes
|
|
||||||
// Or iterative with two stacks
|
|
||||||
const stack: Array<{ id: number; phase: "enter" | "exit" }> = [
|
|
||||||
{ id: root, phase: "enter" },
|
|
||||||
];
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const { id, phase } = stack.pop()!;
|
|
||||||
if (phase === "enter") {
|
|
||||||
stack.push({ id, phase: "exit" });
|
|
||||||
const kids = childrenOf.get(id);
|
|
||||||
if (kids) for (const kid of kids) stack.push({ id: kid, phase: "enter" });
|
|
||||||
} else {
|
|
||||||
const kids = childrenOf.get(id);
|
|
||||||
if (kids) {
|
|
||||||
let sum = 0;
|
|
||||||
for (const kid of kids) sum += subtreeSize.get(kid)!;
|
|
||||||
subtreeSize.set(id, 1 + sum);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Skeleton = primary nodes
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const skeleton = primaryNodes;
|
|
||||||
console.log(`Skeleton: ${skeleton.size} nodes, ${primaryEdges.length} edges`);
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Position arrays & per-node tracking
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
// We use dense arrays logic, but node IDs might be sparse if loaded from file.
|
|
||||||
// However, generate_tree produced sequential IDs starting at 0.
|
|
||||||
// Let's assume dense 0..N-1 for array indexing, mapped via nodeIds if needed.
|
|
||||||
// Actually, let's keep it simple: assume maxId < 2*N or use Maps for positions?
|
|
||||||
// The current code uses Float64Array(N) and assumes `nodeIds[i]` corresponds to index `i`?
|
|
||||||
// No, the previous code pushed `nodeIds` as `0..N-1`.
|
|
||||||
// Here, `nodeIds` IS verified to be `0..N-1` because generate_tree did `nextId++`.
|
|
||||||
// So `nodeIds[i] === i`. We can directly use `x[i]`.
|
|
||||||
// But if input file has gaps, we'd need a map. To be safe, let's build an `idToIdx` map.
|
|
||||||
|
|
||||||
const maxId = Math.max(...nodeIds);
|
|
||||||
const mapSize = maxId + 1; // Or just use `N` if we remap. Let's remap.
|
|
||||||
const idToIdx = new Map<number, number>();
|
const idToIdx = new Map<number, number>();
|
||||||
nodeIds.forEach((id, idx) => idToIdx.set(id, idx));
|
nodeIds.forEach((id, idx) => idToIdx.set(id, idx));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Read graph: ${N} nodes, ${allEdges.length} edges (P=${primaryEdges.length}, S=${secondaryEdges.length})`
|
||||||
|
);
|
||||||
|
console.log(`Primary nodes: ${primaryNodes.size}`);
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Initial placement
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const x = new Float64Array(N);
|
const x = new Float64Array(N);
|
||||||
const y = new Float64Array(N);
|
const y = new Float64Array(N);
|
||||||
const nodeRadius = new Float64Array(N); // distance from origin
|
|
||||||
const sectorStart = new Float64Array(N);
|
// Step 1: Place primary nodes in a radial layout
|
||||||
const sectorEnd = new Float64Array(N);
|
const primaryArr = Array.from(primaryNodes).sort((a, b) => a - b);
|
||||||
const positioned = new Set<number>();
|
const angleStep = (2 * Math.PI) / Math.max(1, primaryArr.length);
|
||||||
|
const radius = PRIMARY_RADIUS * Math.max(1, Math.sqrt(primaryArr.length / 10));
|
||||||
|
|
||||||
|
for (let i = 0; i < primaryArr.length; i++) {
|
||||||
|
const idx = idToIdx.get(primaryArr[i])!;
|
||||||
|
const angle = i * angleStep;
|
||||||
|
x[idx] = radius * Math.cos(angle);
|
||||||
|
y[idx] = radius * Math.sin(angle);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Placed ${primaryArr.length} primary nodes in radial layout (r=${radius.toFixed(0)})`);
|
||||||
|
|
||||||
|
// Step 2: Place remaining nodes near their connected neighbors
|
||||||
|
// BFS from already-placed nodes
|
||||||
|
const placed = new Set<number>(primaryNodes);
|
||||||
|
const queue: number[] = [...primaryArr];
|
||||||
|
let head = 0;
|
||||||
|
|
||||||
|
while (head < queue.length) {
|
||||||
|
const nodeId = queue[head++];
|
||||||
|
const nodeNeighbors = neighbors.get(nodeId);
|
||||||
|
if (!nodeNeighbors) continue;
|
||||||
|
|
||||||
|
for (const nbId of nodeNeighbors) {
|
||||||
|
if (placed.has(nbId)) continue;
|
||||||
|
placed.add(nbId);
|
||||||
|
|
||||||
|
// Place near this neighbor with some jitter
|
||||||
|
const parentIdx = idToIdx.get(nodeId)!;
|
||||||
|
const childIdx = idToIdx.get(nbId)!;
|
||||||
|
const jitterAngle = Math.random() * 2 * Math.PI;
|
||||||
|
const jitterDist = EDGE_LENGTH * (0.5 + Math.random() * 0.5);
|
||||||
|
x[childIdx] = x[parentIdx] + jitterDist * Math.cos(jitterAngle);
|
||||||
|
y[childIdx] = y[parentIdx] + jitterDist * Math.sin(jitterAngle);
|
||||||
|
|
||||||
|
queue.push(nbId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle disconnected nodes (place randomly)
|
||||||
|
for (const id of nodeIds) {
|
||||||
|
if (!placed.has(id)) {
|
||||||
|
const idx = idToIdx.get(id)!;
|
||||||
|
const angle = Math.random() * 2 * Math.PI;
|
||||||
|
const r = radius * (1 + Math.random());
|
||||||
|
x[idx] = r * Math.cos(angle);
|
||||||
|
y[idx] = r * Math.sin(angle);
|
||||||
|
placed.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Initial placement complete: ${placed.size} nodes`);
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
// Phase 1: Layout skeleton with long edges
|
// Force-directed layout with Barnes-Hut
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const rootIdx = idToIdx.get(root)!;
|
console.log(`Running force simulation (${ITERATIONS} iterations, ${N} nodes, ${allEdges.length} edges)...`);
|
||||||
x[rootIdx] = 0;
|
|
||||||
y[rootIdx] = 0;
|
|
||||||
nodeRadius[rootIdx] = 0;
|
|
||||||
sectorStart[rootIdx] = 0;
|
|
||||||
sectorEnd[rootIdx] = 2 * Math.PI;
|
|
||||||
positioned.add(root);
|
|
||||||
|
|
||||||
{
|
const t0 = performance.now();
|
||||||
const queue: number[] = [root];
|
let maxDisp = INITIAL_MAX_DISP;
|
||||||
let head = 0;
|
|
||||||
|
|
||||||
while (head < queue.length) {
|
for (let iter = 0; iter < ITERATIONS; iter++) {
|
||||||
const parentId = queue[head++];
|
const bhRoot = buildBHTree(x, y, N);
|
||||||
const parentIdx = idToIdx.get(parentId)!;
|
const fx = new Float64Array(N);
|
||||||
const kids = childrenOf.get(parentId);
|
const fy = new Float64Array(N);
|
||||||
if (!kids || kids.length === 0) continue;
|
|
||||||
|
|
||||||
const aStart = sectorStart[parentIdx];
|
// 1. Repulsion via Barnes-Hut
|
||||||
const aEnd = sectorEnd[parentIdx];
|
for (let i = 0; i < N; i++) {
|
||||||
const totalWeight = kids.reduce((s, k) => s + subtreeSize.get(k)!, 0);
|
calcBHForce(bhRoot, x[i], y[i], fx, fy, i, BH_THETA, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
// Sort children by subtree size
|
// 2. Edge attraction (spring force)
|
||||||
const sortedKids = [...kids].sort(
|
for (const [aId, bId] of allEdges) {
|
||||||
(a, b) => subtreeSize.get(b)! - subtreeSize.get(a)!
|
const a = idToIdx.get(aId)!;
|
||||||
|
const b = idToIdx.get(bId)!;
|
||||||
|
const dx = x[b] - x[a];
|
||||||
|
const dy = y[b] - y[a];
|
||||||
|
const d = Math.sqrt(dx * dx + dy * dy) || MIN_DIST;
|
||||||
|
const displacement = d - EDGE_LENGTH;
|
||||||
|
const f = ATTRACTION_K * displacement;
|
||||||
|
const ux = dx / d;
|
||||||
|
const uy = dy / d;
|
||||||
|
fx[a] += ux * f;
|
||||||
|
fy[a] += uy * f;
|
||||||
|
fx[b] -= ux * f;
|
||||||
|
fy[b] -= uy * f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Apply forces with displacement capping
|
||||||
|
let totalForce = 0;
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const mag = Math.sqrt(fx[i] * fx[i] + fy[i] * fy[i]);
|
||||||
|
totalForce += mag;
|
||||||
|
if (mag > 0) {
|
||||||
|
const cap = Math.min(maxDisp, mag) / mag;
|
||||||
|
x[i] += fx[i] * cap;
|
||||||
|
y[i] += fy[i] * cap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxDisp *= COOLING;
|
||||||
|
|
||||||
|
if ((iter + 1) % PRINT_EVERY === 0 || iter === 0) {
|
||||||
|
console.log(
|
||||||
|
` iter ${iter + 1}/${ITERATIONS} max_disp=${maxDisp.toFixed(2)} avg_force=${(totalForce / N).toFixed(2)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
let angle = aStart;
|
|
||||||
for (const kid of sortedKids) {
|
|
||||||
const kidIdx = idToIdx.get(kid)!;
|
|
||||||
const w = subtreeSize.get(kid)!;
|
|
||||||
const sector = (w / totalWeight) * (aEnd - aStart);
|
|
||||||
sectorStart[kidIdx] = angle;
|
|
||||||
sectorEnd[kidIdx] = angle + sector;
|
|
||||||
|
|
||||||
// Only position skeleton children now
|
|
||||||
if (skeleton.has(kid)) {
|
|
||||||
const midAngle = angle + sector / 2;
|
|
||||||
const r = nodeRadius[parentIdx] + SKELETON_STEP;
|
|
||||||
nodeRadius[kidIdx] = r;
|
|
||||||
x[kidIdx] = r * Math.cos(midAngle);
|
|
||||||
y[kidIdx] = r * Math.sin(midAngle);
|
|
||||||
positioned.add(kid);
|
|
||||||
queue.push(kid); // continue BFS within skeleton
|
|
||||||
}
|
|
||||||
|
|
||||||
angle += sector;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Phase 1: Positioned ${positioned.size} skeleton nodes`);
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Force simulation on skeleton only
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
if (ENABLE_FORCE_SIM && skeleton.size > 1) {
|
|
||||||
const skeletonArr = Array.from(skeleton);
|
|
||||||
const skeletonIndices = skeletonArr.map(id => idToIdx.get(id)!);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Force sim on skeleton (${skeletonArr.length} nodes, ${primaryEdges.length} edges)...`
|
|
||||||
);
|
|
||||||
|
|
||||||
const t0 = performance.now();
|
|
||||||
let maxDisp = INITIAL_MAX_DISP;
|
|
||||||
|
|
||||||
for (let iter = 0; iter < ITERATIONS; iter++) {
|
|
||||||
const fx = new Float64Array(N);
|
|
||||||
const fy = new Float64Array(N);
|
|
||||||
|
|
||||||
// 1. Pairwise repulsion
|
|
||||||
for (let i = 0; i < skeletonIndices.length; i++) {
|
|
||||||
const u = skeletonIndices[i];
|
|
||||||
for (let j = i + 1; j < skeletonIndices.length; j++) {
|
|
||||||
const v = skeletonIndices[j];
|
|
||||||
const dx = x[u] - x[v];
|
|
||||||
const dy = y[u] - y[v];
|
|
||||||
const d2 = dx * dx + dy * dy;
|
|
||||||
const d = Math.sqrt(d2) || MIN_DIST;
|
|
||||||
const f = REPULSION_K / (d2 + MIN_DIST);
|
|
||||||
fx[u] += (dx / d) * f;
|
|
||||||
fy[u] += (dy / d) * f;
|
|
||||||
fx[v] -= (dx / d) * f;
|
|
||||||
fy[v] -= (dy / d) * f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Edge attraction
|
|
||||||
for (const [aId, bId] of primaryEdges) {
|
|
||||||
const a = idToIdx.get(aId)!;
|
|
||||||
const b = idToIdx.get(bId)!;
|
|
||||||
const dx = x[b] - x[a];
|
|
||||||
const dy = y[b] - y[a];
|
|
||||||
const d = Math.sqrt(dx * dx + dy * dy) || MIN_DIST;
|
|
||||||
const displacement = d - SKELETON_STEP;
|
|
||||||
const f = (ATTRACTION_K / LONG_EDGE_MULTIPLIER) * displacement;
|
|
||||||
const ux = dx / d, uy = dy / d;
|
|
||||||
fx[a] += ux * f;
|
|
||||||
fy[a] += uy * f;
|
|
||||||
fx[b] -= ux * f;
|
|
||||||
fy[b] -= uy * f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Apply forces (skip root)
|
|
||||||
for (const idx of skeletonIndices) {
|
|
||||||
if (nodeIds[idx] === root) continue;
|
|
||||||
const mag = Math.sqrt(fx[idx] * fx[idx] + fy[idx] * fy[idx]);
|
|
||||||
if (mag > 0) {
|
|
||||||
const cap = Math.min(maxDisp, mag) / mag;
|
|
||||||
x[idx] += fx[idx] * cap;
|
|
||||||
y[idx] += fy[idx] * cap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
maxDisp *= COOLING;
|
|
||||||
|
|
||||||
if ((iter + 1) % PRINT_EVERY === 0) {
|
|
||||||
let totalForce = 0;
|
|
||||||
for (const idx of skeletonIndices) {
|
|
||||||
totalForce += Math.sqrt(fx[idx] * fx[idx] + fy[idx] * fy[idx]);
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
` iter ${iter + 1}/${ITERATIONS} max_disp=${maxDisp.toFixed(2)} avg_force=${(totalForce / skeletonIndices.length).toFixed(2)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = performance.now() - t0;
|
|
||||||
console.log(`Skeleton force sim done in ${(elapsed / 1000).toFixed(1)}s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Phase 2: Fill subtrees
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
{
|
|
||||||
const queue: number[] = Array.from(positioned);
|
|
||||||
let head = 0;
|
|
||||||
while (head < queue.length) {
|
|
||||||
const parentId = queue[head++];
|
|
||||||
const parentIdx = idToIdx.get(parentId)!;
|
|
||||||
const kids = childrenOf.get(parentId);
|
|
||||||
|
|
||||||
if (!kids) continue;
|
|
||||||
|
|
||||||
const unpositionedKids = kids.filter(k => !positioned.has(k));
|
|
||||||
if (unpositionedKids.length === 0) continue;
|
|
||||||
|
|
||||||
unpositionedKids.sort((a, b) => subtreeSize.get(b)! - subtreeSize.get(a)!);
|
|
||||||
|
|
||||||
const px = x[parentIdx];
|
|
||||||
const py = y[parentIdx];
|
|
||||||
|
|
||||||
// Determine available angular sector
|
|
||||||
// If parent is SKELETON, we reset to full 360 (local root behavior).
|
|
||||||
// If parent is NORMAL, we strictly use the sector allocated to it by its parent.
|
|
||||||
const isSkeleton = skeleton.has(parentId);
|
|
||||||
let currentAngle = isSkeleton ? 0 : sectorStart[parentIdx];
|
|
||||||
const endAngle = isSkeleton ? 2 * Math.PI : sectorEnd[parentIdx];
|
|
||||||
const totalSpan = endAngle - currentAngle;
|
|
||||||
|
|
||||||
const totalWeight = unpositionedKids.reduce((s, k) => s + subtreeSize.get(k)!, 0);
|
|
||||||
|
|
||||||
for (const kid of unpositionedKids) {
|
|
||||||
const kidIdx = idToIdx.get(kid)!;
|
|
||||||
const w = subtreeSize.get(kid)!;
|
|
||||||
|
|
||||||
// Allocate a portion of the available sector based on subtree weight
|
|
||||||
const span = (w / totalWeight) * totalSpan;
|
|
||||||
|
|
||||||
// Track the sector for this child so ITS children are constrained
|
|
||||||
sectorStart[kidIdx] = currentAngle;
|
|
||||||
sectorEnd[kidIdx] = currentAngle + span;
|
|
||||||
|
|
||||||
const midAngle = currentAngle + span / 2;
|
|
||||||
const r = RADIUS_PER_DEPTH;
|
|
||||||
|
|
||||||
x[kidIdx] = px + r * Math.cos(midAngle);
|
|
||||||
y[kidIdx] = py + r * Math.sin(midAngle);
|
|
||||||
|
|
||||||
positioned.add(kid);
|
|
||||||
queue.push(kid);
|
|
||||||
|
|
||||||
currentAngle += span;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Phase 2: Positioned ${positioned.size} total nodes (of ${N})`);
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Phase 3: Final Relaxation (Force Sim on ALL nodes)
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
{
|
|
||||||
console.log(`Phase 3: Final relaxation on ${N} nodes...`);
|
|
||||||
const FINAL_ITERATIONS = 50;
|
|
||||||
const FINAL_MAX_DISP = 5.0;
|
|
||||||
const BH_THETA = 0.5;
|
|
||||||
|
|
||||||
// We use slightly weaker forces for final polish
|
|
||||||
// Keep repulsion same but limit displacement strongly
|
|
||||||
// Use Barnes-Hut for performance with 10k nodes
|
|
||||||
|
|
||||||
for (let iter = 0; iter < FINAL_ITERATIONS; iter++) {
|
|
||||||
const rootBH = buildBHTree(nodeIds, x, y);
|
|
||||||
const fx = new Float64Array(N);
|
|
||||||
const fy = new Float64Array(N);
|
|
||||||
|
|
||||||
// 1. Repulsion via Barnes-Hut
|
|
||||||
for (let i = 0; i < N; i++) {
|
|
||||||
calcBHForce(rootBH, x[i], y[i], fx, fy, i, BH_THETA);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Attraction edges
|
|
||||||
// Only attract if displacement > rest length?
|
|
||||||
// Standard spring: f = k * (d - L)
|
|
||||||
// L = EDGE_LENGTH for normal, SKELETON_STEP for skeleton?
|
|
||||||
// We can just use standard EDGE_LENGTH as "rest" for everyone to pull tight?
|
|
||||||
// Or respect hierarchy?
|
|
||||||
// With 10k nodes, we just want to relax overlaps.
|
|
||||||
|
|
||||||
for (const [uId, vId] of allEdges) {
|
|
||||||
const u = idToIdx.get(uId)!;
|
|
||||||
const v = idToIdx.get(vId)!;
|
|
||||||
const dx = x[v] - x[u];
|
|
||||||
const dy = y[v] - y[u];
|
|
||||||
const d = Math.sqrt(dx * dx + dy * dy) || MIN_DIST;
|
|
||||||
|
|
||||||
// Identifying if edge is skeletal?
|
|
||||||
// If u and v both skeleton, use longer length.
|
|
||||||
// Else normal length.
|
|
||||||
let restLen = EDGE_LENGTH;
|
|
||||||
let k = ATTRACTION_K;
|
|
||||||
|
|
||||||
if (primaryNodes.has(uId) && primaryNodes.has(vId)) {
|
|
||||||
restLen = SKELETON_STEP;
|
|
||||||
k = ATTRACTION_K / LONG_EDGE_MULTIPLIER;
|
|
||||||
}
|
|
||||||
|
|
||||||
const displacement = d - restLen;
|
|
||||||
const f = k * displacement;
|
|
||||||
const ux = dx / d, uy = dy / d;
|
|
||||||
|
|
||||||
fx[u] += ux * f;
|
|
||||||
fy[u] += uy * f;
|
|
||||||
fx[v] -= ux * f;
|
|
||||||
fy[v] -= uy * f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Apply forces
|
|
||||||
let totalDisp = 0;
|
|
||||||
let maxD = 0;
|
|
||||||
const currentLimit = FINAL_MAX_DISP * (1 - iter / FINAL_ITERATIONS); // Cool down
|
|
||||||
|
|
||||||
for (let i = 0; i < N; i++) {
|
|
||||||
if (nodeIds[i] === root) continue; // Pin root
|
|
||||||
|
|
||||||
const dx = fx[i];
|
|
||||||
const dy = fy[i];
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (dist > 0) {
|
|
||||||
const limit = Math.min(currentLimit, dist);
|
|
||||||
const scale = limit / dist;
|
|
||||||
x[i] += dx * scale;
|
|
||||||
y[i] += dy * scale;
|
|
||||||
totalDisp += limit;
|
|
||||||
maxD = Math.max(maxD, limit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (iter % 10 === 0) {
|
|
||||||
console.log(` Phase 3 iter ${iter}: max movement ${maxD.toFixed(3)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elapsed = performance.now() - t0;
|
||||||
|
console.log(`Force simulation done in ${(elapsed / 1000).toFixed(1)}s`);
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
// Write output
|
// Write output
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// Write node positions
|
|
||||||
const outLines: string[] = ["vertex,x,y"];
|
const outLines: string[] = ["vertex,x,y"];
|
||||||
for (let i = 0; i < N; i++) {
|
for (let i = 0; i < N; i++) {
|
||||||
outLines.push(`${nodeIds[i]},${x[i]},${y[i]}`);
|
outLines.push(`${nodeIds[i]},${x[i]},${y[i]}`);
|
||||||
@@ -487,9 +245,6 @@ for (let i = 0; i < N; i++) {
|
|||||||
const outPath = join(PUBLIC_DIR, "node_positions.csv");
|
const outPath = join(PUBLIC_DIR, "node_positions.csv");
|
||||||
writeFileSync(outPath, outLines.join("\n") + "\n");
|
writeFileSync(outPath, outLines.join("\n") + "\n");
|
||||||
console.log(`Wrote ${N} positions to ${outPath}`);
|
console.log(`Wrote ${N} positions to ${outPath}`);
|
||||||
|
|
||||||
// Edges are provided via primary_edges.csv and secondary_edges.csv generated by generate_tree.ts
|
|
||||||
// We do not write a consolidated edges.csv anymore.
|
|
||||||
console.log(`Layout complete.`);
|
console.log(`Layout complete.`);
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
@@ -498,79 +253,61 @@ console.log(`Layout complete.`);
|
|||||||
|
|
||||||
interface BHNode {
|
interface BHNode {
|
||||||
mass: number;
|
mass: number;
|
||||||
x: number;
|
cx: number;
|
||||||
y: number;
|
cy: number;
|
||||||
minX: number;
|
minX: number;
|
||||||
maxX: number;
|
maxX: number;
|
||||||
minY: number;
|
minY: number;
|
||||||
maxY: number;
|
maxY: number;
|
||||||
children?: BHNode[]; // NW, NE, SW, SE
|
children?: BHNode[];
|
||||||
pointIdx?: number; // Leaf node index
|
pointIdx?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBHTree(indices: number[], x: Float64Array, y: Float64Array): BHNode {
|
function buildBHTree(x: Float64Array, y: Float64Array, n: number): BHNode {
|
||||||
// Determine bounds
|
|
||||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||||
for (let i = 0; i < x.length; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
if (x[i] < minX) minX = x[i];
|
if (x[i] < minX) minX = x[i];
|
||||||
if (x[i] > maxX) maxX = x[i];
|
if (x[i] > maxX) maxX = x[i];
|
||||||
if (y[i] < minY) minY = y[i];
|
if (y[i] < minY) minY = y[i];
|
||||||
if (y[i] > maxY) maxY = y[i];
|
if (y[i] > maxY) maxY = y[i];
|
||||||
}
|
}
|
||||||
// Square bounds for quadtree
|
|
||||||
const cx = (minX + maxX) / 2;
|
const cx = (minX + maxX) / 2;
|
||||||
const cy = (minY + maxY) / 2;
|
const cy = (minY + maxY) / 2;
|
||||||
const halfDim = Math.max(maxX - minX, maxY - minY) / 2 + 0.01;
|
const halfDim = Math.max(maxX - minX, maxY - minY) / 2 + 0.01;
|
||||||
|
|
||||||
const root: BHNode = {
|
const root: BHNode = {
|
||||||
mass: 0, x: 0, y: 0,
|
mass: 0, cx: 0, cy: 0,
|
||||||
minX: cx - halfDim, maxX: cx + halfDim,
|
minX: cx - halfDim, maxX: cx + halfDim,
|
||||||
minY: cy - halfDim, maxY: cy + halfDim
|
minY: cy - halfDim, maxY: cy + halfDim,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < x.length; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
insertBH(root, i, x[i], y[i]);
|
insertBH(root, i, x[i], y[i], x, y);
|
||||||
}
|
}
|
||||||
calcBHMass(root);
|
calcBHMass(root, x, y);
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertBH(node: BHNode, idx: number, px: number, py: number) {
|
function insertBH(node: BHNode, idx: number, px: number, py: number, x: Float64Array, y: Float64Array) {
|
||||||
if (!node.children && node.pointIdx === undefined) {
|
if (!node.children && node.pointIdx === undefined) {
|
||||||
// Empty leaf -> Put point here
|
|
||||||
node.pointIdx = idx;
|
node.pointIdx = idx;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!node.children && node.pointIdx !== undefined) {
|
if (!node.children && node.pointIdx !== undefined) {
|
||||||
// Occupied leaf -> Subdivide
|
|
||||||
const oldIdx = node.pointIdx;
|
const oldIdx = node.pointIdx;
|
||||||
node.pointIdx = undefined;
|
node.pointIdx = undefined;
|
||||||
subdivideBH(node);
|
subdivideBH(node);
|
||||||
// Re-insert old point and new point
|
insertBH(node, oldIdx, x[oldIdx], y[oldIdx], x, y);
|
||||||
// Note: oldIdx needs x,y. But we don't pass array. Wait, BHTree function scope?
|
|
||||||
// We need explicit x,y access. But passing array everywhere is ugly.
|
|
||||||
// Hack: The recursive function needs access to global x/y or passed in values.
|
|
||||||
// But here we are inserting one by one.
|
|
||||||
// Wait, to re-insert oldIdx, WE NEED ITS COORDS.
|
|
||||||
// This simple 'insertBH' signature is insufficient unless we capture x/y closure or pass them.
|
|
||||||
// Let's assume x, y are available globally or we redesign.
|
|
||||||
// Since this script is top-level, x and y are available in scope!
|
|
||||||
// But `insertBH` is defined outside main scope if hoisted? No, it's inside module.
|
|
||||||
// If defined as function `function insertBH`, it captures module scope `x`, `y`?
|
|
||||||
// `x` and `y` are const Float64Array defined at line ~120.
|
|
||||||
// So yes, they are captured!
|
|
||||||
insertBH(node, oldIdx, x[oldIdx], y[oldIdx]);
|
|
||||||
// Then fall through to insert new point
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.children) {
|
if (node.children) {
|
||||||
const mx = (node.minX + node.maxX) / 2;
|
const mx = (node.minX + node.maxX) / 2;
|
||||||
const my = (node.minY + node.maxY) / 2;
|
const my = (node.minY + node.maxY) / 2;
|
||||||
let q = 0;
|
let q = 0;
|
||||||
if (px > mx) q += 1; // East
|
if (px > mx) q += 1;
|
||||||
if (py > my) q += 2; // South
|
if (py > my) q += 2;
|
||||||
insertBH(node.children[q], idx, px, py);
|
insertBH(node.children[q], idx, px, py, x, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,62 +315,62 @@ function subdivideBH(node: BHNode) {
|
|||||||
const mx = (node.minX + node.maxX) / 2;
|
const mx = (node.minX + node.maxX) / 2;
|
||||||
const my = (node.minY + node.maxY) / 2;
|
const my = (node.minY + node.maxY) / 2;
|
||||||
node.children = [
|
node.children = [
|
||||||
{ mass: 0, x: 0, y: 0, minX: node.minX, maxX: mx, minY: node.minY, maxY: my }, // NW
|
{ mass: 0, cx: 0, cy: 0, minX: node.minX, maxX: mx, minY: node.minY, maxY: my },
|
||||||
{ mass: 0, x: 0, y: 0, minX: mx, maxX: node.maxX, minY: node.minY, maxY: my }, // NE
|
{ mass: 0, cx: 0, cy: 0, minX: mx, maxX: node.maxX, minY: node.minY, maxY: my },
|
||||||
{ mass: 0, x: 0, y: 0, minX: node.minX, maxX: mx, minY: my, maxY: node.maxY }, // SW
|
{ mass: 0, cx: 0, cy: 0, minX: node.minX, maxX: mx, minY: my, maxY: node.maxY },
|
||||||
{ mass: 0, x: 0, y: 0, minX: mx, maxX: node.maxX, minY: my, maxY: node.maxY } // SE
|
{ mass: 0, cx: 0, cy: 0, minX: mx, maxX: node.maxX, minY: my, maxY: node.maxY },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function calcBHMass(node: BHNode) {
|
function calcBHMass(node: BHNode, x: Float64Array, y: Float64Array) {
|
||||||
if (node.pointIdx !== undefined) {
|
if (node.pointIdx !== undefined) {
|
||||||
node.mass = 1;
|
node.mass = 1;
|
||||||
node.x = x[node.pointIdx];
|
node.cx = x[node.pointIdx];
|
||||||
node.y = y[node.pointIdx];
|
node.cy = y[node.pointIdx];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (node.children) {
|
if (node.children) {
|
||||||
let m = 0, cx = 0, cy = 0;
|
let m = 0, sx = 0, sy = 0;
|
||||||
for (const c of node.children) {
|
for (const c of node.children) {
|
||||||
calcBHMass(c);
|
calcBHMass(c, x, y);
|
||||||
m += c.mass;
|
m += c.mass;
|
||||||
cx += c.x * c.mass;
|
sx += c.cx * c.mass;
|
||||||
cy += c.y * c.mass;
|
sy += c.cy * c.mass;
|
||||||
}
|
}
|
||||||
node.mass = m;
|
node.mass = m;
|
||||||
if (m > 0) {
|
if (m > 0) {
|
||||||
node.x = cx / m;
|
node.cx = sx / m;
|
||||||
node.y = cy / m;
|
node.cy = sy / m;
|
||||||
} else {
|
} else {
|
||||||
// Center of box if empty
|
node.cx = (node.minX + node.maxX) / 2;
|
||||||
node.x = (node.minX + node.maxX) / 2;
|
node.cy = (node.minY + node.maxY) / 2;
|
||||||
node.y = (node.minY + node.maxY) / 2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function calcBHForce(node: BHNode, px: number, py: number, fx: Float64Array, fy: Float64Array, idx: number, theta: number) {
|
function calcBHForce(
|
||||||
const dx = px - node.x;
|
node: BHNode,
|
||||||
const dy = py - node.y;
|
px: number, py: number,
|
||||||
|
fx: Float64Array, fy: Float64Array,
|
||||||
|
idx: number, theta: number,
|
||||||
|
x: Float64Array, y: Float64Array,
|
||||||
|
) {
|
||||||
|
const dx = px - node.cx;
|
||||||
|
const dy = py - node.cy;
|
||||||
const d2 = dx * dx + dy * dy;
|
const d2 = dx * dx + dy * dy;
|
||||||
const dist = Math.sqrt(d2);
|
const dist = Math.sqrt(d2);
|
||||||
const width = node.maxX - node.minX;
|
const width = node.maxX - node.minX;
|
||||||
|
|
||||||
if (width / dist < theta || !node.children) {
|
if (width / dist < theta || !node.children) {
|
||||||
// Treat as single body
|
if (node.mass > 0 && node.pointIdx !== idx) {
|
||||||
if (node.mass > 0 && (node.pointIdx !== idx)) {
|
|
||||||
// Apply repulsion
|
|
||||||
// F = K * mass / dist^2
|
|
||||||
// Direction: from node to p
|
|
||||||
const dEff = Math.max(dist, MIN_DIST);
|
const dEff = Math.max(dist, MIN_DIST);
|
||||||
const f = (REPULSION_K * node.mass) / (dEff * dEff); // d^2 repulsion
|
const f = (REPULSION_K * node.mass) / (dEff * dEff);
|
||||||
fx[idx] += (dx / dEff) * f;
|
fx[idx] += (dx / dEff) * f;
|
||||||
fy[idx] += (dy / dEff) * f;
|
fy[idx] += (dy / dEff) * f;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Recurse
|
|
||||||
for (const c of node.children) {
|
for (const c of node.children) {
|
||||||
calcBHForce(c, px, py, fx, fy, idx, theta);
|
calcBHForce(c, px, py, fx, fy, idx, theta, x, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
390
scripts/fetch_from_db.ts
Normal file
390
scripts/fetch_from_db.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* Fetch RDF Data from AnzoGraph DB
|
||||||
|
*
|
||||||
|
* 1. Query the first 1000 distinct subject URIs
|
||||||
|
* 2. Fetch all triples where those URIs appear as subject or object
|
||||||
|
* 3. Identify primary nodes (objects of rdf:type)
|
||||||
|
* 4. Write primary_edges.csv, secondary_edges.csv, and uri_map.csv
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/fetch_from_db.ts [--host http://localhost:8080]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync } from "fs";
|
||||||
|
import { join, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PUBLIC_DIR = join(__dirname, "..", "public");
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Configuration
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||||
|
const BATCH_SIZE = 100; // URIs per VALUES batch query
|
||||||
|
const MAX_RETRIES = 30; // Wait up to ~120s for AnzoGraph to start
|
||||||
|
const RETRY_DELAY_MS = 4000;
|
||||||
|
|
||||||
|
// Path to TTL file inside the AnzoGraph container (mapped via docker-compose volume)
|
||||||
|
const DATA_FILE = process.env.SPARQL_DATA_FILE || "file:///opt/shared-files/vkg-materialized.ttl";
|
||||||
|
|
||||||
|
// Parse --host flag, default to http://localhost:8080
|
||||||
|
function getEndpoint(): string {
|
||||||
|
const hostIdx = process.argv.indexOf("--host");
|
||||||
|
if (hostIdx !== -1 && process.argv[hostIdx + 1]) {
|
||||||
|
return process.argv[hostIdx + 1];
|
||||||
|
}
|
||||||
|
// Inside Docker, use service name; otherwise localhost
|
||||||
|
return process.env.SPARQL_HOST || "http://localhost:8080";
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPARQL_ENDPOINT = `${getEndpoint()}/sparql`;
|
||||||
|
|
||||||
|
// Auth credentials (AnzoGraph defaults)
|
||||||
|
const SPARQL_USER = process.env.SPARQL_USER || "admin";
|
||||||
|
const SPARQL_PASS = process.env.SPARQL_PASS || "Passw0rd1";
|
||||||
|
const AUTH_HEADER = "Basic " + Buffer.from(`${SPARQL_USER}:${SPARQL_PASS}`).toString("base64");
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// SPARQL helpers
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface SparqlBinding {
|
||||||
|
[key: string]: { type: string; value: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sparqlQuery(query: string, retries = 5): Promise<SparqlBinding[]> {
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 300_000); // 5 min timeout
|
||||||
|
|
||||||
|
try {
|
||||||
|
const t0 = performance.now();
|
||||||
|
const response = await fetch(SPARQL_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Accept": "application/sparql-results+json",
|
||||||
|
"Authorization": AUTH_HEADER,
|
||||||
|
},
|
||||||
|
body: "query=" + encodeURIComponent(query),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const t1 = performance.now();
|
||||||
|
console.log(` [sparql] response status=${response.status} in ${((t1 - t0) / 1000).toFixed(1)}s`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`SPARQL query failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const t2 = performance.now();
|
||||||
|
console.log(` [sparql] body read (${(text.length / 1024).toFixed(0)} KB) in ${((t2 - t1) / 1000).toFixed(1)}s`);
|
||||||
|
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
return json.results.bindings;
|
||||||
|
} catch (err: any) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
const isTransient = msg.includes("fetch failed") || msg.includes("Timeout") || msg.includes("ABORT") || msg.includes("abort");
|
||||||
|
if (isTransient && attempt < retries) {
|
||||||
|
console.log(` [sparql] transient error (attempt ${attempt}/${retries}): ${msg.substring(0, 100)}`);
|
||||||
|
console.log(` [sparql] retrying in 10s (AnzoGraph may still be indexing after LOAD)...`);
|
||||||
|
await sleep(10_000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("sparqlQuery: should not reach here");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAnzoGraph(): Promise<void> {
|
||||||
|
console.log(`Waiting for AnzoGraph at ${SPARQL_ENDPOINT}...`);
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(SPARQL_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Accept": "application/sparql-results+json",
|
||||||
|
"Authorization": AUTH_HEADER,
|
||||||
|
},
|
||||||
|
body: "query=" + encodeURIComponent("ASK WHERE { ?s ?p ?o }"),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
// Verify it's actual JSON (not a plain-text error from a half-started engine)
|
||||||
|
JSON.parse(text);
|
||||||
|
console.log(` AnzoGraph is ready (attempt ${attempt})`);
|
||||||
|
return;
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.log(` Attempt ${attempt}/${MAX_RETRIES}: ${msg.substring(0, 100)}`);
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
await sleep(RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`AnzoGraph not available after ${MAX_RETRIES} attempts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sparqlUpdate(update: string): Promise<string> {
|
||||||
|
const response = await fetch(SPARQL_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/sparql-update",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": AUTH_HEADER,
|
||||||
|
},
|
||||||
|
body: update,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`SPARQL update failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData(): Promise<void> {
|
||||||
|
console.log(`Loading data from ${DATA_FILE}...`);
|
||||||
|
const t0 = performance.now();
|
||||||
|
const result = await sparqlUpdate(`LOAD <${DATA_FILE}>`);
|
||||||
|
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
||||||
|
console.log(` Load complete in ${elapsed}s: ${result.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Step 1: Fetch seed URIs
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function fetchSeedURIs(): Promise<string[]> {
|
||||||
|
console.log("Querying first 1000 distinct subject URIs...");
|
||||||
|
const t0 = performance.now();
|
||||||
|
const query = `
|
||||||
|
SELECT DISTINCT ?s
|
||||||
|
WHERE { ?s ?p ?o }
|
||||||
|
LIMIT 1000
|
||||||
|
`;
|
||||||
|
const bindings = await sparqlQuery(query);
|
||||||
|
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
||||||
|
const uris = bindings.map((b) => b.s.value);
|
||||||
|
console.log(` Got ${uris.length} seed URIs in ${elapsed}s`);
|
||||||
|
return uris;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Step 2: Fetch all triples involving seed URIs
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface Triple {
|
||||||
|
s: string;
|
||||||
|
p: string;
|
||||||
|
o: string;
|
||||||
|
oType: string; // "uri" or "literal"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTriples(seedURIs: string[]): Promise<Triple[]> {
|
||||||
|
console.log(`Fetching triples for ${seedURIs.length} seed URIs (batch size: ${BATCH_SIZE})...`);
|
||||||
|
const allTriples: Triple[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < seedURIs.length; i += BATCH_SIZE) {
|
||||||
|
const batch = seedURIs.slice(i, i + BATCH_SIZE);
|
||||||
|
const valuesClause = batch.map((u) => `<${u}>`).join(" ");
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT ?s ?p ?o
|
||||||
|
WHERE {
|
||||||
|
VALUES ?uri { ${valuesClause} }
|
||||||
|
{
|
||||||
|
?uri ?p ?o .
|
||||||
|
BIND(?uri AS ?s)
|
||||||
|
}
|
||||||
|
UNION
|
||||||
|
{
|
||||||
|
?s ?p ?uri .
|
||||||
|
BIND(?uri AS ?o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const bindings = await sparqlQuery(query);
|
||||||
|
for (const b of bindings) {
|
||||||
|
allTriples.push({
|
||||||
|
s: b.s.value,
|
||||||
|
p: b.p.value,
|
||||||
|
o: b.o.value,
|
||||||
|
oType: b.o.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = Math.min(i + BATCH_SIZE, seedURIs.length);
|
||||||
|
process.stdout.write(`\r Fetched triples: batch ${Math.ceil(progress / BATCH_SIZE)}/${Math.ceil(seedURIs.length / BATCH_SIZE)} (${allTriples.length} triples so far)`);
|
||||||
|
}
|
||||||
|
console.log(`\n Total triples: ${allTriples.length}`);
|
||||||
|
return allTriples;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Step 3: Build graph data
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface GraphData {
|
||||||
|
nodeURIs: string[]; // All unique URIs (subjects & objects that are URIs)
|
||||||
|
uriToId: Map<string, number>;
|
||||||
|
primaryNodeIds: Set<number>; // Nodes that are objects of rdf:type
|
||||||
|
edges: Array<[number, number]>; // [source, target] as numeric IDs
|
||||||
|
primaryEdges: Array<[number, number]>;
|
||||||
|
secondaryEdges: Array<[number, number]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGraphData(triples: Triple[]): GraphData {
|
||||||
|
console.log("Building graph data...");
|
||||||
|
|
||||||
|
// Collect all unique URI nodes (skip literal objects)
|
||||||
|
const uriSet = new Set<string>();
|
||||||
|
for (const t of triples) {
|
||||||
|
uriSet.add(t.s);
|
||||||
|
if (t.oType === "uri") {
|
||||||
|
uriSet.add(t.o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign numeric IDs
|
||||||
|
const nodeURIs = Array.from(uriSet).sort();
|
||||||
|
const uriToId = new Map<string, number>();
|
||||||
|
nodeURIs.forEach((uri, idx) => uriToId.set(uri, idx));
|
||||||
|
|
||||||
|
// Identify primary nodes: objects of rdf:type triples
|
||||||
|
const primaryNodeIds = new Set<number>();
|
||||||
|
for (const t of triples) {
|
||||||
|
if (t.p === RDF_TYPE && t.oType === "uri") {
|
||||||
|
const objId = uriToId.get(t.o);
|
||||||
|
if (objId !== undefined) {
|
||||||
|
primaryNodeIds.add(objId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build edges (only between URI nodes, skip literal objects)
|
||||||
|
const edgeSet = new Set<string>();
|
||||||
|
const edges: Array<[number, number]> = [];
|
||||||
|
for (const t of triples) {
|
||||||
|
if (t.oType !== "uri") continue;
|
||||||
|
const srcId = uriToId.get(t.s);
|
||||||
|
const dstId = uriToId.get(t.o);
|
||||||
|
if (srcId === undefined || dstId === undefined) continue;
|
||||||
|
if (srcId === dstId) continue; // Skip self-loops
|
||||||
|
|
||||||
|
const key = `${srcId},${dstId}`;
|
||||||
|
if (edgeSet.has(key)) continue; // Deduplicate
|
||||||
|
edgeSet.add(key);
|
||||||
|
edges.push([srcId, dstId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify edges into primary (touches a primary node) and secondary
|
||||||
|
const primaryEdges: Array<[number, number]> = [];
|
||||||
|
const secondaryEdges: Array<[number, number]> = [];
|
||||||
|
for (const [src, dst] of edges) {
|
||||||
|
if (primaryNodeIds.has(src) || primaryNodeIds.has(dst)) {
|
||||||
|
primaryEdges.push([src, dst]);
|
||||||
|
} else {
|
||||||
|
secondaryEdges.push([src, dst]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Nodes: ${nodeURIs.length}`);
|
||||||
|
console.log(` Primary nodes (rdf:type objects): ${primaryNodeIds.size}`);
|
||||||
|
console.log(` Edges: ${edges.length} (primary: ${primaryEdges.length}, secondary: ${secondaryEdges.length})`);
|
||||||
|
|
||||||
|
return { nodeURIs, uriToId, primaryNodeIds, edges, primaryEdges, secondaryEdges };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Step 4: Write CSV files
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function extractLabel(uri: string): string {
|
||||||
|
// Extract local name: after # or last /
|
||||||
|
const hashIdx = uri.lastIndexOf("#");
|
||||||
|
if (hashIdx !== -1) return uri.substring(hashIdx + 1);
|
||||||
|
const slashIdx = uri.lastIndexOf("/");
|
||||||
|
if (slashIdx !== -1) return uri.substring(slashIdx + 1);
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCSVs(data: GraphData): void {
|
||||||
|
// Write primary_edges.csv
|
||||||
|
const pLines = ["source,target"];
|
||||||
|
for (const [src, dst] of data.primaryEdges) {
|
||||||
|
pLines.push(`${src},${dst}`);
|
||||||
|
}
|
||||||
|
const pPath = join(PUBLIC_DIR, "primary_edges.csv");
|
||||||
|
writeFileSync(pPath, pLines.join("\n") + "\n");
|
||||||
|
console.log(`Wrote ${data.primaryEdges.length} primary edges to ${pPath}`);
|
||||||
|
|
||||||
|
// Write secondary_edges.csv
|
||||||
|
const sLines = ["source,target"];
|
||||||
|
for (const [src, dst] of data.secondaryEdges) {
|
||||||
|
sLines.push(`${src},${dst}`);
|
||||||
|
}
|
||||||
|
const sPath = join(PUBLIC_DIR, "secondary_edges.csv");
|
||||||
|
writeFileSync(sPath, sLines.join("\n") + "\n");
|
||||||
|
console.log(`Wrote ${data.secondaryEdges.length} secondary edges to ${sPath}`);
|
||||||
|
|
||||||
|
// Write uri_map.csv (id,uri,label,isPrimary)
|
||||||
|
const uLines = ["id,uri,label,isPrimary"];
|
||||||
|
for (let i = 0; i < data.nodeURIs.length; i++) {
|
||||||
|
const uri = data.nodeURIs[i];
|
||||||
|
const label = extractLabel(uri);
|
||||||
|
const isPrimary = data.primaryNodeIds.has(i) ? "1" : "0";
|
||||||
|
// Escape commas in URIs by quoting
|
||||||
|
const safeUri = uri.includes(",") ? `"${uri}"` : uri;
|
||||||
|
const safeLabel = label.includes(",") ? `"${label}"` : label;
|
||||||
|
uLines.push(`${i},${safeUri},${safeLabel},${isPrimary}`);
|
||||||
|
}
|
||||||
|
const uPath = join(PUBLIC_DIR, "uri_map.csv");
|
||||||
|
writeFileSync(uPath, uLines.join("\n") + "\n");
|
||||||
|
console.log(`Wrote ${data.nodeURIs.length} URI mappings to ${uPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Main
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`SPARQL endpoint: ${SPARQL_ENDPOINT}`);
|
||||||
|
const t0 = performance.now();
|
||||||
|
|
||||||
|
await waitForAnzoGraph();
|
||||||
|
await loadData();
|
||||||
|
|
||||||
|
// Smoke test: simplest possible query to verify connectivity
|
||||||
|
console.log("Smoke test: SELECT ?s ?p ?o LIMIT 3...");
|
||||||
|
const smokeT0 = performance.now();
|
||||||
|
const smokeResult = await sparqlQuery("SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 3");
|
||||||
|
const smokeElapsed = ((performance.now() - smokeT0) / 1000).toFixed(1);
|
||||||
|
console.log(` Smoke test OK: ${smokeResult.length} results in ${smokeElapsed}s`);
|
||||||
|
if (smokeResult.length > 0) {
|
||||||
|
console.log(` First triple: ${smokeResult[0].s.value} ${smokeResult[0].p.value} ${smokeResult[0].o.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedURIs = await fetchSeedURIs();
|
||||||
|
const triples = await fetchTriples(seedURIs);
|
||||||
|
const graphData = buildGraphData(triples);
|
||||||
|
writeCSVs(graphData);
|
||||||
|
|
||||||
|
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
||||||
|
console.log(`\nDone in ${elapsed}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Fatal error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -20,7 +20,7 @@ const PUBLIC_DIR = join(__dirname, "..", "public");
|
|||||||
|
|
||||||
const TARGET_NODES = 10000; // Approximate number of nodes to generate
|
const TARGET_NODES = 10000; // Approximate number of nodes to generate
|
||||||
const MAX_CHILDREN = 4; // Each node gets 1..MAX_CHILDREN children
|
const MAX_CHILDREN = 4; // Each node gets 1..MAX_CHILDREN children
|
||||||
const PRIMARY_DEPTH = 3; // Nodes at depth ≤ this form the primary skeleton
|
const PRIMARY_DEPTH = 4; // Nodes at depth ≤ this form the primary skeleton
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
// Tree data types
|
// Tree data types
|
||||||
|
|||||||
47
src/App.tsx
47
src/App.tsx
@@ -6,6 +6,7 @@ export default function App() {
|
|||||||
const rendererRef = useRef<Renderer | null>(null);
|
const rendererRef = useRef<Renderer | null>(null);
|
||||||
const [status, setStatus] = useState("Loading node positions…");
|
const [status, setStatus] = useState("Loading node positions…");
|
||||||
const [nodeCount, setNodeCount] = useState(0);
|
const [nodeCount, setNodeCount] = useState(0);
|
||||||
|
const uriMapRef = useRef<Map<number, { uri: string; label: string; isPrimary: boolean }>>(new Map());
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
fps: 0,
|
fps: 0,
|
||||||
drawn: 0,
|
drawn: 0,
|
||||||
@@ -14,7 +15,7 @@ export default function App() {
|
|||||||
ptSize: 0,
|
ptSize: 0,
|
||||||
});
|
});
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [hoveredNode, setHoveredNode] = useState<{ x: number; y: number; screenX: number; screenY: number } | null>(null);
|
const [hoveredNode, setHoveredNode] = useState<{ x: number; y: number; screenX: number; screenY: number; index?: number } | null>(null);
|
||||||
const [selectedNodes, setSelectedNodes] = useState<Set<number>>(new Set());
|
const [selectedNodes, setSelectedNodes] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// Store mouse position in a ref so it can be accessed in render loop without re-renders
|
// Store mouse position in a ref so it can be accessed in render loop without re-renders
|
||||||
@@ -39,19 +40,21 @@ export default function App() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
setStatus("Fetching data files…");
|
setStatus("Fetching data files…");
|
||||||
const [nodesResponse, primaryEdgesResponse, secondaryEdgesResponse] = await Promise.all([
|
const [nodesResponse, primaryEdgesResponse, secondaryEdgesResponse, uriMapResponse] = await Promise.all([
|
||||||
fetch("/node_positions.csv"),
|
fetch("/node_positions.csv"),
|
||||||
fetch("/primary_edges.csv"),
|
fetch("/primary_edges.csv"),
|
||||||
fetch("/secondary_edges.csv"),
|
fetch("/secondary_edges.csv"),
|
||||||
|
fetch("/uri_map.csv"),
|
||||||
]);
|
]);
|
||||||
if (!nodesResponse.ok) throw new Error(`Failed to fetch nodes: ${nodesResponse.status}`);
|
if (!nodesResponse.ok) throw new Error(`Failed to fetch nodes: ${nodesResponse.status}`);
|
||||||
if (!primaryEdgesResponse.ok) throw new Error(`Failed to fetch primary edges: ${primaryEdgesResponse.status}`);
|
if (!primaryEdgesResponse.ok) throw new Error(`Failed to fetch primary edges: ${primaryEdgesResponse.status}`);
|
||||||
if (!secondaryEdgesResponse.ok) throw new Error(`Failed to fetch secondary edges: ${secondaryEdgesResponse.status}`);
|
if (!secondaryEdgesResponse.ok) throw new Error(`Failed to fetch secondary edges: ${secondaryEdgesResponse.status}`);
|
||||||
|
|
||||||
const [nodesText, primaryEdgesText, secondaryEdgesText] = await Promise.all([
|
const [nodesText, primaryEdgesText, secondaryEdgesText, uriMapText] = await Promise.all([
|
||||||
nodesResponse.text(),
|
nodesResponse.text(),
|
||||||
primaryEdgesResponse.text(),
|
primaryEdgesResponse.text(),
|
||||||
secondaryEdgesResponse.text(),
|
secondaryEdgesResponse.text(),
|
||||||
|
uriMapResponse.ok ? uriMapResponse.text() : Promise.resolve(""),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
@@ -90,6 +93,21 @@ export default function App() {
|
|||||||
edgeData[idx++] = parseInt(parts[1], 10);
|
edgeData[idx++] = parseInt(parts[1], 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse URI map if available
|
||||||
|
if (uriMapText) {
|
||||||
|
const uriLines = uriMapText.split("\n").slice(1).filter(l => l.trim().length > 0);
|
||||||
|
for (const line of uriLines) {
|
||||||
|
const parts = line.split(",");
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
const id = parseInt(parts[0], 10);
|
||||||
|
const uri = parts[1];
|
||||||
|
const label = parts[2];
|
||||||
|
const isPrimary = parts[3].trim() === "1";
|
||||||
|
uriMapRef.current.set(id, { uri, label, isPrimary });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
setStatus("Building spatial index…");
|
setStatus("Building spatial index…");
|
||||||
@@ -182,9 +200,9 @@ export default function App() {
|
|||||||
frameCount++;
|
frameCount++;
|
||||||
|
|
||||||
// Find hovered node using quadtree
|
// Find hovered node using quadtree
|
||||||
const node = renderer.findNodeAt(mousePos.current.x, mousePos.current.y);
|
const nodeResult = renderer.findNodeIndexAt(mousePos.current.x, mousePos.current.y);
|
||||||
if (node) {
|
if (nodeResult) {
|
||||||
setHoveredNode({ ...node, screenX: mousePos.current.x, screenY: mousePos.current.y });
|
setHoveredNode({ x: nodeResult.x, y: nodeResult.y, screenX: mousePos.current.x, screenY: mousePos.current.y, index: nodeResult.index });
|
||||||
} else {
|
} else {
|
||||||
setHoveredNode(null);
|
setHoveredNode(null);
|
||||||
}
|
}
|
||||||
@@ -331,7 +349,22 @@ export default function App() {
|
|||||||
boxShadow: "0 2px 8px rgba(0,0,0,0.5)",
|
boxShadow: "0 2px 8px rgba(0,0,0,0.5)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
|
{(() => {
|
||||||
|
if (hoveredNode.index !== undefined && rendererRef.current) {
|
||||||
|
const vertexId = rendererRef.current.getVertexId(hoveredNode.index);
|
||||||
|
const info = vertexId !== undefined ? uriMapRef.current.get(vertexId) : undefined;
|
||||||
|
if (info) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ fontWeight: "bold", marginBottom: 2 }}>{info.label}</div>
|
||||||
|
<div style={{ fontSize: "10px", color: "#8cf", wordBreak: "break-all", maxWidth: 400 }}>{info.uri}</div>
|
||||||
|
{info.isPrimary && <div style={{ color: "#ff0", fontSize: "10px", marginTop: 2 }}>⭐ Primary (rdf:type)</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <>({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})</>;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export class Renderer {
|
|||||||
private nodeCount = 0;
|
private nodeCount = 0;
|
||||||
private edgeCount = 0;
|
private edgeCount = 0;
|
||||||
private neighborMap: Map<number, number[]> = new Map();
|
private neighborMap: Map<number, number[]> = new Map();
|
||||||
|
private sortedToVertexId: Uint32Array = new Uint32Array(0);
|
||||||
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
|
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
|
||||||
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
|
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
|
||||||
private maxPtSize = 256;
|
private maxPtSize = 256;
|
||||||
@@ -213,6 +214,12 @@ export class Renderer {
|
|||||||
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
|
||||||
gl.bindVertexArray(null);
|
gl.bindVertexArray(null);
|
||||||
|
|
||||||
|
// Build sorted index → vertex ID mapping for hover lookups
|
||||||
|
this.sortedToVertexId = new Uint32Array(count);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
this.sortedToVertexId[i] = vertexIds[order[i]];
|
||||||
|
}
|
||||||
|
|
||||||
// Build vertex ID → original input index mapping
|
// Build vertex ID → original input index mapping
|
||||||
const vertexIdToOriginal = new Map<number, number>();
|
const vertexIdToOriginal = new Map<number, number>();
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -331,6 +338,15 @@ export class Renderer {
|
|||||||
return this.nodeCount;
|
return this.nodeCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the original vertex ID for a given sorted index.
|
||||||
|
* Useful for looking up URI labels from the URI map.
|
||||||
|
*/
|
||||||
|
getVertexId(sortedIndex: number): number | undefined {
|
||||||
|
if (sortedIndex < 0 || sortedIndex >= this.sortedToVertexId.length) return undefined;
|
||||||
|
return this.sortedToVertexId[sortedIndex];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert screen coordinates (CSS pixels) to world coordinates.
|
* Convert screen coordinates (CSS pixels) to world coordinates.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user