Add vault graph poster to Obsidian Wrapped
- 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>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
// Граф-постер: 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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Web Worker: парсит .md-тексты в агрегаты (off-main, без фриза UI).
|
||||
// Получает RawNote[], возвращает Stats. Тексты заметок дальше воркера не идут.
|
||||
|
||||
import type { RawNote, Stats, Period, Archetype } from "./types";
|
||||
import type { RawNote, Stats, Period, Archetype, GraphData } from "./types";
|
||||
|
||||
const WIKILINK = /\[\[([^\]|#]+)/g;
|
||||
const TAG = /(^|\s)#([\p{L}\d_/-]+)/gu;
|
||||
@@ -13,12 +13,22 @@ function filterByPeriod(notes: RawNote[], period: Period): RawNote[] {
|
||||
return notes.filter((n) => n.mtime >= cutoff);
|
||||
}
|
||||
|
||||
function buildGraph(degree: Map<string, number>, edges: { source: string; target: string }[]): GraphData {
|
||||
const TOP = 150;
|
||||
const top = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP);
|
||||
const nodes = top.map(([id, deg]) => ({ id, deg }));
|
||||
const ids = new Set(nodes.map((n) => n.id));
|
||||
const links = edges.filter((e) => ids.has(e.source) && ids.has(e.target)).slice(0, 600);
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
function buildStats(
|
||||
notes: RawNote[],
|
||||
degree: Map<string, number>,
|
||||
tags: Map<string, number>,
|
||||
linkCount: number,
|
||||
period: Period,
|
||||
edges: { source: string; target: string }[],
|
||||
): Stats {
|
||||
const noteCount = notes.length;
|
||||
const topTags = [...tags.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([tag, n]) => ({ tag, n }));
|
||||
@@ -57,7 +67,7 @@ function buildStats(
|
||||
if (linkDensity >= 3) achievements.push("Плотная сеть знаний");
|
||||
if (orphanRatio < 0.1 && noteCount >= 50) achievements.push("Почти нет сирот");
|
||||
|
||||
return { noteCount, linkCount, tagCount: tags.size, topTags, topLinked, orphans, busiestMonth, achievements, archetype, score, period };
|
||||
return { noteCount, linkCount, tagCount: tags.size, topTags, topLinked, orphans, busiestMonth, achievements, archetype, score, period, graph: buildGraph(degree, edges) };
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<{ notes: RawNote[]; period: Period }>) => {
|
||||
@@ -66,16 +76,21 @@ self.onmessage = (e: MessageEvent<{ notes: RawNote[]; period: Period }>) => {
|
||||
const degree = new Map<string, number>();
|
||||
const tags = new Map<string, number>();
|
||||
let linkCount = 0;
|
||||
const edges: { source: string; target: string }[] = [];
|
||||
for (const n of filtered) {
|
||||
const src = n.name.replace(/\.md$/i, "");
|
||||
for (const m of n.text.matchAll(WIKILINK)) {
|
||||
linkCount++;
|
||||
const t = m[1].trim();
|
||||
if (t) degree.set(t, (degree.get(t) ?? 0) + 1);
|
||||
if (t) {
|
||||
degree.set(t, (degree.get(t) ?? 0) + 1);
|
||||
edges.push({ source: src, target: t });
|
||||
}
|
||||
}
|
||||
for (const m of n.text.matchAll(TAG)) {
|
||||
const t = m[2];
|
||||
tags.set(t, (tags.get(t) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
self.postMessage(buildStats(filtered, degree, tags, linkCount, period));
|
||||
self.postMessage(buildStats(filtered, degree, tags, linkCount, period, edges));
|
||||
};
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
export type Archetype = "architect" | "gardener" | "librarian" | "pragmatist";
|
||||
export type Period = "year" | "quarter" | "all";
|
||||
|
||||
// Граф для постера (топ-N узлов по степени). Имена живут только в браузере.
|
||||
export interface GraphData {
|
||||
nodes: { id: string; deg: number }[];
|
||||
links: { source: string; target: string }[];
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
noteCount: number;
|
||||
linkCount: number;
|
||||
@@ -17,6 +23,7 @@ export interface Stats {
|
||||
archetype: Archetype;
|
||||
score: number; // 0..100
|
||||
period: Period;
|
||||
graph: GraphData; // топ-N узлов/рёбер для граф-постера (не уходит в БД)
|
||||
}
|
||||
|
||||
// Сырьё — НИКОГДА не покидает браузер.
|
||||
|
||||
Reference in New Issue
Block a user