Files
visualizador_instanciados/src/App.tsx
2026-02-13 16:39:41 -03:00

375 lines
12 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Renderer } from "./renderer";
export default function App() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rendererRef = useRef<Renderer | null>(null);
const [status, setStatus] = useState("Loading node positions…");
const [nodeCount, setNodeCount] = useState(0);
const uriMapRef = useRef<Map<number, { uri: string; label: string; isPrimary: boolean }>>(new Map());
const [stats, setStats] = useState({
fps: 0,
drawn: 0,
mode: "",
zoom: 0,
ptSize: 0,
});
const [error, setError] = useState("");
const [hoveredNode, setHoveredNode] = useState<{ x: number; y: number; screenX: number; screenY: number; index?: number } | null>(null);
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
const mousePos = useRef({ x: 0, y: 0 });
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let renderer: Renderer;
try {
renderer = new Renderer(canvas);
rendererRef.current = renderer;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
return;
}
let cancelled = false;
// Fetch CSVs, parse, and init renderer
(async () => {
try {
setStatus("Fetching data files…");
const [nodesResponse, primaryEdgesResponse, secondaryEdgesResponse, uriMapResponse] = await Promise.all([
fetch("/node_positions.csv"),
fetch("/primary_edges.csv"),
fetch("/secondary_edges.csv"),
fetch("/uri_map.csv"),
]);
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 (!secondaryEdgesResponse.ok) throw new Error(`Failed to fetch secondary edges: ${secondaryEdgesResponse.status}`);
const [nodesText, primaryEdgesText, secondaryEdgesText, uriMapText] = await Promise.all([
nodesResponse.text(),
primaryEdgesResponse.text(),
secondaryEdgesResponse.text(),
uriMapResponse.ok ? uriMapResponse.text() : Promise.resolve(""),
]);
if (cancelled) return;
setStatus("Parsing positions…");
const nodeLines = nodesText.split("\n").slice(1).filter(l => l.trim().length > 0);
const count = nodeLines.length;
const xs = new Float32Array(count);
const ys = new Float32Array(count);
const vertexIds = new Uint32Array(count);
for (let i = 0; i < count; i++) {
const parts = nodeLines[i].split(",");
vertexIds[i] = parseInt(parts[0], 10);
xs[i] = parseFloat(parts[1]);
ys[i] = parseFloat(parts[2]);
}
setStatus("Parsing edges…");
const pLines = primaryEdgesText.split("\n").slice(1).filter(l => l.trim().length > 0);
const sLines = secondaryEdgesText.split("\n").slice(1).filter(l => l.trim().length > 0);
const totalEdges = pLines.length + sLines.length;
const edgeData = new Uint32Array(totalEdges * 2);
let idx = 0;
// Parse primary
for (let i = 0; i < pLines.length; i++) {
const parts = pLines[i].split(",");
edgeData[idx++] = parseInt(parts[0], 10);
edgeData[idx++] = parseInt(parts[1], 10);
}
// Parse secondary
for (let i = 0; i < sLines.length; i++) {
const parts = sLines[i].split(",");
edgeData[idx++] = parseInt(parts[0], 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;
setStatus("Building spatial index…");
await new Promise(r => setTimeout(r, 0));
const buildMs = renderer.init(xs, ys, vertexIds, edgeData);
setNodeCount(renderer.getNodeCount());
setStatus("");
console.log(`Init complete: ${count.toLocaleString()} nodes, ${totalEdges.toLocaleString()} edges in ${buildMs.toFixed(0)}ms`);
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : String(e));
}
}
})();
// ── Input handling ──
let dragging = false;
let didDrag = false; // true if mouse moved significantly during drag
let downX = 0;
let downY = 0;
let lastX = 0;
let lastY = 0;
const DRAG_THRESHOLD = 5; // pixels
const onDown = (e: MouseEvent) => {
dragging = true;
didDrag = false;
downX = e.clientX;
downY = e.clientY;
lastX = e.clientX;
lastY = e.clientY;
};
const onMove = (e: MouseEvent) => {
mousePos.current = { x: e.clientX, y: e.clientY };
if (!dragging) return;
// Check if we've moved enough to consider it a drag
const dx = e.clientX - downX;
const dy = e.clientY - downY;
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
didDrag = true;
}
renderer.pan(e.clientX - lastX, e.clientY - lastY);
lastX = e.clientX;
lastY = e.clientY;
};
const onUp = (e: MouseEvent) => {
if (dragging && !didDrag) {
// This was a click, not a drag - handle selection
const node = renderer.findNodeIndexAt(e.clientX, e.clientY);
if (node) {
setSelectedNodes((prev: Set<number>) => {
const next = new Set(prev);
if (next.has(node.index)) {
next.delete(node.index); // Deselect if already selected
} else {
next.add(node.index); // Select
}
return next;
});
}
}
dragging = false;
didDrag = false;
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1 / 0.9;
renderer.zoomAt(factor, e.clientX, e.clientY);
};
const onMouseLeave = () => {
setHoveredNode(null);
};
canvas.addEventListener("mousedown", onDown);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
canvas.addEventListener("wheel", onWheel, { passive: false });
canvas.addEventListener("mouseleave", onMouseLeave);
// ── Render loop ──
let frameCount = 0;
let lastTime = performance.now();
let raf = 0;
const frame = () => {
const result = renderer.render();
frameCount++;
// Find hovered node using quadtree
const nodeResult = renderer.findNodeIndexAt(mousePos.current.x, mousePos.current.y);
if (nodeResult) {
setHoveredNode({ x: nodeResult.x, y: nodeResult.y, screenX: mousePos.current.x, screenY: mousePos.current.y, index: nodeResult.index });
} else {
setHoveredNode(null);
}
const now = performance.now();
if (now - lastTime >= 500) {
const fps = (frameCount / (now - lastTime)) * 1000;
setStats({
fps: Math.round(fps),
drawn: result.drawnCount,
mode: result.mode,
zoom: result.zoom,
ptSize: result.ptSize,
});
frameCount = 0;
lastTime = now;
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => {
cancelled = true;
cancelAnimationFrame(raf);
canvas.removeEventListener("mousedown", onDown);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
canvas.removeEventListener("wheel", onWheel);
canvas.removeEventListener("mouseleave", onMouseLeave);
};
}, []);
// Sync selection state to renderer
useEffect(() => {
if (rendererRef.current) {
rendererRef.current.updateSelection(selectedNodes);
}
}, [selectedNodes]);
return (
<div style={{ width: "100vw", height: "100vh", overflow: "hidden", background: "#000" }}>
<canvas
ref={canvasRef}
style={{ display: "block", width: "100%", height: "100%" }}
/>
{/* Loading overlay */}
{status && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.9)",
color: "#0f0",
fontFamily: "monospace",
fontSize: "16px",
}}
>
{status}
</div>
)}
{/* Error overlay */}
{error && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.9)",
color: "#f44",
fontFamily: "monospace",
fontSize: "16px",
}}
>
Error: {error}
</div>
)}
{/* HUD */}
{!status && !error && (
<>
<div
style={{
position: "absolute",
top: 10,
left: 10,
background: "rgba(0,0,0,0.75)",
color: "#0f0",
fontFamily: "monospace",
padding: "8px 12px",
fontSize: "12px",
lineHeight: "1.6",
borderRadius: "4px",
pointerEvents: "none",
}}
>
<div>FPS: {stats.fps}</div>
<div>Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}</div>
<div>Mode: {stats.mode}</div>
<div>Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit</div>
<div>Pt Size: {stats.ptSize.toFixed(1)}px</div>
<div style={{ color: "#f80" }}>Selected: {selectedNodes.size}</div>
</div>
<div
style={{
position: "absolute",
bottom: 10,
left: 10,
background: "rgba(0,0,0,0.75)",
color: "#888",
fontFamily: "monospace",
padding: "6px 10px",
fontSize: "11px",
borderRadius: "4px",
pointerEvents: "none",
}}
>
Drag to pan · Scroll to zoom · Click to select
</div>
{/* Hover tooltip */}
{hoveredNode && (
<div
style={{
position: "absolute",
left: hoveredNode.screenX + 15,
top: hoveredNode.screenY + 15,
background: "rgba(0,0,0,0.85)",
color: "#0ff",
fontFamily: "monospace",
padding: "6px 10px",
fontSize: "12px",
borderRadius: "4px",
pointerEvents: "none",
whiteSpace: "nowrap",
border: "1px solid rgba(0,255,255,0.3)",
boxShadow: "0 2px 8px rgba(0,0,0,0.5)",
}}
>
{(() => {
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>
);
}