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:
2026-06-23 14:57:36 +05:00
parent 0a1425301d
commit f8aa27533c
7 changed files with 592 additions and 7 deletions
@@ -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));
};