import type Graph from "graphology"; import { useEffect, useMemo, useRef, useState } from "react"; import Sigma from "sigma"; import { ancestorPath, applyLayout, buildGraph, computeForceLayout } from "./graph"; import { CHROME, factionColor } from "./theme"; import type { AnalyzeResult, Faction, LayoutKind, Mode } from "./types"; interface Props { result: AnalyzeResult; mode: Mode; layout: LayoutKind; onLayoutChange: (l: LayoutKind) => void; selectedUri: string | null; onSelect: (uri: string | null) => void; hiddenFactions: Set; highlightFaction: string | null; } interface ReducerState { mode: Mode; path: Set | null; sameAuthor: Set | null; hiddenFactions: Set; highlightFaction: string | null; factionsById: Map; selectedUri: string | null; } function makeHoverDrawer(mode: Mode) { const chrome = CHROME[mode]; return (ctx: CanvasRenderingContext2D, data: any, settings: any) => { if (!data.label) return; const size = settings.labelSize; ctx.font = `600 ${size}px ${settings.labelFont}`; const width = ctx.measureText(data.label).width + 16; const x = data.x + data.size + 6; const y = data.y - (size + 12) / 2; ctx.fillStyle = chrome.surface; ctx.strokeStyle = chrome.baseline; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(x, y, width, size + 12, 6); ctx.fill(); ctx.stroke(); ctx.fillStyle = chrome.inkPrimary; ctx.textBaseline = "middle"; ctx.fillText(data.label, x + 8, data.y); }; } export default function GraphView(props: Props) { const { result, mode, layout, selectedUri, hiddenFactions, highlightFaction } = props; const containerRef = useRef(null); const sigmaRef = useRef(null); const graphRef = useRef(null); const onSelectRef = useRef(props.onSelect); onSelectRef.current = props.onSelect; const factionsById = useMemo( () => new Map(result.factions.map((f) => [f.id, f])), [result], ); const postsByUri = useMemo( () => new Map(result.posts.map((p) => [p.uri, p])), [result], ); const path = useMemo( () => (selectedUri ? ancestorPath(postsByUri, selectedUri) : null), [selectedUri, postsByUri], ); // Other posts by the selected post's author, shown half-dimmed. const sameAuthor = useMemo(() => { const sel = selectedUri ? postsByUri.get(selectedUri) : undefined; if (!sel) return null; const uris = new Set(); for (const p of result.posts) if (p.author.did === sel.author.did) uris.add(p.uri); return uris; }, [selectedUri, postsByUri, result]); const stateRef = useRef(null as any); stateRef.current = { mode, path, sameAuthor, hiddenFactions, highlightFaction, factionsById, selectedUri }; const layoutRef = useRef(layout); layoutRef.current = layout; const [showLabels, setShowLabels] = useState(false); const showLabelsRef = useRef(showLabels); showLabelsRef.current = showLabels; const [forceSettling, setForceSettling] = useState(false); useEffect(() => { let disposed = false; let cancelForce: (() => void) | null = null; // Dynamic import: @sigma/node-image probes WebGL at module scope, which // would crash the whole app at load in browsers without WebGL. void (async () => { const { createTintedNodeImageProgram } = await import("./tintProgram"); if (disposed || !containerRef.current) return; const graph = buildGraph(result); graphRef.current = graph; applyLayout(graph, layoutRef.current, false); // FA2 runs in a worker; meanwhile the radial layout stands in. setForceSettling(true); cancelForce = computeForceLayout(graph, () => { if (disposed) return; setForceSettling(false); if (layoutRef.current === "force") applyLayout(graph, "force"); }); // Atlas capacity: store avatars smaller on huge graphs so the texture // fits a couple of 4096² pages instead of dozens. const atlasImageSize = result.posts.length > 4000 ? 64 : 128; const sigma = new Sigma(graph, containerRef.current, { nodeProgramClasses: { image: createTintedNodeImageProgram({ drawingMode: "background", size: { mode: "max", value: atlasImageSize }, }), // Square variant for quote posts (image fills the quad, no circle clip). imageSquare: createTintedNodeImageProgram({ drawingMode: "background", keepWithinCircle: false, size: { mode: "max", value: atlasImageSize }, }), }, defaultNodeType: "image", renderLabels: showLabelsRef.current, labelSize: 11, labelFont: 'system-ui, -apple-system, "Segoe UI", sans-serif', labelWeight: "600", labelRenderedSizeThreshold: 8, zIndex: true, stagePadding: 50, minCameraRatio: 0.02, maxCameraRatio: 20, nodeReducer: (node, data) => { const s = stateRef.current; const chrome = CHROME[s.mode]; const res: any = { ...data, color: factionColor(s.factionsById.get(data.faction), s.mode), zIndex: 1, }; if (s.hiddenFactions.has(data.faction)) { res.hidden = true; return res; } const inPath = s.path?.has(node) ?? false; const dimmedByPath = s.path !== null && !inPath; const dimmedByFilter = s.highlightFaction !== null && data.faction !== s.highlightFaction && !inPath; if (dimmedByPath || dimmedByFilter) { if (dimmedByPath && !dimmedByFilter && s.sameAuthor?.has(node)) { // Half-dim: the selected author's other posts keep their faction // color (no avatar/label) so their trail stays visible. res.image = null; res.label = null; res.zIndex = 1; } else { res.color = chrome.dim; res.image = null; res.label = null; res.zIndex = 0; } } if (node === s.selectedUri) { res.highlighted = true; res.zIndex = 2; } return res; }, edgeReducer: (edge, data) => { const s = stateRef.current; const chrome = CHROME[s.mode]; const res: any = { ...data, color: chrome.hairline, size: 1 }; if (s.path) { const [a, b] = graph.extremities(edge); if (s.path.has(a) && s.path.has(b)) { res.color = chrome.inkSecondary; res.size = 2.5; res.zIndex = 1; } else { res.color = chrome.dim; } } return res; }, }); sigma.on("clickNode", ({ node }) => onSelectRef.current(node)); sigma.on("clickStage", () => onSelectRef.current(null)); sigma.setSetting("labelColor", { color: CHROME[stateRef.current.mode].inkPrimary }); sigma.setSetting("defaultDrawNodeHover", makeHoverDrawer(stateRef.current.mode) as any); sigmaRef.current = sigma; (window as any).__fraySigma = sigma; // headless UI-test hook })(); return () => { disposed = true; cancelForce?.(); sigmaRef.current?.kill(); sigmaRef.current = null; graphRef.current = null; }; }, [result]); // Layout switching. useEffect(() => { if (graphRef.current) applyLayout(graphRef.current, layout); }, [layout]); useEffect(() => { sigmaRef.current?.setSetting("renderLabels", showLabels); }, [showLabels]); // Theme-dependent sigma settings. useEffect(() => { const sigma = sigmaRef.current; if (!sigma) return; sigma.setSetting("labelColor", { color: CHROME[mode].inkPrimary }); sigma.setSetting("defaultDrawNodeHover", makeHoverDrawer(mode) as any); }, [mode, result]); // Re-render on any reducer-relevant state change. useEffect(() => { sigmaRef.current?.refresh({ skipIndexation: true }); }, [mode, path, hiddenFactions, highlightFaction, selectedUri]); return ( <>
); }