0a9ab7b
ZhenYi
feat(voice): implement shared layout management for participant windows with remote synchronization
0a9ab7b
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GitData Knowledge Graph</title>
<style>
:root {
color-scheme: light;
--bg: #f4f7fb;
--surface: rgba(255, 255, 255, .88);
--surface-strong: #ffffff;
--text: #172033;
--muted: #667085;
--line: #d9e0ec;
--line-strong: #aebbd0;
--accent: #315efb;
--accent-soft: #e7edff;
--shadow: 0 16px 42px rgba(30, 53, 93, .11);
--app: #e8f0ff;
--domain: #e9f8f1;
--foundation: #fff3dc;
--resource: #f2eafd;
--app-line: #6f92e8;
--domain-line: #4fae83;
--foundation-line: #d69c3a;
--resource-line: #9b79d4;
}
[data-theme="dark"] {
color-scheme: dark;
--bg: #111827;
--surface: rgba(24, 34, 52, .9);
--surface-strong: #1a2437;
--text: #edf2ff;
--muted: #a6b2c8;
--line: #334156;
--line-strong: #55657f;
--accent: #8ca7ff;
--accent-soft: #25345f;
--shadow: 0 18px 42px rgba(0, 0, 0, .24);
--app: #1d2b4d;
--domain: #193d35;
--foundation: #4a3820;
--resource: #362851;
--app-line: #7e9eff;
--domain-line: #70cfaa;
--foundation-line: #f0bb61;
--resource-line: #b69ae7;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
background:
radial-gradient(circle at 0 0, rgba(49, 94, 251, .12), transparent 30rem),
var(--bg);
color: var(--text);
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
button, input { font: inherit; }
button { color: inherit; }
.shell { max-width: 1680px; margin: 0 auto; padding: 28px; }
.topbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
.eyebrow { color: var(--accent); font-weight: 700; letter-spacing: .08em; text-transform: uppercase; font-size: 12px; }
h1 { margin: 4px 0 5px; font-size: clamp(25px, 3vw, 38px); letter-spacing: -.035em; line-height: 1.1; }
.subtitle { margin: 0; color: var(--muted); max-width: 760px; }
.top-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.button, .filter-button {
border: 1px solid var(--line);
background: var(--surface-strong);
border-radius: 10px;
padding: 9px 13px;
cursor: pointer;
transition: border-color .15s ease, background .15s ease, transform .15s ease;
}
.button:hover, .filter-button:hover { border-color: var(--accent); transform: translateY(-1px); }
.button:focus-visible, .filter-button:focus-visible, input:focus-visible, .node:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 42%, transparent); outline-offset: 2px; }
.button.primary { border-color: var(--accent); background: var(--accent); color: #fff; }
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.stat { border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow); border-radius: 14px; padding: 14px 16px; }
.stat-label { color: var(--muted); font-size: 12px; }
.stat-value { margin-top: 2px; font-size: 26px; font-weight: 700; letter-spacing: -.03em; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 16px; align-items: stretch; }
.graph-card, .detail-card { border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow); border-radius: 16px; overflow: hidden; }
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 13px 14px; border-bottom: 1px solid var(--line); }
.search { min-width: 230px; flex: 1 1 260px; position: relative; }
.search input { width: 100%; border: 1px solid var(--line); background: var(--surface-strong); color: var(--text); border-radius: 10px; padding: 9px 12px 9px 36px; }
.search span { position: absolute; left: 12px; top: 8px; color: var(--muted); }
.filters { display: flex; gap: 6px; flex-wrap: wrap; }
.filter-button { padding: 7px 10px; font-size: 12px; }
.filter-button[aria-pressed="false"] { opacity: .48; }
.canvas-wrap { position: relative; min-height: 680px; background: linear-gradient(180deg, color-mix(in srgb, var(--surface-strong) 90%, transparent), transparent); }
svg { display: block; width: 100%; height: 680px; touch-action: none; user-select: none; }
.layer-band { fill: color-mix(in srgb, var(--band) 55%, transparent); stroke: color-mix(in srgb, var(--band-line) 65%, transparent); stroke-width: 1; stroke-dasharray: 4 5; }
.layer-title { fill: var(--muted); font-size: 13px; font-weight: 700; letter-spacing: .04em; }
.edge { fill: none; stroke: var(--line-strong); stroke-width: 1.3; opacity: .68; transition: opacity .15s ease, stroke .15s ease, stroke-width .15s ease; }
.edge[data-type="connects"] { stroke-dasharray: 5 5; }
.edge.is-related { stroke: var(--accent); stroke-width: 2.6; opacity: 1; }
.edge.is-dim { opacity: .08; }
.node { cursor: pointer; transition: opacity .15s ease; }
.node.is-dim { opacity: .13; }
.node rect { stroke: var(--node-line); stroke-width: 1.3; fill: var(--node-fill); }
.node.is-selected rect { stroke: var(--accent); stroke-width: 3; filter: drop-shadow(0 5px 8px color-mix(in srgb, var(--accent) 28%, transparent)); }
.node.is-match rect { stroke: var(--accent); stroke-width: 2.4; }
.node-title { fill: var(--text); font-size: 14px; font-weight: 700; pointer-events: none; }
.node-type { fill: var(--muted); font-size: 11px; pointer-events: none; }
.node-dot { fill: var(--node-line); pointer-events: none; }
.legend { display: flex; flex-wrap: wrap; gap: 11px; padding: 10px 15px 14px; color: var(--muted); font-size: 12px; border-top: 1px solid var(--line); }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend i { width: 9px; height: 9px; border-radius: 50%; background: var(--legend); }
.canvas-hint { position: absolute; bottom: 12px; left: 15px; color: var(--muted); font-size: 11px; pointer-events: none; }
.detail-card { min-height: 756px; padding: 19px; }
.detail-kicker { color: var(--accent); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.detail-card h2 { margin: 5px 0 6px; font-size: 24px; letter-spacing: -.025em; }
.detail-summary { margin: 0 0 17px; color: var(--muted); }
.detail-section { border-top: 1px solid var(--line); padding-top: 14px; margin-top: 14px; }
.detail-section h3 { margin: 0 0 8px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .07em; }
.file-list, .link-list { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
.file-list code { display: block; padding: 6px 8px; border-radius: 7px; background: color-mix(in srgb, var(--accent-soft) 62%, var(--surface-strong)); color: var(--text); font-size: 11px; overflow-wrap: anywhere; }
.link-list button { display: block; width: 100%; text-align: left; padding: 6px 0; border: 0; background: transparent; color: var(--accent); cursor: pointer; }
.link-list button:hover { text-decoration: underline; }
.empty-detail { display: grid; place-items: center; height: 680px; color: var(--muted); text-align: center; }
.footer { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 15px; color: var(--muted); font-size: 12px; }
.tabular { font-variant-numeric: tabular-nums; }
@media (max-width: 1080px) {
.workspace { grid-template-columns: 1fr; }
.detail-card { min-height: 0; }
.empty-detail { height: auto; min-height: 180px; }
}
@media (max-width: 640px) {
.shell { padding: 17px; }
.topbar { flex-direction: column; }
.top-actions { justify-content: flex-start; }
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.canvas-wrap, svg { min-height: 560px; height: 560px; }
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<div class="eyebrow">GitData / architecture map</div>
<h1>项目知识图谱</h1>
<p class="subtitle">以当前源码为准的架构级视图:应用入口、领域服务、基础设施与运行时资源之间的依赖和数据流。</p>
</div>
<div class="top-actions">
<button class="button" id="resetView" type="button">重置视图</button>
<button class="button" id="themeToggle" type="button">切换主题</button>
</div>
</header>
<section class="stats" aria-label="图谱统计">
<div class="stat"><div class="stat-label">Workspace packages</div><div class="stat-value tabular">23</div></div>
<div class="stat"><div class="stat-label">Rust files</div><div class="stat-value tabular">918</div></div>
<div class="stat"><div class="stat-label">Rust lines</div><div class="stat-value tabular">81.8k</div></div>
<div class="stat"><div class="stat-label">OpenAPI paths / operations</div><div class="stat-value tabular">283 / 409</div></div>
</section>
<section class="workspace">
<div class="graph-card">
<div class="toolbar">
<label class="search"><span aria-hidden="true">⌕</span><input id="search" type="search" placeholder="搜索 crate、服务或文件…" aria-label="搜索图谱"></label>
<div class="filters" id="filters" aria-label="显示分层"></div>
<button class="button" id="zoomOut" type="button" aria-label="缩小">−</button>
<button class="button" id="zoomIn" type="button" aria-label="放大">+</button>
</div>
<div class="canvas-wrap">
<svg id="graph" viewBox="0 0 1280 820" role="img" aria-labelledby="graphTitle graphDesc">
<title id="graphTitle">GitData 架构知识图谱</title>
<desc id="graphDesc">展示应用、领域、基础设施和外部资源之间的依赖关系。点击节点查看详情。</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--line-strong)"></path></marker>
</defs>
<g id="viewport">
<g id="bands"></g>
<g id="edges"></g>
<g id="nodes"></g>
</g>
</svg>
<div class="canvas-hint">滚轮缩放 · 拖动节点 · 点击节点查看调用边界</div>
</div>
<div class="legend" aria-label="分层图例">
<span><i style="--legend:var(--app-line)"></i>应用入口</span>
<span><i style="--legend:var(--domain-line)"></i>业务领域</span>
<span><i style="--legend:var(--foundation-line)"></i>基础设施</span>
<span><i style="--legend:var(--resource-line)"></i>运行时资源</span>
<span>实线:依赖 · 虚线:连接运行时资源</span>
</div>
</div>
<aside class="detail-card" id="detail" aria-live="polite"></aside>
</section>
<footer class="footer">
<span>数据来源:当前工作区源码、Cargo metadata、config/default.toml、openapi.json。</span>
<span>生成时间:2026-09-14 · commit <code>06505aa8</code></span>
</footer>
</main>
<script>
const graphData = {
layers: [
{ id: "apps", name: "应用入口", band: "var(--app)", line: "var(--app-line)" },
{ id: "domains", name: "业务领域", band: "var(--domain)", line: "var(--domain-line)" },
{ id: "foundation", name: "基础设施", band: "var(--foundation)", line: "var(--foundation-line)" },
{ id: "resources", name: "运行时资源", band: "var(--resource)", line: "var(--resource-line)" }
],
nodes: [
{ id: "apihost", name: "apihost", layer: "apps", type: "binary", summary: "主 API 进程:聚合 REST、GraphQL、页面、Swagger 和健康检查,并启动 Chat 与注销后台任务。", files: ["app/apihost/src/main.rs"] },
{ id: "githost", name: "githost", layer: "apps", type: "binary", summary: "Git 数据面进程:Smart HTTP 与 SSH 双监听,接收 clone/fetch/push。", files: ["app/githost/src/main.rs", "lib/git/src/smart/handler.rs"] },
{ id: "synchost", name: "synchost", layer: "apps", type: "binary", summary: "Git 元数据同步 worker,从 Redis LIST 消费 post-receive 任务。", files: ["app/synchost/src/main.rs", "lib/git/src/sync/worker.rs"] },
{ id: "im", name: "im", layer: "apps", type: "binary", summary: "独立即时通信网关,负责 HTTP/1 upgrade 和 WebSocket 会话。", files: ["app/im/src/main.rs", "app/im/src/gateway.rs", "app/im/src/socket.rs"] },
{ id: "mails", name: "mails", layer: "apps", type: "binary", summary: "邮件队列消费者,连接 Redis Pub/Sub 和 SMTP。", files: ["app/mails/src/main.rs", "lib/email/src/runtime.rs"] },
{ id: "migrate", name: "migrate", layer: "apps", type: "binary", summary: "一次性数据库 schema 同步入口。", files: ["app/migrate/src/main.rs", "lib/models/src/migrate/mod.rs"] },
{ id: "openapi", name: "openapi", layer: "apps", type: "binary", summary: "生成或校验仓库根目录的 openapi.json。", files: ["app/openapi/src/main.rs", "lib/webapi/src/openapi.rs"] },
{ id: "webapi", name: "webapi", layer: "domains", type: "assembly", summary: "HTTP 装配层:合并所有领域路由,安装 Session、CORS、body limit、trace、metrics 和 SPA fallback。", files: ["lib/webapi/src/lib.rs", "lib/webapi/src/openapi.rs", "lib/webapi/src/metrics.rs"] },
{ id: "auth", name: "auth", layer: "domains", type: "domain", summary: "注册、登录、密码、邮箱验证、2FA、访问密钥、SSH key、通知和账户注销。", files: ["lib/auth/src/routes.rs", "lib/auth/src/services/login.rs", "lib/auth/src/services/two_factor.rs"] },
{ id: "user", name: "user", layer: "domains", type: "domain", summary: "用户资料、邮箱、头像、偏好、设备、会话和用户侧资源。", files: ["lib/user/src/routes.rs", "lib/user/src/services/service.rs"] },
{ id: "space", name: "space", layer: "domains", type: "domain", summary: "多租户 Space、成员、组、邀请、封禁、目录树和 ACL。", files: ["lib/space/src/routes.rs", "lib/space/src/share/authz.rs", "lib/space/src/services/service.rs"] },
{ id: "issues", name: "issues", layer: "domains", type: "domain", summary: "Board、Issue、评论、标签、里程碑、依赖、事件线和工时。", files: ["lib/issues/src/routes.rs", "lib/issues/src/services/issues.rs", "lib/issues/src/services/events.rs"] },
{ id: "pulls", name: "pulls", layer: "domains", type: "domain", summary: "Pull Request、Review、评论、检查、分支更新、锁定和合并。", files: ["lib/pulls/src/routes.rs", "lib/pulls/src/services/merge.rs", "lib/pulls/src/services/reviews.rs"] },
{ id: "git", name: "git", layer: "domains", type: "domain", summary: "仓库、分支、提交、Smart HTTP、SSH、LFS、GraphQL、保护规则和同步。", files: ["lib/git/src/routes.rs", "lib/git/src/service/mod.rs", "lib/git/src/smart/handler.rs", "lib/git/src/sync/worker.rs"] },
{ id: "chat", name: "chat", layer: "domains", type: "domain", summary: "房间、消息、线程、DM、附件、提醒、Webhook、实时事件和后台任务。", files: ["lib/chat/src/routes.rs", "lib/chat/src/services/messages.rs", "lib/chat/src/share/realtime.rs"] },
{ id: "config", name: "config", layer: "foundation", type: "foundation", summary: "AppConfig、Figment/TOML/APP_ 环境变量加载,以及各进程的 scoped config。", files: ["lib/config/src/lib.rs", "lib/config/src/loader.rs", "lib/config/src/scoped.rs", "config/default.toml"] },
{ id: "models", name: "models", layer: "foundation", type: "foundation", summary: "SeaORM 领域实体、schema sync 和用户、Space、Repo、Issue、PR、Chat 等持久化模型。", files: ["lib/models/src/lib.rs", "lib/models/src/migrate/mod.rs", "lib/models/schema_pre_sync.sql", "lib/models/schema_extras.sql"] },
{ id: "export", name: "export", layer: "foundation", type: "foundation", summary: "共享 AppState、数据库读写路由、Redis、S3、统一响应、健康检查、通知和 telemetry。", files: ["lib/export/src/lib.rs", "lib/export/src/database.rs", "lib/export/src/redis.rs", "lib/export/src/rest.rs"] },
{ id: "session", name: "session", layer: "foundation", type: "foundation", summary: "Redis-backed axum session、用户上下文、密码版本和通用 SessionGuard。", files: ["lib/session/src/store.rs", "lib/session/src/share/guard.rs", "lib/session/src/share/session.rs"] },
{ id: "storage", name: "storage", layer: "foundation", type: "foundation", summary: "S3 兼容对象存储封装,支持流式传输、multipart 和 presigned URL。", files: ["lib/storage/src/lib.rs", "lib/storage/src/s3.rs"] },
{ id: "email", name: "email", layer: "foundation", type: "foundation", summary: "SMTP transport、模板渲染、Redis 邮件队列和 subscriber 生命周期。", files: ["lib/email/src/service.rs", "lib/email/src/queue.rs", "lib/email/src/runtime.rs"] },
{ id: "cache", name: "cache", layer: "foundation", type: "foundation", summary: "oxcache 两级缓存:本地 L1 + Redis L2。主要服务 Git 和 synchost。", files: ["lib/cache/src/lib.rs", "lib/cache/src/tiered.rs"] },
{ id: "pow-solver", name: "pow-solver", layer: "foundation", type: "foundation", summary: "SHA-256 PoW challenge 的生成、校验和 WASM 求解导出。", files: ["lib/pow-solver/src/challenge.rs", "lib/pow-solver/src/wasm.rs"] },
{ id: "postgres", name: "PostgreSQL", layer: "resources", type: "runtime", summary: "业务事实源:用户、权限、Git 元数据、Issue/PR/Chat 和 session 影子记录。", files: ["config/default.toml", "lib/export/src/database.rs"] },
{ id: "redis", name: "Redis / Valkey", layer: "resources", type: "runtime", summary: "Session、配额、Pub/Sub、邮件队列、Git 同步 LIST、锁和实时事件。", files: ["config/default.toml", "lib/export/src/redis.rs", "lib/git/src/sync/queue.rs"] },
{ id: "s3", name: "S3 compatible", layer: "resources", type: "runtime", summary: "头像、空间图片、Chat 附件、LFS 等二进制对象。", files: ["config/default.toml", "lib/storage/src/s3.rs"] },
{ id: "repo-fs", name: "bare repository FS", layer: "resources", type: "runtime", summary: "Git 裸仓库和对象池所在文件系统;githost 与 synchost 共享挂载。", files: ["config/default.toml", "chart/templates/repos-pvc.yaml", "lib/git/src/context/facade.rs"] },
{ id: "smtp", name: "SMTP", layer: "resources", type: "runtime", summary: "验证码、邀请、密码重置和通知邮件的外部传输端。", files: ["config/default.toml", "lib/email/src/transport.rs"] },
{ id: "web-assets", name: "web crate assets", layer: "resources", type: "runtime", summary: "外部固定 commit 的 web crate 提供内嵌前端静态资源,webapi 负责 SPA fallback 和缓存。", files: ["lib/webapi/Cargo.toml", "lib/webapi/src/spa.rs"] }
],
edges: [
["apihost", "webapi", "depends"], ["apihost", "auth", "starts"], ["apihost", "chat", "starts"], ["apihost", "config", "depends"], ["apihost", "export", "depends"], ["apihost", "session", "depends"],
["githost", "git", "depends"], ["githost", "export", "depends"], ["githost", "config", "depends"], ["githost", "repo-fs", "connects"], ["synchost", "git", "depends"], ["synchost", "cache", "depends"], ["synchost", "export", "depends"], ["synchost", "config", "depends"], ["synchost", "repo-fs", "connects"],
["im", "chat", "depends"], ["im", "session", "depends"], ["im", "export", "depends"], ["im", "config", "depends"], ["mails", "email", "depends"], ["mails", "export", "depends"], ["mails", "config", "depends"], ["mails", "smtp", "connects"], ["migrate", "models", "depends"], ["migrate", "config", "depends"], ["openapi", "webapi", "depends"],
["webapi", "auth", "depends"], ["webapi", "chat", "depends"], ["webapi", "issues", "depends"], ["webapi", "pulls", "depends"], ["webapi", "space", "depends"], ["webapi", "user", "depends"], ["webapi", "git", "depends"], ["webapi", "session", "depends"], ["webapi", "export", "depends"], ["webapi", "web-assets", "connects"],
["auth", "session", "depends"], ["auth", "export", "depends"], ["auth", "models", "depends"], ["auth", "config", "depends"], ["auth", "email", "depends"], ["auth", "space", "depends"], ["auth", "pow-solver", "depends"],
["user", "session", "depends"], ["user", "export", "depends"], ["user", "models", "depends"], ["user", "space", "depends"], ["user", "storage", "depends"], ["space", "session", "depends"], ["space", "export", "depends"], ["space", "models", "depends"], ["space", "email", "depends"], ["space", "storage", "depends"],
["issues", "session", "depends"], ["issues", "export", "depends"], ["issues", "models", "depends"], ["issues", "space", "depends"], ["issues", "email", "depends"], ["pulls", "git", "depends"], ["pulls", "issues", "depends"], ["pulls", "space", "depends"], ["pulls", "session", "depends"], ["pulls", "export", "depends"], ["pulls", "models", "depends"],
["git", "export", "depends"], ["git", "config", "depends"], ["git", "models", "depends"], ["git", "space", "depends"], ["git", "session", "depends"], ["git", "cache", "depends"], ["chat", "session", "depends"], ["chat", "export", "depends"], ["chat", "models", "depends"], ["chat", "space", "depends"], ["chat", "storage", "depends"],
["export", "config", "depends"], ["export", "email", "depends"], ["export", "storage", "depends"], ["export", "postgres", "connects"], ["export", "redis", "connects"], ["session", "redis", "connects"], ["models", "postgres", "connects"], ["storage", "s3", "connects"], ["email", "redis", "connects"], ["email", "smtp", "connects"], ["cache", "redis", "connects"]
]
};
const svg = document.getElementById("graph");
const viewport = document.getElementById("viewport");
const bands = document.getElementById("bands");
const edgesLayer = document.getElementById("edges");
const nodesLayer = document.getElementById("nodes");
const detail = document.getElementById("detail");
const search = document.getElementById("search");
const nodeMap = new Map(graphData.nodes.map(node => [node.id, node]));
const layerMap = new Map(graphData.layers.map(layer => [layer.id, layer]));
const edgeData = graphData.edges.map(([source, target, type]) => ({ source, target, type }));
const positions = new Map();
const columnX = { apps: 155, domains: 455, foundation: 755, resources: 1055 };
const bandWidth = 245;
let selectedId = null;
let scale = 1;
let panX = 0;
let panY = 0;
let dragState = null;
let enabledLayers = new Set(graphData.layers.map(layer => layer.id));
for (const layer of graphData.layers) {
const nodes = graphData.nodes.filter(node => node.layer === layer.id);
nodes.forEach((node, index) => positions.set(node.id, { x: columnX[layer.id], y: 92 + index * 82 }));
const band = document.createElementNS("http://www.w3.org/2000/svg", "rect");
band.setAttribute("x", columnX[layer.id] - bandWidth / 2);
band.setAttribute("y", 42);
band.setAttribute("width", bandWidth);
band.setAttribute("height", 708);
band.setAttribute("rx", 18);
band.classList.add("layer-band");
band.style.setProperty("--band", layer.band);
band.style.setProperty("--band-line", layer.line);
bands.appendChild(band);
const title = document.createElementNS("http://www.w3.org/2000/svg", "text");
title.setAttribute("x", columnX[layer.id] - bandWidth / 2 + 18);
title.setAttribute("y", 70);
title.classList.add("layer-title");
title.textContent = layer.name;
bands.appendChild(title);
}
function nodeStyle(node) {
const layer = layerMap.get(node.layer);
return { fill: layer.band, line: layer.line };
}
function makeNode(node) {
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.classList.add("node");
group.dataset.id = node.id;
group.setAttribute("tabindex", "0");
group.setAttribute("role", "button");
group.setAttribute("aria-label", `${node.name}:${node.summary}`);
const style = nodeStyle(node);
group.style.setProperty("--node-fill", style.fill);
group.style.setProperty("--node-line", style.line);
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", -105); rect.setAttribute("y", -27); rect.setAttribute("width", 210); rect.setAttribute("height", 54); rect.setAttribute("rx", 12);
group.appendChild(rect);
const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
dot.setAttribute("cx", -88); dot.setAttribute("cy", -9); dot.setAttribute("r", 5); dot.classList.add("node-dot"); group.appendChild(dot);
const title = document.createElementNS("http://www.w3.org/2000/svg", "text");
title.setAttribute("x", -75); title.setAttribute("y", -5); title.classList.add("node-title"); title.textContent = node.name; group.appendChild(title);
const type = document.createElementNS("http://www.w3.org/2000/svg", "text");
type.setAttribute("x", -75); type.setAttribute("y", 15); type.classList.add("node-type"); type.textContent = node.type; group.appendChild(type);
group.addEventListener("click", event => { if (!dragState || !dragState.moved) selectNode(node.id); event.stopPropagation(); });
group.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectNode(node.id); } });
group.addEventListener("pointerdown", event => startDrag(event, node.id, group));
nodesLayer.appendChild(group);
return group;
}
const nodeElements = new Map(graphData.nodes.map(node => [node.id, makeNode(node)]));
const edgeElements = edgeData.map(edge => {
const line = document.createElementNS("http://www.w3.org/2000/svg", "path");
line.classList.add("edge"); line.dataset.source = edge.source; line.dataset.target = edge.target; line.dataset.type = edge.type; line.setAttribute("marker-end", "url(#arrow)");
edgesLayer.appendChild(line); return line;
});
function edgePath(source, target) {
const a = positions.get(source), b = positions.get(target);
const dx = b.x - a.x;
const bend = Math.max(38, Math.abs(dx) * .28);
const sign = dx >= 0 ? 1 : -1;
return `M ${a.x + sign * 105} ${a.y} C ${a.x + sign * bend} ${a.y}, ${b.x - sign * bend} ${b.y}, ${b.x - sign * 105} ${b.y}`;
}
function redraw() {
for (const node of graphData.nodes) nodeElements.get(node.id).setAttribute("transform", `translate(${positions.get(node.id).x} ${positions.get(node.id).y})`);
edgeElements.forEach((element, index) => element.setAttribute("d", edgePath(edgeData[index].source, edgeData[index].target)));
viewport.setAttribute("transform", `translate(${panX} ${panY}) scale(${scale})`);
applyState();
}
function connectedTo(id) {
const ids = new Set([id]);
edgeData.forEach(edge => { if (edge.source === id) ids.add(edge.target); if (edge.target === id) ids.add(edge.source); });
return ids;
}
function applyState() {
const query = search.value.trim().toLowerCase();
const matches = new Set(graphData.nodes.filter(node => `${node.name} ${node.type} ${node.summary} ${node.files.join(" ")}`.toLowerCase().includes(query)).map(node => node.id));
const related = selectedId ? connectedTo(selectedId) : null;
graphData.nodes.forEach(node => {
const element = nodeElements.get(node.id);
const visible = enabledLayers.has(node.layer);
element.classList.toggle("is-dim", !visible || (query.length > 0 && !matches.has(node.id)) || (related && !related.has(node.id)));
element.classList.toggle("is-match", query.length > 0 && matches.has(node.id));
element.classList.toggle("is-selected", selectedId === node.id);
element.style.pointerEvents = visible ? "auto" : "none";
});
edgeData.forEach((edge, index) => {
const element = edgeElements[index];
const visible = enabledLayers.has(nodeMap.get(edge.source).layer) && enabledLayers.has(nodeMap.get(edge.target).layer);
element.classList.toggle("is-dim", !visible || (related && !(related.has(edge.source) && related.has(edge.target))));
});
const count = matches.size;
search.setAttribute("aria-label", query ? `搜索图谱,匹配 ${count} 个节点` : "搜索图谱");
}
function detailHtml(node) {
const incoming = edgeData.filter(edge => edge.target === node.id).map(edge => nodeMap.get(edge.source));
const outgoing = edgeData.filter(edge => edge.source === node.id).map(edge => nodeMap.get(edge.target));
const unique = list => [...new Map(list.map(item => [item.id, item])).values()];
const links = (title, list) => list.length ? `<div class="detail-section"><h3>${title}</h3><ul class="link-list">${unique(list).map(item => `<li><button type="button" data-jump="${item.id}">${item.name}<span class="text-muted"> · ${item.type}</span></button></li>`).join("")}</ul></div>` : "";
return `<div class="detail-kicker">${layerMap.get(node.layer).name} · ${node.type}</div><h2>${node.name}</h2><p class="detail-summary">${node.summary}</p><div class="detail-section"><h3>关键文件</h3><ul class="file-list">${node.files.map(file => `<li><code>${file}</code></li>`).join("")}</ul></div>${links("依赖 / 下游", outgoing)}${links("被使用 / 上游", incoming)}`;
}
function selectNode(id) {
selectedId = id;
const node = nodeMap.get(id);
detail.innerHTML = detailHtml(node);
detail.querySelectorAll("[data-jump]").forEach(button => button.addEventListener("click", () => selectNode(button.dataset.jump)));
applyState();
}
function resetView() {
selectedId = null; search.value = ""; scale = 1; panX = 0; panY = 0; enabledLayers = new Set(graphData.layers.map(layer => layer.id));
document.querySelectorAll(".filter-button").forEach(button => { button.setAttribute("aria-pressed", "true"); });
detail.innerHTML = `<div class="empty-detail"><div><strong>选择一个节点</strong><br>查看职责、关键文件和上下游关系。</div></div>`;
redraw();
}
function makeFilters() {
const container = document.getElementById("filters");
graphData.layers.forEach(layer => {
const button = document.createElement("button"); button.type = "button"; button.className = "filter-button"; button.textContent = layer.name; button.setAttribute("aria-pressed", "true");
button.addEventListener("click", () => { const enabled = enabledLayers.has(layer.id); enabled ? enabledLayers.delete(layer.id) : enabledLayers.add(layer.id); button.setAttribute("aria-pressed", String(!enabled)); applyState(); });
container.appendChild(button);
});
}
function zoomAt(next, cx = 640, cy = 410) {
const beforeX = (cx - panX) / scale, beforeY = (cy - panY) / scale;
scale = Math.max(.55, Math.min(1.8, next)); panX = cx - beforeX * scale; panY = cy - beforeY * scale; redraw();
}
function svgPoint(event) { const rect = svg.getBoundingClientRect(); return { x: (event.clientX - rect.left) * 1280 / rect.width, y: (event.clientY - rect.top) * 820 / rect.height }; }
function startDrag(event, id, element) { element.setPointerCapture(event.pointerId); const point = svgPoint(event), pos = positions.get(id); dragState = { id, element, startX: point.x, startY: point.y, originalX: pos.x, originalY: pos.y, moved: false }; }
function moveDrag(event) { if (!dragState) return; const point = svgPoint(event), dx = (point.x - dragState.startX) / scale, dy = (point.y - dragState.startY) / scale; if (Math.abs(dx) + Math.abs(dy) > 3) dragState.moved = true; positions.set(dragState.id, { x: dragState.originalX + dx, y: dragState.originalY + dy }); redraw(); }
function endDrag() { dragState = null; }
search.addEventListener("input", applyState);
document.getElementById("zoomIn").addEventListener("click", () => zoomAt(scale + .15));
document.getElementById("zoomOut").addEventListener("click", () => zoomAt(scale - .15));
document.getElementById("resetView").addEventListener("click", resetView);
document.getElementById("themeToggle").addEventListener("click", () => { document.body.dataset.theme = document.body.dataset.theme === "dark" ? "light" : "dark"; });
svg.addEventListener("wheel", event => { event.preventDefault(); const point = svgPoint(event); zoomAt(scale + (event.deltaY < 0 ? .1 : -.1), point.x, point.y); }, { passive: false });
svg.addEventListener("pointermove", moveDrag);
svg.addEventListener("pointerup", endDrag);
svg.addEventListener("pointercancel", endDrag);
svg.addEventListener("click", event => { if (event.target === svg) { selectedId = null; detail.innerHTML = `<div class="empty-detail"><div><strong>选择一个节点</strong><br>查看职责、关键文件和上下游关系。</div></div>`; applyState(); } });
makeFilters();
resetView();
</script>
</body>
</html>