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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user