|
@@ -0,0 +1,1100 @@
|
|
|
|
|
+#!/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()
|