116 lines
4.5 KiB
JavaScript
116 lines
4.5 KiB
JavaScript
// ═══════════════════════════════════════════════════════════
|
||
// graph.js — 從知識庫筆記內 [[wikilink]] 建築節點與邊(給圖譜視圖)
|
||
// ═══════════════════════════════════════════════════════════
|
||
const WIKI_RE = /\[\[([^\]]+)\]\]/g;
|
||
|
||
const KIND_META = {
|
||
overview: { color: '#0071e3', shape: 'box' },
|
||
principleMap: { color: '#5856d6', shape: 'box' },
|
||
quiz: { color: '#ff2d55', shape: 'box' },
|
||
category: { color: '#0071e3', shape: 'box' },
|
||
case: { color: '#34c759', shape: 'dot' },
|
||
principle: { color: '#af52de', shape: 'dot' },
|
||
term: { color: '#ff9500', shape: 'dot' },
|
||
company: { color: '#5ac8fa', shape: 'dot' },
|
||
episode: { color: '#8e8e93', shape: 'dot' },
|
||
};
|
||
|
||
function nodeKey(hit) { return `${hit.kind}:${hit.id}`; }
|
||
|
||
function resolveLink(linkMap, target) {
|
||
const t = (target || '').trim();
|
||
return linkMap[t] || linkMap[t.split('#').pop()] || linkMap[t.split('/').pop()] || null;
|
||
}
|
||
|
||
function shortLabel(title, max = 28) {
|
||
const s = String(title || '');
|
||
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
||
}
|
||
|
||
export function buildGraph(knowledge, opts = {}) {
|
||
const linkMap = knowledge.linkMap || {};
|
||
const includeIndex = opts.includeIndex === '1' || opts.includeIndex === true;
|
||
const kinds = opts.kinds ? new Set(String(opts.kinds).split(',')) : null;
|
||
const limit = Math.min(Number(opts.limit) || 450, 800);
|
||
const center = opts.center ? String(opts.center) : null;
|
||
const depth = Math.min(Math.max(Number(opts.depth) || 2, 1), 4);
|
||
|
||
const nodes = new Map();
|
||
const edgeSet = new Set();
|
||
const edges = [];
|
||
|
||
const addNode = (hit) => {
|
||
if (!hit) return null;
|
||
if (kinds && !kinds.has(hit.kind)) return null;
|
||
const id = nodeKey(hit);
|
||
if (nodes.size >= limit && !nodes.has(id)) return null;
|
||
if (!nodes.has(id)) {
|
||
const meta = KIND_META[hit.kind] || { color: '#8e8e93', shape: 'dot' };
|
||
nodes.set(id, {
|
||
id, label: shortLabel(hit.title || hit.id),
|
||
title: hit.title || hit.id,
|
||
kind: hit.kind,
|
||
color: meta.color,
|
||
shape: meta.shape,
|
||
});
|
||
}
|
||
return id;
|
||
};
|
||
|
||
const addEdge = (from, to) => {
|
||
if (!from || !to || from === to) return;
|
||
const k = `${from}|${to}`;
|
||
if (edgeSet.has(k)) return;
|
||
edgeSet.add(k);
|
||
edges.push({ from, to });
|
||
};
|
||
|
||
const parseBody = (body, fromId) => {
|
||
if (!body || !fromId) return;
|
||
let m;
|
||
WIKI_RE.lastIndex = 0;
|
||
while ((m = WIKI_RE.exec(body)) !== null) {
|
||
const hit = resolveLink(linkMap, m[1]);
|
||
const toId = addNode(hit);
|
||
if (toId) addEdge(fromId, toId);
|
||
}
|
||
};
|
||
|
||
const seed = (hit, body) => {
|
||
const id = addNode(hit);
|
||
if (id && body) parseBody(body, id);
|
||
return id;
|
||
};
|
||
|
||
if (knowledge.overview) seed({ kind: 'overview', id: knowledge.overview.id, title: knowledge.overview.title }, knowledge.overview.body);
|
||
if (knowledge.principleMap) seed({ kind: 'principleMap', id: knowledge.principleMap.id, title: knowledge.principleMap.title }, knowledge.principleMap.body);
|
||
if (knowledge.quiz) seed({ kind: 'quiz', id: knowledge.quiz.id, title: knowledge.quiz.title }, knowledge.quiz.body);
|
||
|
||
for (const c of (knowledge.categories || [])) seed({ kind: 'category', id: c.id, title: c.title }, c.body);
|
||
for (const c of (knowledge.cases || [])) seed({ kind: 'case', id: c.id, title: c.title }, c.body);
|
||
for (const p of (knowledge.principles || [])) seed({ kind: 'principle', id: p.id, title: p.title }, p.body);
|
||
|
||
if (includeIndex) {
|
||
for (const x of (knowledge.index || [])) addNode({ kind: x.kind, id: x.id, title: x.title });
|
||
}
|
||
|
||
// 以某節點為中心:BFS 保留 depth 層內的節點與邊
|
||
if (center && nodes.has(center)) {
|
||
const keep = new Set([center]);
|
||
let frontier = [center];
|
||
for (let d = 0; d < depth; d++) {
|
||
const next = [];
|
||
for (const e of edges) {
|
||
if (frontier.includes(e.from) && nodes.has(e.to)) { keep.add(e.to); next.push(e.to); }
|
||
if (frontier.includes(e.to) && nodes.has(e.from)) { keep.add(e.from); next.push(e.from); }
|
||
}
|
||
frontier = [...new Set(next)];
|
||
}
|
||
const filtEdges = edges.filter(e => keep.has(e.from) && keep.has(e.to));
|
||
const filtNodes = [...nodes.values()].filter(n => keep.has(n.id));
|
||
return { nodes: filtNodes, edges: filtEdges, center, depth, total: filtNodes.length };
|
||
}
|
||
|
||
return { nodes: [...nodes.values()], edges, total: nodes.size };
|
||
}
|