finance-dashboard/lib/graph.js

116 lines
4.5 KiB
JavaScript
Raw Normal View History

2026-06-03 09:33:23 +00:00
// ═══════════════════════════════════════════════════════════
// 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 };
}