| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- 将匹配图谱数据可视化为交互式HTML文件
- 输入:match_graph目录下的JSON文件
- 输出:单个HTML文件,包含所有帖子的图谱,可通过Tab切换
- """
- import json
- from pathlib import Path
- from typing import Dict, List
- import sys
- # 添加项目根目录到路径
- project_root = Path(__file__).parent.parent.parent
- sys.path.insert(0, str(project_root))
- from script.data_processing.path_config import PathConfig
- HTML_TEMPLATE = '''<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>匹配图谱可视化</title>
- <script src="https://d3js.org/d3.v7.min.js"></script>
- <style>
- * {{
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }}
- body {{
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
- background: #1a1a2e;
- color: #eee;
- overflow: hidden;
- }}
- #container {{
- display: flex;
- height: 100vh;
- flex-direction: column;
- }}
- /* Tab样式 */
- .tabs {{
- display: flex;
- background: #0f3460;
- padding: 0 20px;
- overflow-x: auto;
- flex-shrink: 0;
- }}
- .tab {{
- padding: 12px 20px;
- cursor: pointer;
- border-bottom: 3px solid transparent;
- white-space: nowrap;
- font-size: 13px;
- color: #888;
- transition: all 0.2s;
- }}
- .tab:hover {{
- color: #fff;
- background: rgba(255,255,255,0.05);
- }}
- .tab.active {{
- color: #e94560;
- border-bottom-color: #e94560;
- background: rgba(233, 69, 96, 0.1);
- }}
- /* 主内容区 */
- .main-content {{
- display: flex;
- flex: 1;
- overflow: hidden;
- }}
- #graph {{
- flex: 1;
- position: relative;
- }}
- #sidebar {{
- width: 280px;
- background: #16213e;
- padding: 15px;
- overflow-y: auto;
- border-left: 1px solid #0f3460;
- }}
- h1 {{
- font-size: 15px;
- margin-bottom: 10px;
- color: #e94560;
- }}
- h2 {{
- font-size: 12px;
- margin: 10px 0 6px;
- color: #0f9b8e;
- }}
- .legend {{
- margin-top: 10px;
- }}
- .legend-grid {{
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 4px 8px;
- }}
- .legend-item {{
- display: flex;
- align-items: center;
- font-size: 11px;
- }}
- .legend-color {{
- width: 12px;
- height: 12px;
- border-radius: 50%;
- margin-right: 6px;
- flex-shrink: 0;
- }}
- .legend-line {{
- width: 20px;
- height: 3px;
- margin-right: 6px;
- flex-shrink: 0;
- }}
- .detail-panel {{
- margin-top: 20px;
- padding: 15px;
- background: #0f3460;
- border-radius: 8px;
- display: none;
- }}
- .detail-panel.active {{
- display: block;
- }}
- .detail-panel h3 {{
- font-size: 14px;
- margin-bottom: 10px;
- color: #e94560;
- }}
- .detail-panel p {{
- font-size: 12px;
- line-height: 1.6;
- color: #ccc;
- margin: 5px 0;
- }}
- .detail-panel .label {{
- color: #888;
- }}
- .detail-panel .close-btn {{
- position: absolute;
- top: 10px;
- right: 10px;
- background: none;
- border: none;
- color: #888;
- cursor: pointer;
- font-size: 16px;
- }}
- .detail-panel .close-btn:hover {{
- color: #e94560;
- }}
- .detail-panel-wrapper {{
- position: relative;
- }}
- .similarity-score {{
- background: #e94560;
- color: #fff;
- padding: 2px 6px;
- border-radius: 4px;
- font-weight: bold;
- }}
- .edge-description {{
- background: #1a1a2e;
- padding: 10px;
- border-radius: 4px;
- margin-top: 8px;
- font-size: 11px;
- line-height: 1.5;
- }}
- svg {{
- width: 100%;
- height: 100%;
- }}
- .node {{
- cursor: pointer;
- }}
- .node circle, .node rect {{
- stroke-width: 3px;
- }}
- .node .post-node {{
- stroke: #fff;
- stroke-dasharray: 4,2;
- }}
- .node .persona-node {{
- stroke: #fff;
- }}
- .node text {{
- font-size: 11px;
- fill: #fff;
- pointer-events: none;
- }}
- .link {{
- stroke-opacity: 0.7;
- }}
- .link-hitarea {{
- stroke: transparent;
- stroke-width: 15px;
- cursor: pointer;
- fill: none;
- }}
- .link-hitarea:hover + .link {{
- stroke-opacity: 1;
- stroke-width: 3px;
- }}
- .edge-label {{
- font-size: 10px;
- fill: #fff;
- pointer-events: none;
- text-anchor: middle;
- }}
- .edge-label-bg {{
- fill: rgba(0,0,0,0.7);
- }}
- .link.match {{
- stroke: #e94560;
- stroke-dasharray: 5,5;
- }}
- .link.category-cross {{
- stroke: #2ecc71;
- }}
- .link.category-intra {{
- stroke: #27ae60;
- stroke-dasharray: 3,3;
- }}
- .link.tag-cooccur {{
- stroke: #f39c12;
- }}
- .link.belong {{
- stroke: #9b59b6;
- }}
- .link.contain {{
- stroke: #8e44ad;
- stroke-dasharray: 2,2;
- }}
- /* 镜像边样式(虚线,颜色与原边相同) */
- .link.mirror-category-cross {{
- stroke: #2ecc71;
- stroke-dasharray: 6,3;
- }}
- .link.mirror-category-intra {{
- stroke: #27ae60;
- stroke-dasharray: 6,3;
- }}
- .link.mirror-tag-cooccur {{
- stroke: #f39c12;
- stroke-dasharray: 6,3;
- }}
- .link.mirror-belong {{
- stroke: #9b59b6;
- stroke-dasharray: 6,3;
- }}
- .link.mirror-contain {{
- stroke: #8e44ad;
- stroke-dasharray: 6,3;
- }}
- .tooltip {{
- position: absolute;
- background: rgba(0,0,0,0.9);
- color: #fff;
- padding: 10px 15px;
- border-radius: 6px;
- font-size: 12px;
- pointer-events: none;
- max-width: 300px;
- z-index: 1000;
- display: none;
- }}
- .controls {{
- position: absolute;
- top: 20px;
- left: 20px;
- background: rgba(22, 33, 62, 0.9);
- padding: 15px;
- border-radius: 8px;
- z-index: 100;
- }}
- .controls button {{
- background: #0f3460;
- color: #fff;
- border: none;
- padding: 8px 15px;
- margin: 5px;
- border-radius: 4px;
- cursor: pointer;
- font-size: 12px;
- }}
- .controls button:hover {{
- background: #e94560;
- }}
- </style>
- </head>
- <body>
- <div id="container">
- <div class="tabs" id="tabs">
- {tabs_html}
- </div>
- <div class="main-content">
- <div id="graph">
- <div class="controls">
- <button onclick="resetZoom()">重置视图</button>
- <button onclick="toggleLabels()">切换标签</button>
- </div>
- <div class="tooltip" id="tooltip"></div>
- </div>
- <div id="sidebar">
- <h1>匹配图谱</h1>
- <div class="detail-panel active" id="detailPanel">
- <h3 id="detailTitle">点击节点或边查看详情</h3>
- <div id="detailContent">
- <p style="color: #888; font-size: 11px;">点击图中的节点或边,这里会显示详细信息</p>
- </div>
- </div>
- <div class="legend">
- <h2>节点</h2>
- <div class="legend-grid">
- <div class="legend-item">
- <div class="legend-color" style="background: #666; border: 2px dashed #fff;"></div>
- <span>帖子(虚线)</span>
- </div>
- <div class="legend-item">
- <div class="legend-color" style="background: #666; border: 2px solid #fff;"></div>
- <span>人设(实线)</span>
- </div>
- <div class="legend-item">
- <div class="legend-color" style="background: #666; border-radius: 50%;"></div>
- <span>标签(圆)</span>
- </div>
- <div class="legend-item">
- <div class="legend-color" style="background: #666; border-radius: 2px;"></div>
- <span>分类(方)</span>
- </div>
- <div class="legend-item">
- <div class="legend-color" style="background: #666; opacity: 1;"></div>
- <span>直接匹配</span>
- </div>
- <div class="legend-item">
- <div class="legend-color" style="background: #666; opacity: 0.5;"></div>
- <span>扩展节点</span>
- </div>
- <div class="legend-item">
- <div class="legend-color" style="background: #f39c12;"></div>
- <span>灵感点</span>
- </div>
- <div class="legend-item">
- <div class="legend-color" style="background: #3498db;"></div>
- <span>目的点</span>
- </div>
- <div class="legend-item">
- <div class="legend-color" style="background: #9b59b6;"></div>
- <span>关键点</span>
- </div>
- </div>
- <h2>边(人设/实线)</h2>
- <div class="legend-grid">
- <div class="legend-item">
- <div class="legend-line" style="background: #e94560;"></div>
- <span>匹配</span>
- </div>
- <div class="legend-item">
- <div class="legend-line" style="background: #2ecc71;"></div>
- <span>分类共现(跨)</span>
- </div>
- <div class="legend-item">
- <div class="legend-line" style="background: #27ae60;"></div>
- <span>分类共现(内)</span>
- </div>
- <div class="legend-item">
- <div class="legend-line" style="background: #f39c12;"></div>
- <span>标签共现</span>
- </div>
- <div class="legend-item">
- <div class="legend-line" style="background: #9b59b6;"></div>
- <span>属于</span>
- </div>
- <div class="legend-item">
- <div class="legend-line" style="background: #8e44ad;"></div>
- <span>包含</span>
- </div>
- </div>
- <h2>镜像边(帖子/虚线)</h2>
- <div class="legend-grid">
- <div class="legend-item">
- <div class="legend-line" style="background: repeating-linear-gradient(90deg, #2ecc71, #2ecc71 6px, transparent 6px, transparent 9px);"></div>
- <span>分类共现</span>
- </div>
- <div class="legend-item">
- <div class="legend-line" style="background: repeating-linear-gradient(90deg, #f39c12, #f39c12 6px, transparent 6px, transparent 9px);"></div>
- <span>标签共现</span>
- </div>
- <div class="legend-item">
- <div class="legend-line" style="background: repeating-linear-gradient(90deg, #9b59b6, #9b59b6 6px, transparent 6px, transparent 9px);"></div>
- <span>属于</span>
- </div>
- <div class="legend-item">
- <div class="legend-line" style="background: repeating-linear-gradient(90deg, #8e44ad, #8e44ad 6px, transparent 6px, transparent 9px);"></div>
- <span>包含</span>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <script>
- // 所有帖子的图谱数据
- const allGraphData = {all_graph_data};
- // 当前选中的帖子索引
- let currentIndex = 0;
- let simulation = null;
- let svg = null;
- let g = null;
- let zoom = null;
- let showLabels = true;
- // 初始化
- function init() {{
- const container = document.getElementById("graph");
- const width = container.clientWidth;
- const height = container.clientHeight;
- svg = d3.select("#graph")
- .append("svg")
- .attr("width", width)
- .attr("height", height);
- g = svg.append("g");
- zoom = d3.zoom()
- .scaleExtent([0.1, 4])
- .on("zoom", (event) => {{
- g.attr("transform", event.transform);
- }});
- svg.call(zoom);
- // 绑定Tab点击事件
- document.querySelectorAll(".tab").forEach((tab, index) => {{
- tab.addEventListener("click", () => switchTab(index));
- }});
- // 显示第一个帖子
- switchTab(0);
- }}
- // 切换Tab
- function switchTab(index) {{
- currentIndex = index;
- // 更新Tab样式
- document.querySelectorAll(".tab").forEach((tab, i) => {{
- tab.classList.toggle("active", i === index);
- }});
- // 更新图谱
- renderGraph(allGraphData[index]);
- }}
- // 渲染图谱
- function renderGraph(data) {{
- // 清空现有图谱
- g.selectAll("*").remove();
- if (simulation) {{
- simulation.stop();
- }}
- const container = document.getElementById("graph");
- const width = container.clientWidth;
- const height = container.clientHeight;
- // 准备数据
- const nodes = data.nodes.map(n => ({{
- ...n,
- id: n.节点ID,
- source: n.节点ID.startsWith("帖子_") ? "帖子" : "人设",
- level: n.节点层级
- }}));
- const links = data.edges.map(e => ({{
- ...e,
- source: e.源节点ID,
- target: e.目标节点ID,
- type: e.边类型
- }}));
- // 分离帖子节点和人设节点
- const postNodes = nodes.filter(n => n.source === "帖子");
- const personaNodes = nodes.filter(n => n.source === "人设");
- const matchLinks = links.filter(l => l.type === "匹配");
- // 构建帖子节点到人设节点的映射
- const postToPersona = {{}};
- const personaToPost = {{}};
- matchLinks.forEach(l => {{
- const sid = typeof l.source === "object" ? l.source.id : l.source;
- const tid = typeof l.target === "object" ? l.target.id : l.target;
- if (!postToPersona[sid]) postToPersona[sid] = [];
- postToPersona[sid].push(tid);
- if (!personaToPost[tid]) personaToPost[tid] = [];
- personaToPost[tid].push(sid);
- }});
- // 找出所有连通分量
- function findConnectedComponents(nodes, links) {{
- const nodeIds = new Set(nodes.map(n => n.id));
- const adj = {{}};
- nodeIds.forEach(id => adj[id] = []);
- links.forEach(l => {{
- const sid = typeof l.source === "object" ? l.source.id : l.source;
- const tid = typeof l.target === "object" ? l.target.id : l.target;
- if (nodeIds.has(sid) && nodeIds.has(tid)) {{
- adj[sid].push(tid);
- adj[tid].push(sid);
- }}
- }});
- const visited = new Set();
- const components = [];
- nodeIds.forEach(startId => {{
- if (visited.has(startId)) return;
- const component = [];
- const queue = [startId];
- while (queue.length > 0) {{
- const id = queue.shift();
- if (visited.has(id)) continue;
- visited.add(id);
- component.push(id);
- adj[id].forEach(neighbor => {{
- if (!visited.has(neighbor)) queue.push(neighbor);
- }});
- }}
- components.push(component);
- }});
- return components;
- }}
- // 按大小排序连通分量(大的在前)
- const components = findConnectedComponents(nodes, links)
- .sort((a, b) => b.length - a.length);
- console.log(`找到 ${{components.length}} 个连通分量`);
- // 为每个节点分配连通分量ID和分量内的X范围
- const nodeToComponent = {{}};
- const componentCenters = {{}};
- const componentBounds = {{}};
- const padding = 50; // 分量之间的间距
- const totalPadding = padding * (components.length - 1);
- const availableWidth = width - totalPadding - 100; // 留边距
- // 根据分量大小分配宽度
- const totalNodes = nodes.length;
- let currentX = 50; // 起始边距
- components.forEach((comp, i) => {{
- const compWidth = Math.max(150, (comp.length / totalNodes) * availableWidth);
- const centerX = currentX + compWidth / 2;
- componentCenters[i] = centerX;
- componentBounds[i] = {{ start: currentX, end: currentX + compWidth, width: compWidth }};
- comp.forEach(nodeId => {{
- nodeToComponent[nodeId] = i;
- }});
- currentX += compWidth + padding;
- }});
- // 使用重心法(Barycenter)减少边交叉
- // 迭代优化:交替调整两层节点的顺序
- const nodeTargetX = {{}};
- const personaXMap = {{}};
- // 对每个连通分量单独处理
- components.forEach((comp, compIdx) => {{
- const bounds = componentBounds[compIdx];
- const compPostNodes = postNodes.filter(n => nodeToComponent[n.id] === compIdx);
- const compPersonaNodes = personaNodes.filter(n => nodeToComponent[n.id] === compIdx);
- if (compPostNodes.length === 0 || compPersonaNodes.length === 0) {{
- // 没有匹配关系的分量,均匀分布
- const spacing = bounds.width / (comp.length + 1);
- comp.forEach((nodeId, i) => {{
- const node = nodes.find(n => n.id === nodeId);
- if (node) {{
- node.x = bounds.start + spacing * (i + 1);
- nodeTargetX[nodeId] = node.x;
- if (node.source === "人设") personaXMap[nodeId] = node.x;
- }}
- }});
- return;
- }}
- // 初始化:给人设节点一个初始顺序
- let personaOrder = compPersonaNodes.map((n, i) => ({{ node: n, order: i }}));
- // 迭代优化(3轮)
- for (let iter = 0; iter < 3; iter++) {{
- // 1. 根据人设节点位置,计算帖子节点的重心
- const postBarycenter = {{}};
- compPostNodes.forEach(pn => {{
- const matched = postToPersona[pn.id] || [];
- if (matched.length > 0) {{
- const avgOrder = matched.reduce((sum, pid) => {{
- const po = personaOrder.find(p => p.node.id === pid);
- return sum + (po ? po.order : 0);
- }}, 0) / matched.length;
- postBarycenter[pn.id] = avgOrder;
- }} else {{
- postBarycenter[pn.id] = 0;
- }}
- }});
- // 按重心排序帖子节点
- const sortedPosts = [...compPostNodes].sort((a, b) =>
- postBarycenter[a.id] - postBarycenter[b.id]
- );
- // 2. 根据帖子节点位置,重新计算人设节点的重心
- const personaBarycenter = {{}};
- compPersonaNodes.forEach(pn => {{
- const matched = personaToPost[pn.id] || [];
- if (matched.length > 0) {{
- const avgOrder = matched.reduce((sum, pid) => {{
- const idx = sortedPosts.findIndex(p => p.id === pid);
- return sum + (idx >= 0 ? idx : 0);
- }}, 0) / matched.length;
- personaBarycenter[pn.id] = avgOrder;
- }} else {{
- personaBarycenter[pn.id] = personaOrder.find(p => p.node.id === pn.id)?.order || 0;
- }}
- }});
- // 更新人设节点顺序
- personaOrder = compPersonaNodes
- .map(n => ({{ node: n, order: personaBarycenter[n.id] }}))
- .sort((a, b) => a.order - b.order)
- .map((item, i) => ({{ node: item.node, order: i }}));
- }}
- // 最终排序
- const finalPersonaOrder = personaOrder.map(p => p.node);
- const postBarycenter = {{}};
- compPostNodes.forEach(pn => {{
- const matched = postToPersona[pn.id] || [];
- if (matched.length > 0) {{
- const avgOrder = matched.reduce((sum, pid) => {{
- const idx = finalPersonaOrder.findIndex(n => n.id === pid);
- return sum + (idx >= 0 ? idx : 0);
- }}, 0) / matched.length;
- postBarycenter[pn.id] = avgOrder;
- }} else {{
- postBarycenter[pn.id] = 0;
- }}
- }});
- const finalPostOrder = [...compPostNodes].sort((a, b) =>
- postBarycenter[a.id] - postBarycenter[b.id]
- );
- // 设置位置
- const personaSpacing = bounds.width / (finalPersonaOrder.length + 1);
- finalPersonaOrder.forEach((n, i) => {{
- n.x = bounds.start + personaSpacing * (i + 1);
- nodeTargetX[n.id] = n.x;
- personaXMap[n.id] = n.x;
- }});
- const postSpacing = bounds.width / (finalPostOrder.length + 1);
- finalPostOrder.forEach((n, i) => {{
- // 帖子节点用重心位置(匹配人设的平均X)
- const matched = postToPersona[n.id] || [];
- if (matched.length > 0) {{
- const avgX = matched.reduce((sum, pid) => sum + (personaXMap[pid] || bounds.start + bounds.width/2), 0) / matched.length;
- n.x = avgX;
- }} else {{
- n.x = bounds.start + postSpacing * (i + 1);
- }}
- nodeTargetX[n.id] = n.x;
- }});
- }});
- // 节点颜色
- const levelColors = {{
- "灵感点": "#f39c12",
- "目的点": "#3498db",
- "关键点": "#9b59b6"
- }};
- // 两层Y坐标(带倾斜:右边高,左边低)
- const postBaseY = height * 0.25; // 帖子节点基准Y
- const personaBaseY = height * 0.7; // 人设节点基准Y
- const tiltAmount = height * 0.25; // 倾斜幅度(约14度)
- // 根据X位置计算Y(右边高,左边低)
- function getTiltedY(baseY, x) {{
- const tilt = tiltAmount * (0.5 - x / width);
- return baseY + tilt;
- }}
- // 力导向模拟
- simulation = d3.forceSimulation(nodes)
- .force("link", d3.forceLink(links).id(d => d.id).distance(120).strength(0.1))
- .force("charge", d3.forceManyBody().strength(-400)) // 更强的互斥
- // X方向:拉向目标位置,但允许被推开
- .force("x", d3.forceX(d => nodeTargetX[d.id] || width / 2).strength(0.15))
- // Y方向力:带倾斜
- .force("y", d3.forceY(d => {{
- const baseY = d.source === "帖子" ? postBaseY : personaBaseY;
- return getTiltedY(baseY, d.x || width / 2);
- }}).strength(0.4))
- .force("collision", d3.forceCollide().radius(50)); // 更大的碰撞半径
- // 边类型到CSS类的映射
- const edgeTypeClass = {{
- "匹配": "match",
- "分类共现(跨点)": "category-cross",
- "分类共现(点内)": "category-intra",
- "标签共现": "tag-cooccur",
- "属于": "belong",
- "包含": "contain",
- // 镜像边(帖子节点之间,虚线)
- "镜像_分类共现(跨点)": "mirror-category-cross",
- "镜像_分类共现(点内)": "mirror-category-intra",
- "镜像_标签共现": "mirror-tag-cooccur",
- "镜像_属于": "mirror-belong",
- "镜像_包含": "mirror-contain"
- }};
- // 创建边的容器
- const linkGroup = g.append("g").attr("class", "links");
- // 为每条边创建组
- const linkG = linkGroup.selectAll("g")
- .data(links)
- .join("g")
- .attr("class", "link-group");
- // 绘制点击热区(透明宽线)
- const linkHitarea = linkG.append("line")
- .attr("class", "link-hitarea");
- // 绘制可见的边
- const link = linkG.append("line")
- .attr("class", d => "link " + (edgeTypeClass[d.type] || "match"))
- .attr("stroke-width", d => d.type === "匹配" ? 2.5 : 1.5);
- // 为匹配边添加分数标签
- const edgeLabels = linkG.filter(d => d.type === "匹配" && d.边详情 && d.边详情.相似度)
- .append("g")
- .attr("class", "edge-label-group");
- edgeLabels.append("rect")
- .attr("class", "edge-label-bg")
- .attr("rx", 3)
- .attr("ry", 3);
- edgeLabels.append("text")
- .attr("class", "edge-label")
- .text(d => {{
- const score = d.边详情.相似度;
- return typeof score === "number" ? score.toFixed(2) : score;
- }});
- // 边的点击事件
- linkHitarea.on("click", (event, d) => {{
- event.stopPropagation();
- showEdgeInfo(d);
- }})
- .on("mouseover", function(event, d) {{
- d3.select(this.parentNode).select(".link")
- .attr("stroke-opacity", 1)
- .attr("stroke-width", 4);
- }})
- .on("mouseout", function(event, d) {{
- d3.select(this.parentNode).select(".link")
- .attr("stroke-opacity", 0.7)
- .attr("stroke-width", d.type === "匹配" ? 2.5 : 1.5);
- }});
- // 绘制节点
- const node = g.append("g")
- .selectAll("g")
- .data(nodes)
- .join("g")
- .attr("class", "node")
- .call(d3.drag()
- .on("start", dragstarted)
- .on("drag", dragged)
- .on("end", dragended));
- // 根据节点类型绘制不同形状:标签用圆形,分类用方形
- // 扩展节点用较低透明度表示
- node.each(function(d) {{
- const el = d3.select(this);
- const isExpanded = d.是否扩展 === true;
- const size = d.source === "帖子" ? 12 : (isExpanded ? 8 : 10);
- const fill = levelColors[d.level] || "#666";
- const nodeClass = d.source === "帖子" ? "post-node" : "persona-node";
- const opacity = isExpanded ? 0.5 : 1;
- if (d.节点类型 === "分类") {{
- // 方形
- el.append("rect")
- .attr("width", size * 2)
- .attr("height", size * 2)
- .attr("x", -size)
- .attr("y", -size)
- .attr("fill", fill)
- .attr("class", nodeClass)
- .attr("rx", 3)
- .attr("opacity", opacity);
- }} else {{
- // 圆形(标签)
- el.append("circle")
- .attr("r", size)
- .attr("fill", fill)
- .attr("class", nodeClass)
- .attr("opacity", opacity);
- }}
- }});
- const labels = node.append("text")
- .attr("dx", 15)
- .attr("dy", 4)
- .text(d => d.节点名称)
- .style("display", showLabels ? "block" : "none");
- // 工具提示
- const tooltip = d3.select("#tooltip");
- node.on("mouseover", (event, d) => {{
- tooltip.style("display", "block")
- .html(`<strong>${{d.节点名称}}</strong><br/>类型: ${{d.节点类型}}<br/>层级: ${{d.节点层级}}`);
- }})
- .on("mousemove", (event) => {{
- tooltip.style("left", (event.pageX + 15) + "px")
- .style("top", (event.pageY - 10) + "px");
- }})
- .on("mouseout", () => {{
- tooltip.style("display", "none");
- }})
- .on("click", (event, d) => {{
- showNodeInfo(d);
- }});
- // 更新位置
- simulation.on("tick", () => {{
- // 更新热区线
- linkHitarea
- .attr("x1", d => d.source.x)
- .attr("y1", d => d.source.y)
- .attr("x2", d => d.target.x)
- .attr("y2", d => d.target.y);
- // 更新可见边
- link
- .attr("x1", d => d.source.x)
- .attr("y1", d => d.source.y)
- .attr("x2", d => d.target.x)
- .attr("y2", d => d.target.y);
- // 更新边标签位置(放在边的中点)
- edgeLabels.attr("transform", d => {{
- const midX = (d.source.x + d.target.x) / 2;
- const midY = (d.source.y + d.target.y) / 2;
- return `translate(${{midX}},${{midY}})`;
- }});
- // 更新标签背景大小
- edgeLabels.each(function(d) {{
- const textEl = d3.select(this).select("text").node();
- if (textEl) {{
- const bbox = textEl.getBBox();
- d3.select(this).select("rect")
- .attr("x", bbox.x - 3)
- .attr("y", bbox.y - 1)
- .attr("width", bbox.width + 6)
- .attr("height", bbox.height + 2);
- }}
- }});
- node.attr("transform", d => `translate(${{d.x}},${{d.y}})`);
- }});
- // 拖拽函数
- function dragstarted(event, d) {{
- if (!event.active) simulation.alphaTarget(0.3).restart();
- d.fx = d.x;
- d.fy = d.y;
- }}
- function dragged(event, d) {{
- d.fx = event.x;
- d.fy = event.y;
- }}
- function dragended(event, d) {{
- if (!event.active) simulation.alphaTarget(0);
- d.fx = null;
- d.fy = null;
- }}
- }}
- // 控制函数
- function resetZoom() {{
- const container = document.getElementById("graph");
- const width = container.clientWidth;
- const height = container.clientHeight;
- svg.transition().duration(750).call(
- zoom.transform,
- d3.zoomIdentity.translate(width/2, height/2).scale(1).translate(-width/2, -height/2)
- );
- }}
- function toggleLabels() {{
- showLabels = !showLabels;
- g.selectAll(".node text").style("display", showLabels ? "block" : "none");
- }}
- function showNodeInfo(d) {{
- const panel = document.getElementById("detailPanel");
- panel.classList.add("active");
- document.getElementById("detailTitle").textContent = d.source === "帖子" ? "📌 帖子节点" : "👤 人设节点";
- let html = `
- <p><span class="label">节点ID:</span> ${{d.节点ID}}</p>
- <p><span class="label">名称:</span> <strong>${{d.节点名称}}</strong></p>
- <p><span class="label">类型:</span> ${{d.节点类型}}</p>
- <p><span class="label">层级:</span> ${{d.节点层级}}</p>
- `;
- if (d.权重) {{
- html += `<p><span class="label">权重:</span> ${{d.权重}}</p>`;
- }}
- if (d.所属分类 && d.所属分类.length > 0) {{
- html += `<p><span class="label">所属分类:</span> ${{d.所属分类.join(" > ")}}</p>`;
- }}
- if (d.帖子数) {{
- html += `<p><span class="label">帖子数:</span> ${{d.帖子数}}</p>`;
- }}
- document.getElementById("detailContent").innerHTML = html;
- }}
- function showEdgeInfo(d) {{
- const panel = document.getElementById("detailPanel");
- panel.classList.add("active");
- const sourceNode = typeof d.source === "object" ? d.source : {{ id: d.source }};
- const targetNode = typeof d.target === "object" ? d.target : {{ id: d.target }};
- // 判断是否为镜像边
- const isMirror = d.type.startsWith("镜像_");
- document.getElementById("detailTitle").textContent = isMirror ? "🪞 镜像边详情" : "🔗 边详情";
- let html = `
- <p><span class="label">边类型:</span> <strong>${{d.type}}</strong></p>
- <p><span class="label">源节点:</span> ${{sourceNode.节点名称 || sourceNode.id}}</p>
- <p><span class="label">目标节点:</span> ${{targetNode.节点名称 || targetNode.id}}</p>
- `;
- if (d.边详情) {{
- if (d.边详情.相似度 !== undefined) {{
- const score = typeof d.边详情.相似度 === "number" ? d.边详情.相似度.toFixed(2) : d.边详情.相似度;
- html += `<p><span class="label">相似度:</span> <span class="similarity-score">${{score}}</span></p>`;
- }}
- if (d.边详情.说明) {{
- html += `<p><span class="label">说明:</span></p><div class="edge-description">${{d.边详情.说明}}</div>`;
- }}
- if (d.边详情.共现次数 !== undefined) {{
- html += `<p><span class="label">共现次数:</span> ${{d.边详情.共现次数}}</p>`;
- }}
- // 镜像边特有信息
- if (d.边详情.原始边类型) {{
- html += `<p><span class="label">原始边类型:</span> ${{d.边详情.原始边类型}}</p>`;
- }}
- if (d.边详情.源人设节点) {{
- html += `<p><span class="label">源人设节点:</span> ${{d.边详情.源人设节点}}</p>`;
- }}
- if (d.边详情.目标人设节点) {{
- html += `<p><span class="label">目标人设节点:</span> ${{d.边详情.目标人设节点}}</p>`;
- }}
- }}
- document.getElementById("detailContent").innerHTML = html;
- }}
- function closeDetailPanel() {{
- document.getElementById("detailPanel").classList.remove("active");
- }}
- // 页面加载完成后初始化
- window.addEventListener("load", init);
- window.addEventListener("resize", () => {{
- if (currentIndex >= 0) {{
- renderGraph(allGraphData[currentIndex]);
- }}
- }});
- </script>
- </body>
- </html>
- '''
- def generate_combined_html(all_graph_data: List[Dict], output_file: Path):
- """
- 生成包含所有帖子图谱的HTML文件
- Args:
- all_graph_data: 所有帖子的图谱数据列表
- output_file: 输出文件路径
- """
- # 生成Tab HTML
- tabs_html = ""
- for i, data in enumerate(all_graph_data):
- post_title = data.get("postTitle", "")
- # 使用帖子标题,如果太长则截断
- if post_title:
- tab_name = post_title[:15] + "..." if len(post_title) > 15 else post_title
- else:
- tab_name = f"帖子 {i+1}"
- active_class = "active" if i == 0 else ""
- tabs_html += f'<div class="tab {active_class}" data-index="{i}">{tab_name}</div>\n'
- # 生成HTML
- html_content = HTML_TEMPLATE.format(
- tabs_html=tabs_html,
- all_graph_data=json.dumps(all_graph_data, ensure_ascii=False)
- )
- with open(output_file, "w", encoding="utf-8") as f:
- f.write(html_content)
- def main():
- # 使用路径配置
- config = PathConfig()
- print(f"账号: {config.account_name}")
- print(f"输出版本: {config.output_version}")
- print()
- # 输入目录
- match_graph_dir = config.intermediate_dir / "match_graph"
- # 输出文件
- output_file = config.intermediate_dir / "match_graph.html"
- print(f"输入目录: {match_graph_dir}")
- print(f"输出文件: {output_file}")
- print()
- # 读取所有匹配图谱文件
- graph_files = sorted(match_graph_dir.glob("*_match_graph.json"))
- print(f"找到 {len(graph_files)} 个匹配图谱文件")
- all_graph_data = []
- for i, graph_file in enumerate(graph_files, 1):
- print(f" [{i}/{len(graph_files)}] 读取: {graph_file.name}")
- with open(graph_file, "r", encoding="utf-8") as f:
- match_graph_data = json.load(f)
- # 提取需要的数据
- graph_data = {
- "postId": match_graph_data["说明"]["帖子ID"],
- "postTitle": match_graph_data["说明"].get("帖子标题", ""),
- "stats": match_graph_data["说明"]["统计"],
- "nodes": match_graph_data["节点列表"],
- "edges": match_graph_data["边列表"]
- }
- all_graph_data.append(graph_data)
- # 生成HTML
- print("\n生成HTML文件...")
- generate_combined_html(all_graph_data, output_file)
- print("\n" + "="*60)
- print("处理完成!")
- print(f"输出文件: {output_file}")
- if __name__ == "__main__":
- main()
|