@@ -163,6 +163,15 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
.gcenter { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 104px; height: 104px; border-radius: 50%; background: radial-gradient(circle at 36% 30%,rgba(242,209,148,.95),rgba(232,180,90,.9) 55%,rgba(138,95,30,.95)); display: grid; place-items: center; text-align: center; padding: 8px; box-shadow: 0 0 30px rgba(var(--amber-rgb),.3); }
.gcenter span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 13.5px; font-weight: 600; line-height: 1.35; color: #241804; word-break: break-word; }
.nbrow { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; cursor: pointer; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); font-size: 15.5px; }
/* 總圖(Arcrun#39 GUI 切片):沿用紙感語言——green 章印節點、amber 高度數節點 */
.mapbox { position: relative; border-radius: 14px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); overflow: hidden; }
.mapbox svg { display: block; width: 100%; height: auto; }
.mapnode { cursor: pointer; }
.mapnode circle { fill: rgba(var(--ok-rgb),.12); stroke: rgba(var(--ok-rgb),.45); stroke-width: 1.4; transition: stroke-width .15s; }
.mapnode.hub circle { fill: rgba(var(--amber-rgb),.2); stroke: rgba(var(--amber-rgb),.65); }
.mapnode:hover circle { stroke-width: 3; }
.mapnode text { font-size: 12.5px; fill: var(--ink); pointer-events: none; }
.mapedge { stroke: rgba(var(--ink-rgb),.18); stroke-width: 1; stroke-dasharray: 4 4; }
/* ── 管理頁(P4)── */
.sechead { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 19px; letter-spacing: .14em; color: var(--amber); margin: 26px 0 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(var(--amber-rgb),.3); }
@@ -215,6 +224,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
<div id="sidenav">
<div class="logo"> ${ brand } </div>
<div class="nav" data-nav="search">搜尋</div>
<div class="nav hide" id="nav-map" data-nav="map">總圖</div>
<div class="nav hide" id="nav-upload" data-nav="upload">上傳</div>
<div class="nav hide" id="nav-workflows" data-nav="workflows">工作流</div>
<div class="nav hide" id="nav-admin" data-nav="admin">管理</div>
@@ -258,6 +268,19 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
</div>
</div>
<!-- 總圖(Arcrun#39 藏書地圖 GUI 切片;graph 粗閘後可見):全庫三元組一張網。
力導向佈局內聯手刻(demo 量級 ≤ 數百節點),不引外部套件(頁面自足原則)。-->
<div class="view page" id="v-map">
<div class="pagehead"><span class="t">總圖</span><span class="m" id="map-meta"></span></div>
<div style="padding-top:20px">
<div id="map-box"></div>
<div style="margin-top:12px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)">
點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版
(給 AI 注入用的總庫目錄)<span id="map-md-link"></span>。
</div>
</div>
</div>
<!-- 卡片詳頁 -->
<div class="view page" id="v-card">
<div class="pagehead" style="justify-content:flex-start;gap:14px">
@@ -366,6 +389,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
<!-- 手機底部導覽 -->
<div id="tabbar">
<div class="tab" data-nav="search">搜尋</div>
<div class="tab hide" id="tab-map" data-nav="map">總圖</div>
<div class="tab hide" id="tab-upload" data-nav="upload">上傳</div>
<div class="tab hide" id="tab-workflows" data-nav="workflows">工作流</div>
<div class="tab hide" id="tab-admin" data-nav="admin">管理</div>
@@ -505,7 +529,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
}
// ── 路由 ──
var VIEWS = ['search', 'card', 'upload', 'workflows', 'admin', 'settings'];
var VIEWS = ['search', 'map', 'card', 'upload', 'workflows', 'admin', 'settings'];
var HOME = 'search';
function allowedViews() {
// 工作流/管理/上傳頁按 session 能力顯示(真閘在 server: /portal/data/workflows 403/404、
@@ -534,7 +558,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
});
window.scrollTo(0, 0);
}
var LOADERS = { card: loadCard, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
var LOADERS = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
function route() {
var r = currentRoute();
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
@@ -568,6 +592,8 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
$ ('nav-admin').classList.toggle('hide', p.role !== 'admin');
$ ('tab-admin').classList.toggle('hide', p.role !== 'admin');
$ ('mode-graph').classList.toggle('hide', !p.graph_allowed);
$ ('nav-map').classList.toggle('hide', !p.graph_allowed);
$ ('tab-map').classList.toggle('hide', !p.graph_allowed);
var libs = p.libraries || [];
$ ('se-scope').textContent = libs.indexOf('*') >= 0 ? '範圍:全部知識庫' : ('範圍:' + libs.join('・'));
if (!location.hash) location.hash = '#/' + HOME;
@@ -832,6 +858,124 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
.catch(function (er) { $ ('cd-main').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(er)) + '</div>'; });
}
// ── 總圖(Arcrun#39 藏書地圖 GUI 切片)──
// 全庫三元組一張網:/portal/data/graph/overview → 手刻力導向佈局(demo 量級,零外部套件)。
// 佈局用固定種子的 PRNG=同一庫每次長一樣(客戶重整不會跳位)。
function jumpToGraph(name) {
S.mode = 'graph';
document.querySelectorAll('.modebtn').forEach(function (x) { x.classList.toggle('on', x.getAttribute('data-mode') === 'graph'); });
nav('search');
$ ('se-q').value = name;
doGraphSearch(name);
}
function loadMap() {
$ ('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">載入總圖中…</div>';
$ ('map-md-link').innerHTML = SOURCE_WEB_BASE
? ': <a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
: '存在知識庫的 00-MAP.md';
fetch('/portal/data/graph/overview', { headers: authHeaders() })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (guard401(x.status)) return;
if (!x.ok) { $ ('map-box').innerHTML = '<div class="err">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ') ')) + '</div>'; return; }
var nodes = x.d.nodes || [];
var edges = x.d.edges || [];
$ ('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : '');
if (!nodes.length) { $ ('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。</div>'; return; }
renderMap(nodes, edges);
})
.catch(function (e) { $ ('map-box').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
function renderMap(nodes, edges) {
var N = nodes.length;
var idx = {};
nodes.forEach(function (n, i) { idx[n.name] = i; });
var el = [];
edges.forEach(function (e) {
var a = idx[e.subject], b = idx[e.object];
if (a == null || b == null || a === b) return;
el.push([a, b, e.predicate || '']);
});
// 固定種子 PRNG( mulberry32):確定性佈局
var seed = 42;
function rnd() {
seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
var W = 1000, H = Math.max(480, Math.min(760, 200 + N * 14));
var px = [], py = [];
for (var i = 0; i < N; i++) {
var ang = 2 * Math.PI * i / N;
var rad = Math.min(W, H) * (0.22 + 0.2 * rnd());
px.push(W / 2 + rad * Math.cos(ang));
py.push(H / 2 + rad * Math.sin(ang) * 0.75);
}
// 力導向:斥力(全對)+彈簧(邊)+向心,260 輪退火
var K = Math.max(75, Math.sqrt(W * H / Math.max(N, 1)) * 0.9);
for (var it = 0; it < 260; it++) {
var cool = Math.max(0.06, 1 - it / 260);
var fx = [], fy = [];
for (var a1 = 0; a1 < N; a1++) { fx.push(0); fy.push(0); }
for (var a2 = 0; a2 < N; a2++) {
for (var b2 = a2 + 1; b2 < N; b2++) {
var dx = px[a2] - px[b2], dy = py[a2] - py[b2];
var d2 = dx * dx + dy * dy + 0.01;
var f = Math.min(28, (K * K) / d2 * 2.2);
var d = Math.sqrt(d2);
fx[a2] += dx / d * f; fy[a2] += dy / d * f;
fx[b2] -= dx / d * f; fy[b2] -= dy / d * f;
}
}
el.forEach(function (e) {
var dx = px[e[0]] - px[e[1]], dy = py[e[0]] - py[e[1]];
var d = Math.sqrt(dx * dx + dy * dy) + 0.01;
var f = (d - K) * 0.045;
fx[e[0]] -= dx / d * f; fy[e[0]] -= dy / d * f;
fx[e[1]] += dx / d * f; fy[e[1]] += dy / d * f;
});
for (var m = 0; m < N; m++) {
fx[m] += (W / 2 - px[m]) * 0.012;
fy[m] += (H / 2 - py[m]) * 0.012;
px[m] += Math.max(-14, Math.min(14, fx[m])) * cool;
py[m] += Math.max(-14, Math.min(14, fy[m])) * cool;
}
}
// 視窗貼合
var minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
for (var q = 0; q < N; q++) {
minX = Math.min(minX, px[q]); maxX = Math.max(maxX, px[q]);
minY = Math.min(minY, py[q]); maxY = Math.max(maxY, py[q]);
}
var PAD = 70;
var vb = (minX - PAD) + ' ' + (minY - PAD) + ' ' + (maxX - minX + PAD * 2) + ' ' + (maxY - minY + PAD * 2);
var maxDeg = 1;
nodes.forEach(function (n) { maxDeg = Math.max(maxDeg, n.degree || 1); });
var svg = '<svg viewBox="' + vb + '" preserveAspectRatio="xMidYMid meet">';
el.forEach(function (e) {
svg += '<line class="mapedge" x1="' + px[e[0]].toFixed(1) + '" y1="' + py[e[0]].toFixed(1) +
'" x2="' + px[e[1]].toFixed(1) + '" y2="' + py[e[1]].toFixed(1) + '"><title>' +
esc(nodes[e[0]].name + ' —' + (e[2] || '關聯') + '→ ' + nodes[e[1]].name) + '</title></line>';
});
nodes.forEach(function (n, i2) {
var deg = n.degree || 1;
var r = 9 + Math.sqrt(deg) * 5;
var hub = deg >= Math.max(3, maxDeg * 0.6) ? ' hub' : '';
svg += '<g class="mapnode' + hub + '" data-mapnode="' + esc(n.name) + '">' +
'<circle cx="' + px[i2].toFixed(1) + '" cy="' + py[i2].toFixed(1) + '" r="' + r.toFixed(1) + '"><title>' + esc(n.name + '・' + deg + ' 條關聯') + '</title></circle>' +
'<text x="' + px[i2].toFixed(1) + '" y="' + (py[i2] + r + 15).toFixed(1) + '" text-anchor="middle">' + esc(n.name.length > 14 ? n.name.slice(0, 14) + '…' : n.name) + '</text></g>';
});
svg += '</svg>';
$ ('map-box').innerHTML = '<div class="mapbox">' + svg +
'<div style="position:absolute;right:12px;bottom:10px;font-size:11.5px;color:rgba(var(--ink-rgb),.35)">╌ 知識庫總圖(點節點看鄰居)</div></div>';
}
document.addEventListener('click', function (ev) {
var t = ev.target.closest('[data-mapnode]');
if (!t) return;
jumpToGraph(t.getAttribute('data-mapnode'));
});
// ── 工作流(唯讀)──
function loadWorkflows() {
$ ('wf-list').innerHTML = '<div class="muted">載入中…</div>';