f8aa27533c
- parse.worker collects edges, builds top-150-by-degree graph (links capped 600) - graphPoster.ts renders force-graph on off-screen canvas -> PNG (DS-2 colors), node names never leave the browser - WrappedClient: Карточка/Граф toggle, optional node labels, lazy-load force-graph - THIRD-PARTY-NOTICES for force-graph (MIT) and next/og satori/resvg (MPL-2.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
70 lines
2.4 KiB
TypeScript
70 lines
2.4 KiB
TypeScript
// Граф-постер: 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<Blob> {
|
|
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<void>((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<Blob>((resolve, reject) =>
|
|
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/png"),
|
|
);
|
|
} finally {
|
|
document.body.removeChild(el);
|
|
}
|
|
}
|