Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263import 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<string>; highlightFaction: string | null;}
interface ReducerState { mode: Mode; path: Set<string> | null; sameAuthor: Set<string> | null; hiddenFactions: Set<string>; highlightFaction: string | null; factionsById: Map<string, Faction>; 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<HTMLDivElement>(null); const sigmaRef = useRef<Sigma | null>(null); const graphRef = useRef<Graph | null>(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<string>(); 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<ReducerState>(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 ( <> <div className="graph-container" ref={containerRef} /> <div className="graph-toolbar"> <div className="seg"> <button className={layout === "radial" ? "active" : ""} onClick={() => props.onLayoutChange("radial")} > Radial tree </button> <button className={layout === "force" ? "active" : ""} onClick={() => props.onLayoutChange("force")} title={forceSettling ? "Force layout is still computing in the background" : undefined} > {forceSettling ? "Force…" : "Force"} </button> </div> <button className="small" onClick={() => sigmaRef.current?.getCamera().animatedReset({ duration: 400 })} > Fit view </button> <div className="seg"> <button className={showLabels ? "active" : ""} onClick={() => setShowLabels((s) => !s)} title="Show username labels next to nodes (hover/selection always shows them)" > Labels </button> </div> </div> </> );}