diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3a567ea..c526bdf 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -23,12 +23,17 @@ export interface ServerOptions { * Lightweight graph node payload returned to the web graph explorer. */ interface SnapshotGraphNode { - id: number; - url: string; + 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; } @@ -36,8 +41,8 @@ interface SnapshotGraphNode { * Lightweight graph edge payload returned to the web graph explorer. */ interface SnapshotGraphEdge { - source: number; - target: number; + source: string; + target: string; } export function startServer(options: ServerOptions): Promise { @@ -403,29 +408,29 @@ export function startServer(options: ServerOptions): Promise { }); /** - * Returns a graph projection for a snapshot with lightweight node/edge payloads. - * Filters are applied server-side to keep browser rendering responsive for large sites. + * 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 role = ((req.query.role as string) || 'all').toLowerCase(); const search = ((req.query.search as string) || '').trim(); + const includeEdges = (req.query.includeEdges as string) === 'true'; - const roleSql = role !== 'all' ? 'AND COALESCE(m.link_role, "") = ?' : ''; - const searchSql = search ? 'AND p.normalized_url LIKE ?' : ''; - - const nodeParams: Array = [currentSnapshotId, currentSnapshotId, currentSnapshotId, siteId, minPageRank, minInlinks, minOutlinks]; - if (role !== 'all') nodeParams.push(role); - if (search) nodeParams.push(`%${search}%`); - nodeParams.push(maxNodes); - - const nodeSql = ` + const baseRows = db.prepare(` WITH in_counts AS ( SELECT target_page_id AS page_id, COUNT(*) AS inlinks FROM edges @@ -443,9 +448,28 @@ export function startServer(options: ServerOptions): Promise { 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, - COALESCE(m.link_role, NULL) AS role + 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 @@ -454,61 +478,236 @@ export function startServer(options: ServerOptions): Promise { AND COALESCE(m.pagerank_score, 0) >= ? AND COALESCE(ic.inlinks, 0) >= ? AND COALESCE(oc.outlinks, 0) >= ? - ${roleSql} - ${searchSql} + 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}`; - const nodes = db.prepare(nodeSql).all(...nodeParams) as SnapshotGraphNode[]; - const nodeIds = nodes.map((n) => n.id); - - if (nodeIds.length === 0) { - return res.json({ - snapshotId: currentSnapshotId, - nodes: [], - edges: [], - meta: { - totalNodes: 0, - totalEdges: 0, - truncated: false, - } + 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, + })); } - const placeholders = nodeIds.map(() => '?').join(','); - const edgeSql = ` - SELECT source_page_id AS source, target_page_id AS target - FROM edges - WHERE snapshot_id = ? - AND rel = 'internal' - AND source_page_id IN (${placeholders}) - AND target_page_id IN (${placeholders}) - LIMIT ? - `; - - const edges = db.prepare(edgeSql).all(currentSnapshotId, ...nodeIds, ...nodeIds, maxEdges) as SnapshotGraphEdge[]; - const totalEdges = db.prepare(` - SELECT COUNT(*) AS count - FROM edges - WHERE snapshot_id = ? - AND rel = 'internal' - AND source_page_id IN (${placeholders}) - AND target_page_id IN (${placeholders}) - `).get(currentSnapshotId, ...nodeIds, ...nodeIds) as { count: number }; + 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: totalEdges.count, - truncated: totalEdges.count > edges.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/api.ts b/packages/web/src/api.ts index 539c000..eef136e 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -219,12 +219,17 @@ export interface GraphContext { * A node in the snapshot structure graph explorer. */ export interface SnapshotGraphNode { - id: number; - url: string; + 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; } @@ -232,8 +237,8 @@ export interface SnapshotGraphNode { * A directed internal edge in the snapshot structure graph explorer. */ export interface SnapshotGraphEdge { - source: number; - target: number; + source: string; + target: string; } /** @@ -241,6 +246,7 @@ export interface SnapshotGraphEdge { */ export interface SnapshotGraphResponse { snapshotId: number; + level: number; nodes: SnapshotGraphNode[]; edges: SnapshotGraphEdge[]; meta: { @@ -255,6 +261,8 @@ export interface SnapshotGraphResponse { */ export interface SnapshotGraphQuery { snapshotId?: number; + level?: 1 | 2 | 3; + includeEdges?: boolean; maxNodes?: number; maxEdges?: number; minPageRank?: number; @@ -441,6 +449,8 @@ export async function crawlPage(url: string): Promise<{ success: boolean, snapsh 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()); @@ -454,3 +464,16 @@ export async function fetchSnapshotGraph(query: SnapshotGraphQuery = {}): Promis 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 index a31789d..4b8c001 100644 --- a/packages/web/src/pages/GraphPage.tsx +++ b/packages/web/src/pages/GraphPage.tsx @@ -2,32 +2,34 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r 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; - color: string; -} - -interface RenderEdge extends API.SnapshotGraphEdge { - sourceNode: RenderNode; - targetNode: RenderNode; + opacity: number; } /** - * Optimized canvas-based structure graph explorer for large snapshots. + * 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 canvasRef = useRef(null); const wrapperRef = useRef(null); - const isDraggingRef = useRef(false); - const dragLastRef = useRef({ x: 0, y: 0 }); + 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 [hoveredNode, setHoveredNode] = 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); @@ -35,110 +37,146 @@ export function GraphPage() { const [search, setSearch] = useState(''); const [maxNodes, setMaxNodes] = useState(10000); - const [rawGraph, setRawGraph] = useState(null); + 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 [camera, setCamera] = useState({ x: 0, y: 0, zoom: 1 }); + 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; - const controller = new AbortController(); + let cancelled = false; + setLoading(true); + setError(null); + setSelectedNodeId(null); + setNeighborhood({ nodes: [], edges: [] }); + const timeout = setTimeout(async () => { - setLoading(true); - setError(null); try { - const graph = await API.fetchSnapshotGraph({ - snapshotId: currentSnapshot, - maxNodes, - maxEdges: maxNodes * 4, - minPageRank, - minInlinks, - minOutlinks, - search: search || undefined, - }); - - if (!controller.signal.aborted) { - setRawGraph(graph); - setCamera({ x: 0, y: 0, zoom: 1 }); - } - } catch (e) { - if (!controller.signal.aborted) { - setError('Failed to load structure graph.'); - } + await Promise.all(([1, 2, 3] as ZoomLevel[]).map((level) => fetchLevel(level))); + } catch { + if (!cancelled) setError('Failed to load hierarchical graph model.'); } finally { - if (!controller.signal.aborted) { - setLoading(false); - } + if (!cancelled) setLoading(false); } - }, 300); + }, 220); return () => { - controller.abort(); + cancelled = true; clearTimeout(timeout); }; - }, [currentSnapshot, maxNodes, minInlinks, minOutlinks, minPageRank, search]); + }, [currentSnapshot, fetchLevel]); - const renderData = useMemo(() => { - if (!rawGraph || rawGraph.nodes.length === 0) { - return { nodes: [] as RenderNode[], edges: [] as RenderEdge[] }; - } + const activeGraph = graphByLevel[zoomLevel] || null; - const maxDepth = Math.max(...rawGraph.nodes.map((n) => n.depth), 1); - const ringStep = 110; - const depthGroups = new Map(); + const renderNodes = useMemo(() => { + const graph = activeGraph; + if (!graph?.nodes?.length) return [] as RenderNode[]; - for (const node of rawGraph.nodes) { - const depth = Number.isFinite(node.depth) ? node.depth : 0; - if (!depthGroups.has(depth)) depthGroups.set(depth, []); - depthGroups.get(depth)!.push(node); + 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 computedNodes: RenderNode[] = []; - const colorPalette = ['#38bdf8', '#60a5fa', '#8b5cf6', '#22c55e', '#f59e0b', '#f43f5e']; - - const sortedDepths = Array.from(depthGroups.keys()).sort((a, b) => a - b); - for (const depth of sortedDepths) { - const group = depthGroups.get(depth)!; - const radiusBase = 30 + depth * ringStep; - const angleStep = (Math.PI * 2) / Math.max(group.length, 1); - - group.forEach((node, idx) => { - const angle = idx * angleStep; - const jitter = (idx % 7) * 3; - const x = Math.cos(angle) * (radiusBase + jitter); - const y = Math.sin(angle) * (radiusBase + jitter); - computedNodes.push({ + 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: 1.5 + Math.min(5, node.pageRankScore / 20), - color: colorPalette[Math.min(colorPalette.length - 1, Math.floor((depth / maxDepth) * (colorPalette.length - 1)))], + radius: baseRadius, + opacity: active ? 0.95 : 0.14, }); }); } - const nodeMap = new Map(computedNodes.map((n) => [n.id, n])); + 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); + }; - const computedEdges: RenderEdge[] = rawGraph.edges - .map((edge) => { - const sourceNode = nodeMap.get(edge.source); - const targetNode = nodeMap.get(edge.target); - if (!sourceNode || !targetNode) return null; - return { - ...edge, - sourceNode, - targetNode, - }; - }) - .filter(Boolean) as RenderEdge[]; + return { + degree: norm(degree), + authority: norm(authority), + orphan: norm(orphan), + }; + }, [activeGraph]); - return { nodes: computedNodes, edges: computedEdges }; - }, [rawGraph]); + 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 canvas = canvasRef.current; const wrapper = wrapperRef.current; - if (!canvas || !wrapper) return; + const canvas = canvasRef.current; + if (!wrapper || !canvas) return; const dpr = window.devicePixelRatio || 1; const width = wrapper.clientWidth; @@ -155,86 +193,130 @@ export function GraphPage() { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); - const centerX = width / 2 + camera.x; - const centerY = height / 2 + camera.y; + const cx = width / 2 + camera.x; + const cy = height / 2 + camera.y; ctx.save(); - ctx.translate(centerX, centerY); + ctx.translate(cx, cy); ctx.scale(camera.zoom, camera.zoom); - ctx.strokeStyle = 'rgba(148, 163, 184, 0.18)'; - ctx.lineWidth = 0.7 / camera.zoom; - ctx.beginPath(); - for (const edge of renderData.edges) { - ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y); - ctx.lineTo(edge.targetNode.x, edge.targetNode.y); + 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(); } - ctx.stroke(); - for (const node of renderData.nodes) { + for (const node of renderNodes) { ctx.beginPath(); - ctx.fillStyle = node.color; - ctx.globalAlpha = hoveredNode && hoveredNode.id !== node.id ? 0.4 : 0.95; + 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, hoveredNode, renderData.edges, renderData.nodes]); + }, [camera, heatOverlay.authority, heatOverlay.degree, heatOverlay.orphan, hoveredNodeId, neighborhood.edges, renderNodes, selectedNodeId]); useEffect(() => { - const raf = requestAnimationFrame(draw); - return () => cancelAnimationFrame(raf); + const frame = requestAnimationFrame(draw); + return () => cancelAnimationFrame(frame); }, [draw]); - const toWorldPoint = useCallback((clientX: number, clientY: number) => { + const worldPoint = useCallback((clientX: number, clientY: number) => { const wrapper = wrapperRef.current; if (!wrapper) return { x: 0, y: 0 }; const rect = wrapper.getBoundingClientRect(); - const localX = clientX - rect.left; - const localY = clientY - rect.top; - const worldX = (localX - (rect.width / 2 + camera.x)) / camera.zoom; - const worldY = (localY - (rect.height / 2 + camera.y)) / camera.zoom; - return { x: worldX, y: worldY }; + 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 hitTest = useCallback((x: number, y: number) => { - for (let i = renderData.nodes.length - 1; i >= 0; i -= 1) { - const node = renderData.nodes[i]; - const dx = node.x - x; - const dy = node.y - y; - if (dx * dx + dy * dy <= Math.max(node.radius + 2 / camera.zoom, 5 / camera.zoom) ** 2) { - return node; - } + 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, renderData.nodes]); + }, [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 (
-

Snapshot Structure Graph

-

Pan, zoom, and filter internal link topology. Optimized canvas rendering for up to 10k nodes.

+

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.

-
Nodes: {rawGraph?.nodes.length ?? 0}
-
Edges: {rawGraph?.edges.length ?? 0}
+
Zoom LOD: L{zoomLevel}
+
Nodes: {activeGraph?.nodes.length ?? 0}
@@ -245,52 +327,65 @@ export function GraphPage() { className="relative h-[72vh] min-h-[520px] rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-950" onWheel={(e) => { e.preventDefault(); - const delta = e.deltaY > 0 ? 0.92 : 1.08; - setCamera((prev) => ({ ...prev, zoom: Math.max(0.15, Math.min(4.5, prev.zoom * delta)) })); + 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) => { - isDraggingRef.current = true; - dragLastRef.current = { x: e.clientX, y: e.clientY }; + draggingRef.current = true; + movedRef.current = false; + dragOriginRef.current = { x: e.clientX, y: e.clientY }; }} onPointerMove={(e) => { - if (isDraggingRef.current) { - const dx = e.clientX - dragLastRef.current.x; - const dy = e.clientY - dragLastRef.current.y; - dragLastRef.current = { x: e.clientX, y: e.clientY }; + 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 world = toWorldPoint(e.clientX, e.clientY); - setHoveredNode(hitTest(world.x, world.y)); + const point = worldPoint(e.clientX, e.clientY); + const hit = hitNode(point.x, point.y); + setHoveredNodeId(hit?.id || null); }} - onPointerUp={() => { - isDraggingRef.current = false; + 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={() => { - isDraggingRef.current = false; - setHoveredNode(null); + draggingRef.current = false; + setHoveredNodeId(null); }} > - {loading && ( -
- Loading graph... -
- )} + {loading &&
Stabilizing radial model…
} {hoveredNode && ( -
-
{hoveredNode.url}
+
+
{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}
)}
- {rawGraph?.meta.truncated && ( -
Edge list truncated for rendering performance. Tighten filters for focused analysis.
- )} +
+
In-degree density ring
+
Authority density ring
+
Orphan concentration ring
+
);