Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217import Graph from "graphology";import { fa2Positions } from "./fa2";// Inlined so the single-file viewer build stays self-contained.import FA2Worker from "./fa2.worker?worker&inline";import type { AnalyzeResult, LayoutKind } from "./types";
const RING_SPACING = 90;
export function buildGraph(result: AnalyzeResult): Graph { const graph = new Graph(); for (const p of result.posts) { // Proxied through the backend: WebGL textures need CORS/same-origin. // Exported files carry data: URLs, which need no proxying. // s=128 asks the proxy for a thumbnail: full-size CDN avatars cost the // browser ~8ms of JPEG decode each, which at thousands of nodes freezes // the tab for many seconds while the texture atlas fills. const avatarUrl = p.author.avatar ? p.author.avatar.startsWith("data:") ? p.author.avatar : `/api/avatar?url=${encodeURIComponent(p.author.avatar)}&s=128` : undefined; graph.addNode(p.uri, { label: "@" + p.author.handle, faction: p.faction, size: p.parent_uri === null ? 18 : Math.min(13, 5 + 1.6 * Math.log2(1 + p.likeCount)), // The faction tint is blended in the shader (tintProgram.ts), so the // raw avatar is the only texture the atlas ever holds per author. image: avatarUrl, // Quote posts render as squares, replies as circles. type: p.is_quote ? "imageSquare" : "image", x: 0, y: 0, }); } for (const p of result.posts) { if (p.parent_uri && graph.hasNode(p.parent_uri) && !graph.hasEdge(p.parent_uri, p.uri)) { graph.addDirectedEdge(p.parent_uri, p.uri, { size: 1 }); } } computeRadial(graph, result.posts[0].uri); // Force positions start as the radial ones; computeForceLayout replaces // them from a worker so the graph can render before FA2 finishes. graph.updateEachNodeAttributes( (_n, attrs) => ({ ...attrs, x: attrs.xRadial, y: attrs.yRadial, xForce: attrs.xRadial, yForce: attrs.yRadial }), { attributes: ["x", "y", "xForce", "yForce"] }, ); return graph;}
/** Root at center, replies fan outward; each subtree gets an angular span * proportional to its leaf count. Stored as xRadial/yRadial. */function computeRadial(graph: Graph, rootId: string) { const children = new Map<string, string[]>(); graph.forEachNode((n) => children.set(n, [])); graph.forEachDirectedEdge((_e, _a, source, target) => children.get(source)!.push(target));
const leaves = new Map<string, number>(); const countLeaves = (n: string): number => { const kids = children.get(n)!; const total = kids.length ? kids.reduce((s, k) => s + countLeaves(k), 0) : 1; leaves.set(n, total); return total; }; countLeaves(rootId);
const placed = new Set<string>(); const MIN_ARC = 16; // px of arc a node needs before its fan gets staggered const assign = (n: string, a0: number, a1: number, depth: number, rOffset: number) => { const mid = (a0 + a1) / 2; const r = depth * RING_SPACING + rOffset; graph.mergeNodeAttributes(n, { xRadial: r * Math.cos(mid), yRadial: r * Math.sin(mid) }); placed.add(n); const kids = children.get(n)!; if (!kids.length) return; // Huge sibling fans (e.g. hundreds of quotes of the root) would overlap // on a single ring; spread them across staggered sub-rings instead. const fanArc = (a1 - a0) * (depth + 1) * RING_SPACING; const levels = Math.min(8, Math.max(1, Math.ceil((MIN_ARC * kids.length) / Math.max(fanArc, 1)))); let a = a0; kids.forEach((k, i) => { const span = (a1 - a0) * (leaves.get(k)! / leaves.get(n)!); assign(k, a, a + span, depth + 1, (i % levels) * RING_SPACING * 0.6); a += span; }); }; assign(rootId, 0, 2 * Math.PI, 0, 0);
// Orphans (parent outside the fetched set): park them on an outer ring. let maxR = 0; placed.forEach((n) => { const { xRadial, yRadial } = graph.getNodeAttributes(n); maxR = Math.max(maxR, Math.hypot(xRadial, yRadial)); }); const orphans = graph.nodes().filter((n) => !placed.has(n)); orphans.forEach((n, i) => { const a = (2 * Math.PI * i) / Math.max(1, orphans.length); const r = maxR + RING_SPACING; graph.mergeNodeAttributes(n, { xRadial: r * Math.cos(a), yRadial: r * Math.sin(a) }); });}
/** ForceAtlas2 seeded from the radial positions, run off the main thread * (it blocks its thread for seconds on multi-thousand-node graphs). Calls * onDone once xForce/yForce hold real FA2 positions. Returns a cancel fn. */export function computeForceLayout(graph: Graph, onDone: () => void): () => void { const n = graph.order; const index = new Map<string, number>(); const xy = new Float64Array(2 * n); const sizes = new Float64Array(n); let i = 0; graph.forEachNode((node, attrs) => { index.set(node, i); // Jitter so FA2 never sees exactly-overlapping siblings. xy[2 * i] = attrs.xRadial + (Math.random() - 0.5) * 5; xy[2 * i + 1] = attrs.yRadial + (Math.random() - 0.5) * 5; sizes[i] = attrs.size; i++; }); const edges: number[] = []; graph.forEachDirectedEdge((_e, _a, s, t) => edges.push(index.get(s)!, index.get(t)!)); const input = { xy, sizes, edges: Uint32Array.from(edges) };
let cancelled = false; const finish = (out: Float64Array) => { if (cancelled) return; graph.updateEachNodeAttributes( (node, attrs) => { const j = index.get(node)!; return { ...attrs, xForce: out[2 * j], yForce: out[2 * j + 1] }; }, { attributes: ["xForce", "yForce"] }, ); onDone(); };
let worker: Worker | null = null; try { worker = new FA2Worker(); } catch { worker = null; // no Worker support: fall through to the sync path } if (!worker) { const handle = setTimeout(() => finish(fa2Positions(input)), 0); return () => { cancelled = true; clearTimeout(handle); }; } worker.onmessage = (e: MessageEvent<Float64Array>) => { worker?.terminate(); worker = null; finish(e.data); }; worker.onerror = () => { worker?.terminate(); worker = null; finish(fa2Positions(input)); }; // Cloned, not transferred: the onerror fallback still needs the buffers. worker.postMessage(input); return () => { cancelled = true; worker?.terminate(); worker = null; };}
let tweenHandle = 0;
/** Tween node x/y to the chosen layout's stored coordinates. */export function applyLayout(graph: Graph, kind: LayoutKind, animate = true) { cancelAnimationFrame(tweenHandle); const xAttr = kind === "radial" ? "xRadial" : "xForce"; const yAttr = kind === "radial" ? "yRadial" : "yForce"; // All updates go through updateEachNodeAttributes: one graphology event // per frame instead of one per node, which stutters on large graphs. if (!animate) { graph.updateEachNodeAttributes( (_n, attrs) => ({ ...attrs, x: attrs[xAttr], y: attrs[yAttr] }), { attributes: ["x", "y"] }, ); return; } const from = new Map<string, [number, number]>(); graph.forEachNode((n, attrs) => from.set(n, [attrs.x, attrs.y])); const start = performance.now(); const duration = 600; const tick = (now: number) => { const t = Math.min(1, (now - start) / duration); const ease = 1 - Math.pow(1 - t, 3); graph.updateEachNodeAttributes( (n, attrs) => { const f = from.get(n)!; return { ...attrs, x: f[0] + (attrs[xAttr] - f[0]) * ease, y: f[1] + (attrs[yAttr] - f[1]) * ease, }; }, { attributes: ["x", "y"] }, ); if (t < 1) tweenHandle = requestAnimationFrame(tick); }; tweenHandle = requestAnimationFrame(tick);}
/** Chain of URIs from a node back to the root (inclusive). */export function ancestorPath(posts: Map<string, { parent_uri: string | null }>, uri: string): Set<string> { const path = new Set<string>(); let cur: string | null = uri; while (cur && posts.has(cur) && !path.has(cur)) { path.add(cur); cur = posts.get(cur)!.parent_uri; } return path;}