// ═══════════════════════════════════════════════════════════ // 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 }; }