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>
97 lines
4.1 KiB
TypeScript
97 lines
4.1 KiB
TypeScript
// Web Worker: парсит .md-тексты в агрегаты (off-main, без фриза UI).
|
|
// Получает RawNote[], возвращает Stats. Тексты заметок дальше воркера не идут.
|
|
|
|
import type { RawNote, Stats, Period, Archetype, GraphData } from "./types";
|
|
|
|
const WIKILINK = /\[\[([^\]|#]+)/g;
|
|
const TAG = /(^|\s)#([\p{L}\d_/-]+)/gu;
|
|
const DAY = 864e5;
|
|
|
|
function filterByPeriod(notes: RawNote[], period: Period): RawNote[] {
|
|
if (period === "all") return notes;
|
|
const cutoff = Date.now() - (period === "year" ? 365 * DAY : 90 * DAY);
|
|
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 }));
|
|
const topLinked = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([name, d]) => ({ name, degree: d }));
|
|
|
|
// Сирота — заметка, на которую никто не ссылается (грубая оценка по имени).
|
|
const linkedTargets = new Set(degree.keys());
|
|
const orphans = notes.filter((n) => !linkedTargets.has(n.name.replace(/\.md$/i, ""))).length;
|
|
|
|
const months = new Map<string, number>();
|
|
for (const n of notes) {
|
|
const d = new Date(n.mtime);
|
|
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
months.set(k, (months.get(k) ?? 0) + 1);
|
|
}
|
|
const busiestMonth = [...months.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
|
|
|
|
const linkDensity = noteCount ? linkCount / noteCount : 0;
|
|
const tagDensity = noteCount ? tags.size / noteCount : 0;
|
|
const orphanRatio = noteCount ? orphans / noteCount : 1;
|
|
|
|
let archetype: Archetype;
|
|
if (linkDensity >= 2) archetype = "architect";
|
|
else if (tagDensity >= 0.5) archetype = "librarian";
|
|
else if (orphanRatio >= 0.4) archetype = "gardener";
|
|
else archetype = "pragmatist";
|
|
|
|
const score = Math.max(0, Math.min(100, Math.round(
|
|
Math.min(linkDensity / 3, 1) * 45 + Math.min(tagDensity, 1) * 25 + Math.min(noteCount / 300, 1) * 30,
|
|
)));
|
|
|
|
const achievements: string[] = [];
|
|
if (noteCount >= 100) achievements.push(`${noteCount} заметок`);
|
|
if (linkCount >= 200) achievements.push(`${linkCount} связей`);
|
|
if (tags.size >= 30) achievements.push(`${tags.size} тегов`);
|
|
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, graph: buildGraph(degree, edges) };
|
|
}
|
|
|
|
self.onmessage = (e: MessageEvent<{ notes: RawNote[]; period: Period }>) => {
|
|
const { notes, period } = e.data;
|
|
const filtered = filterByPeriod(notes, 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);
|
|
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, edges));
|
|
};
|