From 2a9f684b1145906834116301b9a6c4d0a7fa4565 Mon Sep 17 00:00:00 2001 From: Saurabh <41580629+saurabhsharma2u@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:21:55 -0800 Subject: [PATCH] feat(graph): redesign explorer with cluster-first LOD model --- packages/server/src/index.ts | 327 ++++++++++++++++++++++ packages/web/src/App.tsx | 2 + packages/web/src/api.ts | 92 +++++++ packages/web/src/pages/GraphPage.tsx | 392 +++++++++++++++++++++++++++ 4 files changed, 813 insertions(+) create mode 100644 packages/web/src/pages/GraphPage.tsx diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 44dc087..09ee780 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -19,6 +19,32 @@ export interface ServerOptions { snapshotId: number; } +/** + * Lightweight graph node payload returned to the web graph explorer. + */ +interface SnapshotGraphNode { + id: string; + label: string; + nodeType: 'section' | 'cluster' | 'url'; + clusterType: 'template' | 'duplicate' | 'content_group' | 'none'; + url?: string; + depth: number; + pageRankScore: number; + inlinks: number; + outlinks: number; + health: number; + size: number; + role: string | null; +} + +/** + * Lightweight graph edge payload returned to the web graph explorer. + */ +interface SnapshotGraphEdge { + source: string; + target: string; +} + export function startServer(options: ServerOptions): Promise { return new Promise((resolve, reject) => { const { port, host = '127.0.0.1', staticPath, siteId, snapshotId } = options; @@ -382,6 +408,307 @@ export function startServer(options: ServerOptions): Promise { res.json({ results: rows }); }); + /** + * Returns hierarchical graph nodes (cluster-first) for a snapshot. + * + * Levels: + * - 1: section clusters (first URL path segment) + * - 2: URL clusters (duplicate/content/template buckets) + * - 3: individual URLs + * + * Edges are disabled by default and only returned when explicitly requested, + * allowing the web app to render nodes first and reveal edges on interaction. + */ + api.get('/graph/snapshot', validateSnapshot, (req, res) => { + const currentSnapshotId = (req as any).snapshotId as number; + + const level = Math.min(Math.max(parseInt((req.query.level as string) || '1', 10), 1), 3); + const maxNodes = Math.min(parseInt((req.query.maxNodes as string) || '10000', 10), 10000); + const maxEdges = Math.min(parseInt((req.query.maxEdges as string) || '40000', 10), 120000); + const minPageRank = Math.max(parseFloat((req.query.minPageRank as string) || '0') || 0, 0); + const minInlinks = Math.max(parseInt((req.query.minInlinks as string) || '0', 10), 0); + const minOutlinks = Math.max(parseInt((req.query.minOutlinks as string) || '0', 10), 0); + const search = ((req.query.search as string) || '').trim(); + const includeEdges = (req.query.includeEdges as string) === 'true'; + + const baseRows = db.prepare(` + WITH in_counts AS ( + SELECT target_page_id AS page_id, COUNT(*) AS inlinks + FROM edges + WHERE snapshot_id = ? AND rel = 'internal' + GROUP BY target_page_id + ), + out_counts AS ( + SELECT source_page_id AS page_id, COUNT(*) AS outlinks + FROM edges + WHERE snapshot_id = ? AND rel = 'internal' + GROUP BY source_page_id + ) + SELECT + p.id, + p.normalized_url AS url, + COALESCE(p.depth, 0) AS depth, + COALESCE(m.pagerank_score, 0) AS pageRankScore, + COALESCE(m.link_role, NULL) AS role, + COALESCE(ic.inlinks, 0) AS inlinks, + COALESCE(oc.outlinks, 0) AS outlinks, + CASE + WHEN m.duplicate_cluster_id IS NOT NULL THEN 'duplicate' + WHEN m.duplicate_type IS NOT NULL AND m.duplicate_type != 'none' THEN 'content_group' + ELSE 'template' + END AS clusterType, + CASE + WHEN p.http_status >= 400 OR p.security_error IS NOT NULL THEN 0.25 + WHEN p.noindex = 1 THEN 0.5 + ELSE 1.0 + END AS health, + CASE + WHEN instr(replace(replace(p.normalized_url, 'https://', ''), 'http://', ''), '/') > 0 + THEN substr( + replace(replace(p.normalized_url, 'https://', ''), 'http://', ''), + instr(replace(replace(p.normalized_url, 'https://', ''), 'http://', ''), '/') + 1 + ) + ELSE '' + END AS pathWithoutHost, + COALESCE(CAST(m.duplicate_cluster_id AS TEXT), '') AS duplicateClusterId + FROM pages p + LEFT JOIN metrics m ON p.id = m.page_id AND m.snapshot_id = ? + LEFT JOIN in_counts ic ON p.id = ic.page_id + LEFT JOIN out_counts oc ON p.id = oc.page_id + WHERE p.site_id = ? + AND COALESCE(m.pagerank_score, 0) >= ? + AND COALESCE(ic.inlinks, 0) >= ? + AND COALESCE(oc.outlinks, 0) >= ? + AND (? = '' OR p.normalized_url LIKE ?) + ORDER BY COALESCE(m.pagerank_score, 0) DESC, COALESCE(ic.inlinks, 0) DESC + LIMIT ? + `).all( + currentSnapshotId, + currentSnapshotId, + currentSnapshotId, + siteId, + minPageRank, + minInlinks, + minOutlinks, + search, + search ? `%${search}%` : '', + maxNodes + ) as any[]; + + const rows = baseRows.map((row) => { + const firstPath = (row.pathWithoutHost || '').split('/').filter(Boolean)[0] || 'root'; + const prefix = firstPath.length > 32 ? firstPath.slice(0, 32) : firstPath; + const fallbackCluster = `${prefix}:${row.clusterType}`; + + return { + ...row, + sectionKey: prefix, + clusterKey: row.duplicateClusterId ? `dup:${row.duplicateClusterId}` : fallbackCluster, + }; + }); + + let nodes: SnapshotGraphNode[] = []; + + if (level === 1) { + const groups = new Map(); + for (const row of rows) { + const key = row.sectionKey; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(row); + } + + nodes = Array.from(groups.entries()).map(([sectionKey, group]) => ({ + id: `section:${sectionKey}`, + label: `/${sectionKey}`, + nodeType: 'section', + clusterType: 'none', + depth: 1, + pageRankScore: group.reduce((sum, r) => sum + r.pageRankScore, 0) / Math.max(group.length, 1), + inlinks: group.reduce((sum, r) => sum + r.inlinks, 0), + outlinks: group.reduce((sum, r) => sum + r.outlinks, 0), + health: group.reduce((sum, r) => sum + r.health, 0) / Math.max(group.length, 1), + size: group.length, + role: null, + })); + } else if (level === 2) { + const groups = new Map(); + for (const row of rows) { + const key = `${row.sectionKey}|${row.clusterKey}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(row); + } + + nodes = Array.from(groups.entries()).map(([groupKey, group]) => { + const [sectionKey, clusterKey] = groupKey.split('|'); + return { + id: `cluster:${sectionKey}:${clusterKey}`, + label: `${sectionKey} · ${clusterKey}`, + nodeType: 'cluster', + clusterType: group[0].clusterType, + depth: 2, + pageRankScore: group.reduce((sum, r) => sum + r.pageRankScore, 0) / Math.max(group.length, 1), + inlinks: group.reduce((sum, r) => sum + r.inlinks, 0), + outlinks: group.reduce((sum, r) => sum + r.outlinks, 0), + health: group.reduce((sum, r) => sum + r.health, 0) / Math.max(group.length, 1), + size: group.length, + role: null, + } as SnapshotGraphNode; + }); + } else { + nodes = rows.map((row) => ({ + id: `url:${row.id}`, + label: row.url, + nodeType: 'url', + clusterType: row.clusterType, + url: row.url, + depth: Number.isFinite(row.depth) ? row.depth : 0, + pageRankScore: row.pageRankScore, + inlinks: row.inlinks, + outlinks: row.outlinks, + health: row.health, + size: 1, + role: row.role, + })); + } + + nodes = nodes + .sort((a, b) => (b.pageRankScore - a.pageRankScore) || (b.size - a.size)) + .slice(0, maxNodes); + + let edges: SnapshotGraphEdge[] = []; + if (includeEdges) { + const urlNodes = rows.map((r) => r.id); + if (urlNodes.length > 1) { + const placeholders = urlNodes.map(() => '?').join(','); + const rawEdges = db.prepare(` + SELECT source_page_id AS sourceId, target_page_id AS targetId + FROM edges + WHERE snapshot_id = ? + AND rel = 'internal' + AND source_page_id IN (${placeholders}) + AND target_page_id IN (${placeholders}) + LIMIT ? + `).all(currentSnapshotId, ...urlNodes, ...urlNodes, maxEdges) as Array<{ sourceId: number; targetId: number }>; + + edges = rawEdges.map((edge) => { + if (level === 1) { + const source = rows.find((r) => r.id === edge.sourceId); + const target = rows.find((r) => r.id === edge.targetId); + return source && target + ? { source: `section:${source.sectionKey}`, target: `section:${target.sectionKey}` } + : null; + } + if (level === 2) { + const source = rows.find((r) => r.id === edge.sourceId); + const target = rows.find((r) => r.id === edge.targetId); + return source && target + ? { + source: `cluster:${source.sectionKey}:${source.clusterKey}`, + target: `cluster:${target.sectionKey}:${target.clusterKey}` + } + : null; + } + return { source: `url:${edge.sourceId}`, target: `url:${edge.targetId}` }; + }).filter(Boolean) as SnapshotGraphEdge[]; + } + } + + res.json({ + snapshotId: currentSnapshotId, + level, + nodes, + edges, + meta: { + totalNodes: nodes.length, + totalEdges: edges.length, + truncated: includeEdges && edges.length >= maxEdges, + } + }); + }); + + /** + * Returns only 1-hop neighbors for an interacted node so the UI can reveal + * local edge structure on demand instead of drawing all links. + */ + api.get('/graph/neighbors', validateSnapshot, (req, res) => { + const currentSnapshotId = (req as any).snapshotId as number; + const nodeId = (req.query.nodeId as string) || ''; + if (!nodeId) return res.status(400).json({ error: 'nodeId is required' }); + + if (nodeId.startsWith('url:')) { + const pageId = parseInt(nodeId.replace('url:', ''), 10); + if (!Number.isFinite(pageId)) return res.status(400).json({ error: 'Invalid url node id' }); + + const incoming = db.prepare(` + SELECT source_page_id AS neighborId + FROM edges + WHERE snapshot_id = ? AND rel = 'internal' AND target_page_id = ? + LIMIT 250 + `).all(currentSnapshotId, pageId) as Array<{ neighborId: number }>; + + const outgoing = db.prepare(` + SELECT target_page_id AS neighborId + FROM edges + WHERE snapshot_id = ? AND rel = 'internal' AND source_page_id = ? + LIMIT 250 + `).all(currentSnapshotId, pageId) as Array<{ neighborId: number }>; + + const neighborIds = Array.from(new Set([...incoming, ...outgoing].map((r) => r.neighborId))); + if (neighborIds.length === 0) { + return res.json({ nodes: [], edges: [] }); + } + + const placeholders = neighborIds.map(() => '?').join(','); + const neighborRows = db.prepare(` + SELECT p.id, p.normalized_url AS url, + COALESCE(p.depth, 0) AS depth, + COALESCE(m.pagerank_score, 0) AS pageRankScore + FROM pages p + LEFT JOIN metrics m ON p.id = m.page_id AND m.snapshot_id = ? + WHERE p.id IN (${placeholders}) + `).all(currentSnapshotId, ...neighborIds) as any[]; + + const nodes = [ + ...neighborRows.map((row) => ({ + id: `url:${row.id}`, + label: row.url, + nodeType: 'url', + clusterType: 'none', + url: row.url, + depth: row.depth, + pageRankScore: row.pageRankScore, + inlinks: 0, + outlinks: 0, + health: 1, + size: 1, + role: null, + })), + { + id: nodeId, + label: nodeId, + nodeType: 'url', + clusterType: 'none', + depth: 0, + pageRankScore: 0, + inlinks: 0, + outlinks: 0, + health: 1, + size: 1, + role: null, + } + ]; + + const edges = [ + ...incoming.map((row) => ({ source: `url:${row.neighborId}`, target: nodeId })), + ...outgoing.map((row) => ({ source: nodeId, target: `url:${row.neighborId}` })), + ]; + + return res.json({ nodes, edges }); + } + + return res.json({ nodes: [], edges: [] }); + }); + // 5.1 GET /api/page api.get('/page', async (req, res) => { const url = req.query.url as string; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index a33a49f..4bb22ff 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -6,6 +6,7 @@ import * as API from './api'; import { Dashboard } from './pages/Dashboard'; import { SinglePage } from './pages/SinglePage'; import { HistoryView } from './components/History/HistoryView'; +import { GraphPage } from './pages/GraphPage'; export const DashboardContext = React.createContext<{ overview: API.OverviewData | null; @@ -106,6 +107,7 @@ function App() { } /> } /> + } /> diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index f643820..a5e818c 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -214,6 +214,63 @@ export interface GraphContext { equityRatio: number; } +/** + * A node in the snapshot structure graph explorer. + */ +export interface SnapshotGraphNode { + id: string; + label: string; + nodeType: 'section' | 'cluster' | 'url'; + clusterType: 'template' | 'duplicate' | 'content_group' | 'none'; + url?: string; + depth: number; + pageRankScore: number; + inlinks: number; + outlinks: number; + health: number; + size: number; + role: string | null; +} + +/** + * A directed internal edge in the snapshot structure graph explorer. + */ +export interface SnapshotGraphEdge { + source: string; + target: string; +} + +/** + * Snapshot graph payload returned by the API. + */ +export interface SnapshotGraphResponse { + snapshotId: number; + level: number; + nodes: SnapshotGraphNode[]; + edges: SnapshotGraphEdge[]; + meta: { + totalNodes: number; + totalEdges: number; + truncated: boolean; + }; +} + +/** + * Query options supported by the snapshot graph endpoint. + */ +export interface SnapshotGraphQuery { + snapshotId?: number; + level?: 1 | 2 | 3; + includeEdges?: boolean; + maxNodes?: number; + maxEdges?: number; + minPageRank?: number; + minInlinks?: number; + minOutlinks?: number; + role?: 'all' | 'hub' | 'authority' | 'orphan' | string; + search?: string; +} + // --- Existing Functions --- export async function fetchOverview(snapshotId?: number): Promise { @@ -374,3 +431,38 @@ export async function crawlPage(url: string): Promise<{ success: boolean, snapsh } return res.json(); } + +/** + * Fetches a scalable graph projection for a specific snapshot. + */ +export async function fetchSnapshotGraph(query: SnapshotGraphQuery = {}): Promise { + const params = new URLSearchParams(); + + if (query.level) params.append('level', query.level.toString()); + if (typeof query.includeEdges === 'boolean') params.append('includeEdges', String(query.includeEdges)); + if (query.snapshotId) params.append('snapshot', query.snapshotId.toString()); + if (query.maxNodes) params.append('maxNodes', query.maxNodes.toString()); + if (query.maxEdges) params.append('maxEdges', query.maxEdges.toString()); + if (typeof query.minPageRank === 'number') params.append('minPageRank', query.minPageRank.toString()); + if (typeof query.minInlinks === 'number') params.append('minInlinks', query.minInlinks.toString()); + if (typeof query.minOutlinks === 'number') params.append('minOutlinks', query.minOutlinks.toString()); + if (query.role) params.append('role', query.role); + if (query.search) params.append('search', query.search); + + const res = await fetch(`${API_PREFIX}/graph/snapshot?${params.toString()}`); + if (!res.ok) throw new Error('Failed to fetch snapshot graph'); + return res.json(); +} + +/** + * Fetches the 1-hop neighborhood for an interacted graph node. + */ +export async function fetchGraphNeighbors(nodeId: string, snapshotId?: number): Promise<{ nodes: SnapshotGraphNode[]; edges: SnapshotGraphEdge[] }> { + const params = new URLSearchParams(); + params.append('nodeId', nodeId); + if (snapshotId) params.append('snapshot', snapshotId.toString()); + + const res = await fetch(`${API_PREFIX}/graph/neighbors?${params.toString()}`); + if (!res.ok) throw new Error('Failed to fetch node neighbors'); + return res.json(); +} diff --git a/packages/web/src/pages/GraphPage.tsx b/packages/web/src/pages/GraphPage.tsx new file mode 100644 index 0000000..4b8c001 --- /dev/null +++ b/packages/web/src/pages/GraphPage.tsx @@ -0,0 +1,392 @@ +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { DashboardContext } from '../App'; +import * as API from '../api'; + +type ZoomLevel = 1 | 2 | 3; + +interface RenderNode extends API.SnapshotGraphNode { + x: number; + y: number; + radius: number; + opacity: number; +} + +/** + * Cluster-first structure graph explorer with radial depth layout, + * progressive zoom levels and interaction-only edge rendering. + */ +export function GraphPage() { + const { currentSnapshot } = useContext(DashboardContext); + + const wrapperRef = useRef(null); + const canvasRef = useRef(null); + const draggingRef = useRef(false); + const movedRef = useRef(false); + const dragOriginRef = useRef({ x: 0, y: 0 }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [camera, setCamera] = useState({ x: 0, y: 0, zoom: 0.9 }); + const [hoveredNodeId, setHoveredNodeId] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [neighborhood, setNeighborhood] = useState<{ nodes: API.SnapshotGraphNode[]; edges: API.SnapshotGraphEdge[] }>({ nodes: [], edges: [] }); + + const [minPageRank, setMinPageRank] = useState(0); + const [minInlinks, setMinInlinks] = useState(0); + const [minOutlinks, setMinOutlinks] = useState(0); + const [search, setSearch] = useState(''); + const [maxNodes, setMaxNodes] = useState(10000); + + const zoomLevel: ZoomLevel = useMemo(() => { + if (camera.zoom < 0.95) return 1; + if (camera.zoom < 1.9) return 2; + return 3; + }, [camera.zoom]); + + const [graphByLevel, setGraphByLevel] = useState>>({}); + + const fetchLevel = useCallback(async (level: ZoomLevel) => { + if (!currentSnapshot) return; + const graph = await API.fetchSnapshotGraph({ + snapshotId: currentSnapshot, + level, + includeEdges: false, + maxNodes, + minInlinks, + minOutlinks, + minPageRank, + search: search || undefined, + }); + + setGraphByLevel((prev) => ({ ...prev, [level]: graph })); + }, [currentSnapshot, maxNodes, minInlinks, minOutlinks, minPageRank, search]); + + useEffect(() => { + if (!currentSnapshot) return; + + let cancelled = false; + setLoading(true); + setError(null); + setSelectedNodeId(null); + setNeighborhood({ nodes: [], edges: [] }); + + const timeout = setTimeout(async () => { + try { + await Promise.all(([1, 2, 3] as ZoomLevel[]).map((level) => fetchLevel(level))); + } catch { + if (!cancelled) setError('Failed to load hierarchical graph model.'); + } finally { + if (!cancelled) setLoading(false); + } + }, 220); + + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [currentSnapshot, fetchLevel]); + + const activeGraph = graphByLevel[zoomLevel] || null; + + const renderNodes = useMemo(() => { + const graph = activeGraph; + if (!graph?.nodes?.length) return [] as RenderNode[]; + + const maxDepth = Math.max(...graph.nodes.map((node) => node.depth), 1); + const groups = new Map(); + for (const node of graph.nodes) { + const d = Math.max(0, Number.isFinite(node.depth) ? node.depth : 0); + if (!groups.has(d)) groups.set(d, []); + groups.get(d)!.push(node); + } + + const orderedDepths = Array.from(groups.keys()).sort((a, b) => a - b); + const placed: RenderNode[] = []; + + for (const depth of orderedDepths) { + const nodes = groups.get(depth)!; + const ringRadius = 45 + (depth / Math.max(maxDepth, 1)) * 560; + const step = (Math.PI * 2) / Math.max(nodes.length, 1); + + nodes.forEach((node, idx) => { + const angle = idx * step; + const jitter = ((idx % 11) - 5) * 2; + const x = Math.cos(angle) * (ringRadius + jitter); + const y = Math.sin(angle) * (ringRadius + jitter); + + const baseRadius = zoomLevel === 1 + ? 3.2 + Math.min(25, Math.sqrt(Math.max(node.size, 1))) + : zoomLevel === 2 + ? 2.2 + Math.min(18, Math.sqrt(Math.max(node.size, 1))) + : 1.8 + Math.min(7, Math.max(node.pageRankScore, 0) / 12); + + const active = !selectedNodeId || node.id === selectedNodeId || neighborhood.nodes.some((n) => n.id === node.id); + + placed.push({ + ...node, + x, + y, + radius: baseRadius, + opacity: active ? 0.95 : 0.14, + }); + }); + } + + return placed; + }, [activeGraph, neighborhood.nodes, selectedNodeId, zoomLevel]); + + const heatOverlay = useMemo(() => { + const graph = activeGraph; + if (!graph?.nodes?.length) return { degree: [], authority: [], orphan: [] }; + + const slices = 24; + const degree = new Array(slices).fill(0); + const authority = new Array(slices).fill(0); + const orphan = new Array(slices).fill(0); + + const maxDepth = Math.max(...graph.nodes.map((node) => Math.max(1, node.depth)), 1); + + for (const node of graph.nodes) { + const idx = Math.min(slices - 1, Math.floor((Math.max(node.depth, 0) / maxDepth) * slices)); + degree[idx] += node.inlinks + node.outlinks; + authority[idx] += node.pageRankScore; + if ((node.role || '').toLowerCase() === 'orphan') orphan[idx] += 1; + } + + const norm = (arr: number[]) => { + const max = Math.max(...arr, 1); + return arr.map((v) => v / max); + }; + + return { + degree: norm(degree), + authority: norm(authority), + orphan: norm(orphan), + }; + }, [activeGraph]); + + const colorForNode = (node: RenderNode): string => { + if (node.nodeType === 'section') return '#38bdf8'; + if (node.clusterType === 'duplicate') return '#f97316'; + if (node.clusterType === 'content_group') return '#22c55e'; + if (node.clusterType === 'template') return '#a855f7'; + return node.health < 0.5 ? '#ef4444' : '#60a5fa'; + }; + + const draw = useCallback(() => { + const wrapper = wrapperRef.current; + const canvas = canvasRef.current; + if (!wrapper || !canvas) return; + + const dpr = window.devicePixelRatio || 1; + const width = wrapper.clientWidth; + const height = wrapper.clientHeight; + + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + + const cx = width / 2 + camera.x; + const cy = height / 2 + camera.y; + + ctx.save(); + ctx.translate(cx, cy); + ctx.scale(camera.zoom, camera.zoom); + + const drawHeatRing = (values: number[], radius: number, color: string) => { + const slices = values.length; + for (let i = 0; i < slices; i += 1) { + const v = values[i]; + if (v <= 0.02) continue; + const start = (Math.PI * 2 * i) / slices; + const end = (Math.PI * 2 * (i + 1)) / slices; + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.globalAlpha = 0.08 + v * 0.35; + ctx.lineWidth = 18; + ctx.arc(0, 0, radius, start, end); + ctx.stroke(); + } + }; + + drawHeatRing(heatOverlay.degree, 240, '#38bdf8'); + drawHeatRing(heatOverlay.authority, 270, '#a78bfa'); + drawHeatRing(heatOverlay.orphan, 300, '#fb7185'); + + // Non-negotiable: hide edges under zoom threshold and render only on selection. + const shouldRenderEdges = camera.zoom >= 1.15 && selectedNodeId && neighborhood.edges.length > 0; + if (shouldRenderEdges) { + ctx.beginPath(); + ctx.strokeStyle = 'rgba(148, 163, 184, 0.55)'; + ctx.lineWidth = 0.8 / camera.zoom; + const nodeMap = new Map(renderNodes.map((node) => [node.id, node])); + for (const edge of neighborhood.edges) { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) continue; + ctx.moveTo(source.x, source.y); + ctx.lineTo(target.x, target.y); + } + ctx.stroke(); + } + + for (const node of renderNodes) { + ctx.beginPath(); + ctx.fillStyle = colorForNode(node); + ctx.globalAlpha = node.id === hoveredNodeId ? 1 : node.opacity; + ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); + }, [camera, heatOverlay.authority, heatOverlay.degree, heatOverlay.orphan, hoveredNodeId, neighborhood.edges, renderNodes, selectedNodeId]); + + useEffect(() => { + const frame = requestAnimationFrame(draw); + return () => cancelAnimationFrame(frame); + }, [draw]); + + const worldPoint = useCallback((clientX: number, clientY: number) => { + const wrapper = wrapperRef.current; + if (!wrapper) return { x: 0, y: 0 }; + const rect = wrapper.getBoundingClientRect(); + return { + x: (clientX - rect.left - (rect.width / 2 + camera.x)) / camera.zoom, + y: (clientY - rect.top - (rect.height / 2 + camera.y)) / camera.zoom, + }; + }, [camera.x, camera.y, camera.zoom]); + + const hitNode = useCallback((x: number, y: number) => { + for (let i = renderNodes.length - 1; i >= 0; i -= 1) { + const n = renderNodes[i]; + const dx = n.x - x; + const dy = n.y - y; + const hitR = Math.max(6 / camera.zoom, n.radius + 2 / camera.zoom); + if ((dx * dx) + (dy * dy) <= hitR * hitR) return n; + } + return null; + }, [camera.zoom, renderNodes]); + + const hoveredNode = useMemo(() => renderNodes.find((n) => n.id === hoveredNodeId) || null, [hoveredNodeId, renderNodes]); + + const onNodeSelect = useCallback(async (node: RenderNode) => { + setSelectedNodeId(node.id); + + if (node.nodeType !== 'url' || !currentSnapshot) { + setNeighborhood({ nodes: [], edges: [] }); + return; + } + + try { + const ng = await API.fetchGraphNeighbors(node.id, currentSnapshot); + setNeighborhood(ng); + } catch { + setNeighborhood({ nodes: [], edges: [] }); + } + }, [currentSnapshot]); + + return ( +
+
+

Structure Graph · Cluster-first Explorer

+

Level 1 sections → Level 2 URL clusters → Level 3 individual URLs. Nodes only by default; edges appear on node selection.

+ +
+ + + + + +
+
Zoom LOD: L{zoomLevel}
+
Nodes: {activeGraph?.nodes.length ?? 0}
+
+
+ + {error &&
{error}
} + +
{ + e.preventDefault(); + const factor = e.deltaY > 0 ? 0.92 : 1.08; + setCamera((prev) => ({ ...prev, zoom: Math.max(0.45, Math.min(4, prev.zoom * factor)) })); + }} + onPointerDown={(e) => { + draggingRef.current = true; + movedRef.current = false; + dragOriginRef.current = { x: e.clientX, y: e.clientY }; + }} + onPointerMove={(e) => { + if (draggingRef.current) { + const dx = e.clientX - dragOriginRef.current.x; + const dy = e.clientY - dragOriginRef.current.y; + dragOriginRef.current = { x: e.clientX, y: e.clientY }; + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true; + setCamera((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy })); + return; + } + + const point = worldPoint(e.clientX, e.clientY); + const hit = hitNode(point.x, point.y); + setHoveredNodeId(hit?.id || null); + }} + onPointerUp={(e) => { + if (!draggingRef.current) return; + draggingRef.current = false; + if (movedRef.current) return; + + const point = worldPoint(e.clientX, e.clientY); + const hit = hitNode(point.x, point.y); + if (hit) { + onNodeSelect(hit); + } else { + setSelectedNodeId(null); + setNeighborhood({ nodes: [], edges: [] }); + } + }} + onPointerLeave={() => { + draggingRef.current = false; + setHoveredNodeId(null); + }} + > + + + {loading &&
Stabilizing radial model…
} + + {hoveredNode && ( +
+
{hoveredNode.label}
+
Type: {hoveredNode.nodeType} · Cluster: {hoveredNode.clusterType} · Size: {hoveredNode.size}
+
Depth: {hoveredNode.depth} · PR: {hoveredNode.pageRankScore.toFixed(2)} · In: {hoveredNode.inlinks} · Out: {hoveredNode.outlinks}
+
+ )} +
+ +
+
In-degree density ring
+
Authority density ring
+
Orphan concentration ring
+
+
+
+ ); +}