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
+99 -3
View File
@@ -5,6 +5,7 @@ import type { Stats, RawNote, Period, RunPayload } from "./_client/types";
import { supportsFSA, readViaFSA, readViaInput, readViaDrop } from "./_client/reader";
type Screen = "intro" | "loading" | "result" | "error";
type View = "card" | "graph";
const ARCHETYPE_RU: Record<string, string> = {
architect: "🏛 Архитектор",
@@ -21,14 +22,49 @@ export function WrappedClient({ userName }: { userName: string }) {
const [error, setError] = useState("");
const [shareUrl, setShareUrl] = useState("");
const [sharing, setSharing] = useState(false);
const [view, setView] = useState<View>("card");
const [graphUrl, setGraphUrl] = useState("");
const [graphLabels, setGraphLabels] = useState(false);
const [graphBusy, setGraphBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const notesRef = useRef<RawNote[]>([]);
// Сбросить отрисованный граф (при новых данных / смене периода).
const resetGraph = useCallback(() => {
setView("card");
setGraphUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return "";
});
}, []);
// Отрисовать граф-постер в офф-скрин Canvas → blob URL. force-graph грузим лениво.
const renderGraph = useCallback(
async (labels: boolean) => {
if (!stats || !stats.graph.nodes.length) return;
setGraphBusy(true);
setError("");
try {
const { renderGraphPoster } = await import("./_client/graphPoster");
const blob = await renderGraphPoster(stats.graph, labels);
setGraphUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
} catch {
setError("Не удалось построить граф");
}
setGraphBusy(false);
},
[stats],
);
const runWorker = useCallback((notes: RawNote[], p: Period) => {
setScreen("loading");
const worker = new Worker(new URL("./_client/parse.worker.ts", import.meta.url), { type: "module" });
worker.onmessage = (e: MessageEvent<Stats>) => {
setStats(e.data);
resetGraph();
setScreen("result");
worker.terminate();
};
@@ -38,7 +74,7 @@ export function WrappedClient({ userName }: { userName: string }) {
worker.terminate();
};
worker.postMessage({ notes, period: p });
}, []);
}, [resetGraph]);
const handleNotes = useCallback(
(notes: RawNote[]) => {
@@ -174,7 +210,7 @@ export function WrappedClient({ userName }: { userName: string }) {
{screen === "result" && stats && (
<div className="space-y-4">
{/* переключатель периода */}
<div className="flex gap-2">
<div className="flex flex-wrap items-center gap-2">
{(["year", "quarter", "all"] as Period[]).map((p) => (
<button
key={p}
@@ -190,9 +226,68 @@ export function WrappedClient({ userName }: { userName: string }) {
{PERIOD_RU[p]}
</button>
))}
{/* переключатель вида: карточка / граф */}
<div className="flex gap-2 ml-auto">
{(["card", "graph"] as View[]).map((v) => (
<button
key={v}
onClick={() => {
setView(v);
if (v === "graph" && !graphUrl && !graphBusy) renderGraph(graphLabels);
}}
className="text-xs px-3 py-1.5"
style={{
border: "2px solid var(--foreground)",
borderRadius: 2,
background: view === v ? "var(--accent)" : "var(--background)",
fontWeight: view === v ? 700 : 400,
}}
>
{v === "card" ? "Карточка" : "Граф"}
</button>
))}
</div>
</div>
{/* граф-постер хранилища */}
{view === "graph" && (
<div className="card-aubade p-6 space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="font-bold text-xl">Граф твоего хранилища</h2>
<label className="text-xs flex items-center gap-1.5" style={{ color: "var(--muted-foreground)" }}>
<input
type="checkbox"
checked={graphLabels}
onChange={(e) => { setGraphLabels(e.target.checked); renderGraph(e.target.checked); }}
/>
подписи узлов
</label>
</div>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{stats.graph.nodes.length} самых связанных заметок из твоего хранилища. Имена остаются в браузере.
</p>
<div className="flex items-center justify-center" style={{ border: "2px solid var(--border)", borderRadius: 2, minHeight: 280, background: "#F5F5F0" }}>
{graphBusy ? (
<p className="text-sm py-12" style={{ color: "var(--muted-foreground)" }}>Строю граф</p>
) : graphUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={graphUrl} alt="Граф хранилища" className="w-full h-auto" style={{ borderRadius: 2 }} />
) : stats.graph.nodes.length === 0 ? (
<p className="text-sm py-12 px-4 text-center" style={{ color: "var(--muted-foreground)" }}>
Слишком мало связей между заметками, чтобы построить граф.
</p>
) : null}
</div>
{graphUrl && (
<a href={graphUrl} download="obsidian-graph.png" className="btn-aubade text-sm inline-block">
Скачать граф (PNG)
</a>
)}
</div>
)}
{/* DOM-карточка результата (ДС-2) */}
{view === "card" && (
<div className="card-aubade p-6">
<h2 className="font-bold text-xl mb-1">Ты {ARCHETYPE_RU[stats.archetype]}</h2>
<p className="text-xs uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
@@ -226,13 +321,14 @@ export function WrappedClient({ userName }: { userName: string }) {
</div>
)}
</div>
)}
{/* действия */}
<div className="flex flex-col sm:flex-row gap-3">
<button onClick={share} disabled={sharing} className="btn-aubade btn-aubade-accent text-sm flex-1">
{sharing ? "Создаю ссылку…" : "Поделиться карточкой"}
</button>
<button onClick={() => { setScreen("intro"); setStats(null); setShareUrl(""); }} className="btn-aubade text-sm flex-1">
<button onClick={() => { setScreen("intro"); setStats(null); setShareUrl(""); resetGraph(); }} className="btn-aubade text-sm flex-1">
Другое хранилище
</button>
</div>
@@ -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 узлов/рёбер для граф-постера (не уходит в БД)
}
// Сырьё — НИКОГДА не покидает браузер.