// Граф-постер: force-graph (Canvas) в офф-скрин контейнере → PNG. ДС-2-окрас. // 100% client-side — имена узлов остаются в браузере, на сервер не уходят. import ForceGraph from "force-graph"; import type { GraphData } from "./types"; /* eslint-disable @typescript-eslint/no-explicit-any */ const SIZE = 1080; const BG = "#F5F5F0"; const NODE = "#323232"; const LINK = "#AAAAAA"; export async function renderGraphPoster(graph: GraphData, labels: boolean): Promise { const el = document.createElement("div"); el.style.cssText = "position:fixed;left:-99999px;top:0;width:1080px;height:1080px;"; document.body.appendChild(el); try { const fg = (ForceGraph as any)()(el) .width(SIZE) .height(SIZE) .backgroundColor(BG) .graphData({ nodes: graph.nodes.map((n) => ({ ...n })), links: graph.links.map((l) => ({ ...l })), }) .nodeId("id") .nodeRelSize(labels ? 3 : 2.5) .nodeVal((n: any) => 1 + Math.sqrt(n.deg ?? 1)) .nodeColor(() => NODE) .linkColor(() => LINK) .linkWidth(0.4) .enableNodeDrag(false) .enablePointerInteraction(false) .cooldownTime(3500); if (labels) { fg.nodeCanvasObjectMode(() => "after").nodeCanvasObject((node: any, ctx: CanvasRenderingContext2D, scale: number) => { if ((node.deg ?? 0) >= 6) { const fontSize = 11 / scale; ctx.font = `${fontSize}px "Fira Mono", monospace`; ctx.fillStyle = NODE; ctx.textBaseline = "middle"; ctx.fillText(String(node.id), node.x + 2 + Math.sqrt(node.deg) / scale, node.y); } }); } // дождаться остановки симуляции (или таймаут) await new Promise((resolve) => { let done = false; const finish = () => { if (!done) { done = true; resolve(); } }; fg.onEngineStop(finish); setTimeout(finish, 5000); }); fg.zoomToFit(0, 50); await new Promise((r) => setTimeout(r, 400)); const canvas = el.querySelector("canvas") as HTMLCanvasElement | null; if (!canvas) throw new Error("canvas not found"); return await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/png"), ); } finally { document.body.removeChild(el); } }