Radial tree + Forces
This commit is contained in:
154
src/App.tsx
154
src/App.tsx
@@ -3,7 +3,9 @@ import { Renderer } from "./renderer";
|
||||
|
||||
export default function App() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [status, setStatus] = useState("Generating 10M particles & building spatial index…");
|
||||
const rendererRef = useRef<Renderer | null>(null);
|
||||
const [status, setStatus] = useState("Loading node positions…");
|
||||
const [nodeCount, setNodeCount] = useState(0);
|
||||
const [stats, setStats] = useState({
|
||||
fps: 0,
|
||||
drawn: 0,
|
||||
@@ -12,6 +14,11 @@ export default function App() {
|
||||
ptSize: 0,
|
||||
});
|
||||
const [error, setError] = useState("");
|
||||
const [hoveredNode, setHoveredNode] = useState<{ x: number; y: number; screenX: number; screenY: 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;
|
||||
@@ -20,45 +27,135 @@ export default function App() {
|
||||
let renderer: Renderer;
|
||||
try {
|
||||
renderer = new Renderer(canvas);
|
||||
rendererRef.current = renderer;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build quadtree + upload (runs once, ~200ms)
|
||||
const buildMs = renderer.init();
|
||||
setStatus("");
|
||||
console.log(`Init complete in ${buildMs.toFixed(0)}ms`);
|
||||
let cancelled = false;
|
||||
|
||||
// Fetch CSVs, parse, and init renderer
|
||||
(async () => {
|
||||
try {
|
||||
setStatus("Fetching data files…");
|
||||
const [nodesResponse, edgesResponse] = await Promise.all([
|
||||
fetch("/node_positions.csv"),
|
||||
fetch("/edges.csv"),
|
||||
]);
|
||||
if (!nodesResponse.ok) throw new Error(`Failed to fetch nodes: ${nodesResponse.status}`);
|
||||
if (!edgesResponse.ok) throw new Error(`Failed to fetch edges: ${edgesResponse.status}`);
|
||||
|
||||
const [nodesText, edgesText] = await Promise.all([
|
||||
nodesResponse.text(),
|
||||
edgesResponse.text(),
|
||||
]);
|
||||
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 edgeLines = edgesText.split("\n").slice(1).filter(l => l.trim().length > 0);
|
||||
const edgeData = new Uint32Array(edgeLines.length * 2);
|
||||
for (let i = 0; i < edgeLines.length; i++) {
|
||||
const parts = edgeLines[i].split(",");
|
||||
edgeData[i * 2] = parseInt(parts[0], 10);
|
||||
edgeData[i * 2 + 1] = parseInt(parts[1], 10);
|
||||
}
|
||||
|
||||
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, ${edgeLines.length.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 = () => {
|
||||
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;
|
||||
@@ -69,6 +166,14 @@ export default function App() {
|
||||
const result = renderer.render();
|
||||
frameCount++;
|
||||
|
||||
// Find hovered node using quadtree
|
||||
const node = renderer.findNodeAt(mousePos.current.x, mousePos.current.y);
|
||||
if (node) {
|
||||
setHoveredNode({ ...node, screenX: mousePos.current.x, screenY: mousePos.current.y });
|
||||
} else {
|
||||
setHoveredNode(null);
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
if (now - lastTime >= 500) {
|
||||
const fps = (frameCount / (now - lastTime)) * 1000;
|
||||
@@ -88,14 +193,23 @@ export default function App() {
|
||||
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
|
||||
@@ -160,10 +274,11 @@ export default function App() {
|
||||
}}
|
||||
>
|
||||
<div>FPS: {stats.fps}</div>
|
||||
<div>Drawn: {stats.drawn.toLocaleString()} / 10,000,000</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={{
|
||||
@@ -179,8 +294,31 @@ export default function App() {
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
Drag to pan · Scroll to zoom
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user