|
@@ -0,0 +1,3666 @@
|
|
|
|
|
+import json
|
|
|
|
|
+import math
|
|
|
|
|
+import os
|
|
|
|
|
+import sys
|
|
|
|
|
+from datetime import datetime
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+from typing import Dict, List, Optional
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _build_selection_points_from_decode(decode_data: Dict) -> List[Dict]:
|
|
|
|
|
+ """将 examples 解构内容 JSON 转为可视化「帖子选题表」所需的选题点行。"""
|
|
|
|
|
+ result: List[Dict] = []
|
|
|
|
|
+ point_types = ["灵感点", "目的点", "关键点"]
|
|
|
|
|
+ for point_type in point_types:
|
|
|
|
|
+ points = decode_data.get(point_type, [])
|
|
|
|
|
+ if not isinstance(points, list):
|
|
|
|
|
+ continue
|
|
|
|
|
+ for item in points:
|
|
|
|
|
+ if not isinstance(item, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ title = item.get("选题点") or item.get("点") or ""
|
|
|
|
|
+ essence: List[str] = []
|
|
|
|
|
+ form: List[str] = []
|
|
|
|
|
+ intent: List[str] = []
|
|
|
|
|
+ for el in item.get("选题点元素") or []:
|
|
|
|
|
+ if not isinstance(el, dict):
|
|
|
|
|
+ continue
|
|
|
|
|
+ name = el.get("元素名称")
|
|
|
|
|
+ if not name:
|
|
|
|
|
+ continue
|
|
|
|
|
+ et = el.get("元素类型") or ""
|
|
|
|
|
+ if et == "实质":
|
|
|
|
|
+ essence.append(name)
|
|
|
|
|
+ elif et == "形式":
|
|
|
|
|
+ form.append(name)
|
|
|
|
|
+ elif et == "意图":
|
|
|
|
|
+ intent.append(name)
|
|
|
|
|
+ else:
|
|
|
|
|
+ essence.append(name)
|
|
|
|
|
+ result.append(
|
|
|
|
|
+ {
|
|
|
|
|
+ "类型": point_type,
|
|
|
|
|
+ "选题点": title,
|
|
|
|
|
+ "实质": essence,
|
|
|
|
|
+ "形式": form,
|
|
|
|
|
+ "意图": intent,
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def load_post_detail_for_visualization(account_name: str, post_id: str) -> Optional[Dict]:
|
|
|
|
|
+ """
|
|
|
|
|
+ 从 Agent 示例目录读取原始帖子与解构内容,供「待解构帖子详情」弹窗与侧边栏使用。
|
|
|
|
|
+ - post_data: input/{account}/原始数据/post_data/{post_id}.json
|
|
|
|
|
+ - 解构: input/{account}/原始数据/解构内容/{post_id}.json
|
|
|
|
|
+ """
|
|
|
|
|
+ base = Path(__file__).resolve().parent
|
|
|
|
|
+ post_path = base / "input" / account_name / "原始数据" / "post_data" / f"{post_id}.json"
|
|
|
|
|
+ decode_path = base / "input" / account_name / "原始数据" / "解构内容" / f"{post_id}.json"
|
|
|
|
|
+ try:
|
|
|
|
|
+ with open(post_path, "r", encoding="utf-8") as f:
|
|
|
|
|
+ post_data = json.load(f)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return None
|
|
|
|
|
+ decode_data: Dict = {}
|
|
|
|
|
+ try:
|
|
|
|
|
+ with open(decode_path, "r", encoding="utf-8") as f:
|
|
|
|
|
+ decode_data = json.load(f)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
|
|
+ out = dict(post_data)
|
|
|
|
|
+ out["选题点"] = _build_selection_points_from_decode(decode_data) if decode_data else []
|
|
|
|
|
+ pid = out.get("channel_content_id") or decode_data.get("帖子ID")
|
|
|
|
|
+ if pid and not out.get("id"):
|
|
|
|
|
+ out["id"] = pid
|
|
|
|
|
+ return out
|
|
|
|
|
+
|
|
|
|
|
+def generate_all_in_one_visualization(
|
|
|
|
|
+ data_map: Dict[str, dict],
|
|
|
|
|
+ output_path: str,
|
|
|
|
|
+ account_name: str,
|
|
|
|
|
+ derivation_data: Dict[str, list] = None,
|
|
|
|
|
+ post_detail_map: Dict[str, dict] = None,
|
|
|
|
|
+ dimension_analyze_map: Dict[str, dict] = None,
|
|
|
|
|
+):
|
|
|
|
|
+ """
|
|
|
|
|
+ 将所有帖子的数据整合到一个 HTML 中,支持动态切换
|
|
|
|
|
+ data_map: { "文件名": json_data, ... }
|
|
|
|
|
+ derivation_data: { "文件名": 推导结果列表, ... }
|
|
|
|
|
+ post_detail_map: { "文件名": 帖子详情(含选题点),来自 load_post_detail_for_visualization }
|
|
|
|
|
+ dimension_analyze_map: { post_id: 整体推导维度分析 JSON(含 rounds.derived_dims 等)}
|
|
|
|
|
+ """
|
|
|
|
|
+ # 提取第一个帖子的数据作为默认展示
|
|
|
|
|
+ first_key = list(data_map.keys())[0]
|
|
|
|
|
+
|
|
|
|
|
+ # 将整个 data_map 转换为 JS 对象
|
|
|
|
|
+ json_data_js = json.dumps(data_map, ensure_ascii=False)
|
|
|
|
|
+
|
|
|
|
|
+ # 将推导数据转换为 JS 对象
|
|
|
|
|
+ if derivation_data is None:
|
|
|
|
|
+ derivation_data = {}
|
|
|
|
|
+ derivation_data_js = json.dumps(derivation_data, ensure_ascii=False)
|
|
|
|
|
+
|
|
|
|
|
+ # 将帖子详情数据转换为 JS 对象(供「待解构帖子」弹窗使用)
|
|
|
|
|
+ if post_detail_map is None:
|
|
|
|
|
+ post_detail_map = {}
|
|
|
|
|
+ post_detail_map_js = json.dumps(post_detail_map, ensure_ascii=False)
|
|
|
|
|
+
|
|
|
|
|
+ if dimension_analyze_map is None:
|
|
|
|
|
+ dimension_analyze_map = {}
|
|
|
|
|
+ dimension_analyze_data_js = json.dumps(dimension_analyze_map, ensure_ascii=False)
|
|
|
|
|
+
|
|
|
|
|
+ account_name_js = json.dumps(account_name, ensure_ascii=False)
|
|
|
|
|
+
|
|
|
|
|
+ html_content = rf'''<!DOCTYPE html>
|
|
|
|
|
+<html lang="zh-CN">
|
|
|
|
|
+<head>
|
|
|
|
|
+ <meta charset="UTF-8">
|
|
|
|
|
+ <title>多源数据流可视化 - 完整全景版</title>
|
|
|
|
|
+ <style>
|
|
|
|
|
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
|
|
|
+ body {{
|
|
|
|
|
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
|
|
|
+ background: #f8fafc;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ user-select: none;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 顶部工具栏 */
|
|
|
|
|
+ #top-bar {{
|
|
|
|
|
+ position: fixed; top: 0; left: 0; right: 0; height: 60px;
|
|
|
|
|
+ background: white; border-bottom: 1px solid #e2e8f0;
|
|
|
|
|
+ display: flex; align-items: center; justify-content: space-between;
|
|
|
|
|
+ padding: 0 24px; z-index: 100;
|
|
|
|
|
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
|
|
|
+ }}
|
|
|
|
|
+ .controls {{ display: flex; gap: 16px; align-items: center; }}
|
|
|
|
|
+ .controls input {{
|
|
|
|
|
+ padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px;
|
|
|
|
|
+ font-size: 14px; width: 220px; transition: border 0.2s;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .controls input:focus {{ border-color: #3b82f6; outline: none; }}
|
|
|
|
|
+ .controls select {{
|
|
|
|
|
+ padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px;
|
|
|
|
|
+ font-size: 14px; width: 200px; transition: border 0.2s;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .controls select:focus {{ border-color: #3b82f6; outline: none; }}
|
|
|
|
|
+ .controls button {{
|
|
|
|
|
+ padding: 8px 16px; background: #3b82f6; color: white; border: none;
|
|
|
|
|
+ border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;
|
|
|
|
|
+ transition: background 0.2s;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .controls button:hover {{ background: #2563eb; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 画布区域 */
|
|
|
|
|
+ #app-container {{
|
|
|
|
|
+ position: fixed; top: 60px; left: 0; right: 0; bottom: 0;
|
|
|
|
|
+ overflow: hidden; cursor: grab; background: #f8fafc;
|
|
|
|
|
+ /* 移除 transition,让画布缩放瞬间完成 */
|
|
|
|
|
+ z-index: 1;
|
|
|
|
|
+ transition: bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
|
|
|
+ }}
|
|
|
|
|
+ #app-container.grabbing {{ cursor: grabbing; }}
|
|
|
|
|
+ /* 当侧边栏显示时,画布缩小并向右移动(宽度通过 JavaScript 动态设置) */
|
|
|
|
|
+ #app-container.sidebar-open {{
|
|
|
|
|
+ /* right 和 width 通过 JavaScript 动态设置 */
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ #canvas {{
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ transform-origin: 0 0;
|
|
|
|
|
+ transition: transform 0.1s linear;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #canvas.animating {{ transition: transform 0.6s cubic-bezier(0.25, 1, 0.5, 1); }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 列标题 */
|
|
|
|
|
+ .column-header {{
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ height: 36px; line-height: 36px;
|
|
|
|
|
+ font-size: 14px; font-weight: 600; color: #64748b;
|
|
|
|
|
+ background: #f1f5f9; border-radius: 18px;
|
|
|
|
|
+ text-align: center; padding: 0 20px;
|
|
|
|
|
+ z-index: 2; white-space: nowrap;
|
|
|
|
|
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 卡片样式(实线框颜色略深) */
|
|
|
|
|
+ .constant-card, .node-card {{
|
|
|
|
|
+ position: absolute; background: white;
|
|
|
|
|
+ border: 1px solid #64748b; border-radius: 10px;
|
|
|
|
|
+ padding: 16px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
|
|
|
+ cursor: pointer; transition: all 0.2s ease-out; z-index: 10;
|
|
|
|
|
+ }}
|
|
|
|
|
+ /* 当侧边栏打开时,提高节点卡片的 z-index */
|
|
|
|
|
+ #app-container.sidebar-open .constant-card,
|
|
|
|
|
+ #app-container.sidebar-open .node-card {{
|
|
|
|
|
+ z-index: 150;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .constant-card {{ width: 280px; border-left: 5px solid #8b5cf6; }}
|
|
|
|
|
+ .node-card {{ width: 320px; }}
|
|
|
|
|
+
|
|
|
|
|
+ .constant-card:hover, .node-card:hover {{
|
|
|
|
|
+ transform: translateY(-2px);
|
|
|
|
|
+ box-shadow: 0 10px 20px -5px rgba(0,0,0,0.1);
|
|
|
|
|
+ border-color: #475569;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 高亮样式(实线蓝框) */
|
|
|
|
|
+ .highlight {{
|
|
|
|
|
+ border: 2px solid #2563eb !important;
|
|
|
|
|
+ background: #eff6ff !important;
|
|
|
|
|
+ box-shadow: 0 0 0 4px rgba(37,99,235,0.15) !important;
|
|
|
|
|
+ z-index: 20;
|
|
|
|
|
+ }}
|
|
|
|
|
+ /* 虚线框节点高亮时保持虚线蓝框(用渐变虚线,去掉实线 border) */
|
|
|
|
|
+ .node-card.not-fully-derived.highlight {{
|
|
|
|
|
+ border: none !important;
|
|
|
|
|
+ background-color: #eff6ff !important;
|
|
|
|
|
+ box-shadow: 0 0 0 4px rgba(37,99,235,0.15) !important;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 变暗样式 */
|
|
|
|
|
+ .dimmed {{ opacity: 0.1; filter: grayscale(100%); pointer-events: none; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 未完全推导的节点:虚线框(用渐变模拟较疏虚线,颜色略深) */
|
|
|
|
|
+ .node-card.not-fully-derived {{
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ border-radius: 10px;
|
|
|
|
|
+ background-color: white;
|
|
|
|
|
+ background-image:
|
|
|
|
|
+ linear-gradient(90deg, #475569 0 8px, transparent 8px 20px),
|
|
|
|
|
+ linear-gradient(90deg, #475569 0 8px, transparent 8px 20px),
|
|
|
|
|
+ linear-gradient(0deg, #475569 0 8px, transparent 8px 20px),
|
|
|
|
|
+ linear-gradient(0deg, #475569 0 8px, transparent 8px 20px);
|
|
|
|
|
+ background-size: 28px 2px, 28px 2px, 2px 28px, 2px 28px;
|
|
|
|
|
+ background-position: left top, left bottom, left top, right top;
|
|
|
|
|
+ background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .node-card.not-fully-derived.highlight {{
|
|
|
|
|
+ background-image:
|
|
|
|
|
+ linear-gradient(90deg, #2563eb 0 8px, transparent 8px 20px),
|
|
|
|
|
+ linear-gradient(90deg, #2563eb 0 8px, transparent 8px 20px),
|
|
|
|
|
+ linear-gradient(0deg, #2563eb 0 8px, transparent 8px 20px),
|
|
|
|
|
+ linear-gradient(0deg, #2563eb 0 8px, transparent 8px 20px);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .edge-path.dimmed {{
|
|
|
|
|
+ opacity: 0.05;
|
|
|
|
|
+ marker-end: none !important;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .edge-label-text.dimmed {{ opacity: 0; }}
|
|
|
|
|
+ .edge-label-sub.dimmed {{ opacity: 0; }}
|
|
|
|
|
+ .connector-dot.dimmed {{ opacity: 0; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 内容排版 */
|
|
|
|
|
+ .node-header {{ font-weight: 700; font-size: 15px; margin-bottom: 12px; color: #0f172a; }}
|
|
|
|
|
+ .constant-name {{ font-weight: 700; font-size: 14px; color: #1e293b; margin-bottom: 6px; }}
|
|
|
|
|
+ .constant-value {{ font-size: 13px; color: #64748b; }}
|
|
|
|
|
+
|
|
|
|
|
+ .row {{ display: flex; margin-bottom: 6px; font-size: 12px; line-height: 1.5; align-items: baseline; }}
|
|
|
|
|
+ .key {{ color: #94a3b8; width: 50px; flex-shrink: 0; text-align: right; margin-right: 12px; white-space: nowrap; }}
|
|
|
|
|
+ .val {{ color: #334155; font-weight: 500; }}
|
|
|
|
|
+ .row-root-source .key {{ width: 80px; }}
|
|
|
|
|
+ .row-root-source .val {{ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }}
|
|
|
|
|
+ .row-post-topic {{ margin-top: 6px; }}
|
|
|
|
|
+ .row-post-topic .key {{ margin-right: 20px; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* SVG */
|
|
|
|
|
+ .edge-layer {{ position: absolute; top: 0; left: 0; pointer-events: none; z-index: 1; }}
|
|
|
|
|
+ .edge-layer g {{ pointer-events: all; }}
|
|
|
|
|
+
|
|
|
|
|
+ .edge-path {{
|
|
|
|
|
+ fill: none; stroke: #cbd5e1; stroke-width: 1.5px;
|
|
|
|
|
+ stroke-linejoin: round; transition: stroke 0.3s, opacity 0.3s;
|
|
|
|
|
+ pointer-events: stroke; cursor: pointer;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .edge-path.highlight {{ stroke: #2563eb; stroke-width: 2.5px; opacity: 1; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 主标签样式 */
|
|
|
|
|
+ .edge-label-text {{
|
|
|
|
|
+ font-size: 12px; fill: #475569; text-anchor: middle;
|
|
|
|
|
+ font-family: monospace; paint-order: stroke;
|
|
|
|
|
+ stroke: #f8fafc; stroke-width: 4px;
|
|
|
|
|
+ transition: opacity 0.3s;
|
|
|
|
|
+ pointer-events: all; cursor: pointer;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 副标签样式(概率) */
|
|
|
|
|
+ .edge-label-sub {{
|
|
|
|
|
+ font-size: 10px; fill: #94a3b8; text-anchor: middle;
|
|
|
|
|
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
|
|
|
|
+ paint-order: stroke; stroke: #f8fafc; stroke-width: 3px;
|
|
|
|
|
+ transition: opacity 0.3s;
|
|
|
|
|
+ pointer-events: all; cursor: pointer;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .edge-label-text.highlight {{ fill: #2563eb; font-weight: 700; opacity: 1; }}
|
|
|
|
|
+ .edge-label-sub.highlight {{ fill: #2563eb; font-weight: 600; opacity: 1; }}
|
|
|
|
|
+
|
|
|
|
|
+ .connector-dot {{ fill: #cbd5e1; transition: fill 0.3s; pointer-events: all; cursor: pointer; }}
|
|
|
|
|
+ .connector-dot.highlight {{ fill: #2563eb; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 侧边栏 */
|
|
|
|
|
+ #sidebar {{
|
|
|
|
|
+ position: fixed; top: 60px; right: 0; width: 380px; min-width: 250px; max-width: 60vw;
|
|
|
|
|
+ height: calc(100vh - 60px);
|
|
|
|
|
+ background: white; border-left: 1px solid #e2e8f0; box-shadow: -4px 0 15px rgba(0,0,0,0.05);
|
|
|
|
|
+ transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
|
|
|
+ z-index: 100; display: flex; flex-direction: column;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #sidebar.active {{ transform: translateX(0); }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 侧边栏拉伸器 */
|
|
|
|
|
+ #sidebar-resizer {{
|
|
|
|
|
+ position: fixed; top: 60px; right: 0; width: 8px; height: calc(100vh - 60px);
|
|
|
|
|
+ background: #e0e0e0; cursor: col-resize; z-index: 101;
|
|
|
|
|
+ transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #sidebar-resizer.active {{ transform: translateX(0); }}
|
|
|
|
|
+ #sidebar-resizer:hover {{ background: #3b82f6; }}
|
|
|
|
|
+ #sidebar-resizer::before {{
|
|
|
|
|
+ content: ""; position: absolute; left: 50%; top: 0; bottom: 0;
|
|
|
|
|
+ width: 2px; background: #999; transform: translateX(-50%);
|
|
|
|
|
+ }}
|
|
|
|
|
+ #sidebar-resizer:hover::before {{ background: #3b82f6; }}
|
|
|
|
|
+ body.resizing {{ user-select: none; }}
|
|
|
|
|
+ body.resizing #sidebar-resizer {{ background: #3b82f6; }}
|
|
|
|
|
+ .sidebar-header {{ padding: 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; background: #f8fafc; }}
|
|
|
|
|
+ .sidebar-content {{ padding: 20px; overflow-y: auto; flex: 1; }}
|
|
|
|
|
+ .detail-item {{ margin-bottom: 20px; }}
|
|
|
|
|
+ .detail-item label {{ display: block; font-size: 11px; font-weight: 600; color: #94a3b8; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }}
|
|
|
|
|
+ .detail-val {{ font-size: 14px; color: #1e293b; padding: 12px; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; line-height: 1.6; white-space: pre-wrap; }}
|
|
|
|
|
+ .detail-empty {{ color: #999; font-style: italic; text-align: center; padding: 40px 20px; }}
|
|
|
|
|
+ .query-block-header {{ cursor: pointer; padding: 8px 0; user-select: none; }}
|
|
|
|
|
+ .query-block-header:hover {{ color: #3b82f6; }}
|
|
|
|
|
+ .query-block-body {{ margin-top: 8px; }}
|
|
|
|
|
+ .external-post-card {{ border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-top: 12px; background: #fafafa; }}
|
|
|
|
|
+ .root-detail-section {{ margin-bottom: 25px; padding-bottom: 15px; border-bottom: 1px solid #eee; }}
|
|
|
|
|
+ .root-detail-section:last-child {{ border-bottom: none; }}
|
|
|
|
|
+ .root-detail-title {{ font-size: 18px; font-weight: bold; color: #333; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; }}
|
|
|
|
|
+ .root-detail-title::before {{ content: ""; display: inline-block; width: 4px; height: 18px; background: #3b82f6; border-radius: 2px; }}
|
|
|
|
|
+ .post-title {{ font-size: 16px; font-weight: bold; margin-bottom: 10px; color: #444; }}
|
|
|
|
|
+ .post-body {{ font-size: 14px; white-space: pre-wrap; color: #666; background: #f9f9f9; padding: 12px; border-radius: 6px; margin-bottom: 15px; }}
|
|
|
|
|
+ .post-stats {{ display: flex; gap: 20px; margin-bottom: 15px; font-size: 14px; color: #888; }}
|
|
|
|
|
+ .post-stats span {{ display: flex; align-items: center; gap: 4px; }}
|
|
|
|
|
+ .image-gallery {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 8px; margin-top: 10px; }}
|
|
|
|
|
+ .image-item {{ width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; cursor: pointer; transition: transform 0.2s; border: 1px solid #ddd; }}
|
|
|
|
|
+ .image-item:hover {{ transform: scale(1.05); border-color: #3b82f6; }}
|
|
|
|
|
+ .jump-link {{ display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f0f4f8; color: #3b82f6; text-decoration: none; border-radius: 8px; font-weight: bold; transition: background 0.2s; margin-bottom: 10px; }}
|
|
|
|
|
+ .jump-link:hover {{ background: #e1e9f0; color: #2563eb; }}
|
|
|
|
|
+ .jump-link::after {{ content: "→"; font-size: 18px; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 推导进度区域 - 底部面板 */
|
|
|
|
|
+ #derivation-progress-section {{
|
|
|
|
|
+ position: fixed; bottom: 0; left: 0; right: 0;
|
|
|
|
|
+ height: 600px; max-height: 80vh; min-height: 200px;
|
|
|
|
|
+ background: white; border-top: 1px solid #e2e8f0; box-shadow: 0 -2px 15px rgba(0,0,0,0.05);
|
|
|
|
|
+ transform: translateY(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
|
|
|
+ z-index: 99; display: flex; flex-direction: column;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #derivation-resizer {{
|
|
|
|
|
+ position: absolute; top: 0; left: 0; right: 0; height: 6px;
|
|
|
|
|
+ cursor: row-resize; z-index: 100;
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #derivation-resizer:hover, #derivation-resizer.active {{
|
|
|
|
|
+ background: #3b82f6;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #derivation-progress-section.active {{ transform: translateY(0); }}
|
|
|
|
|
+ .derivation-progress-title {{
|
|
|
|
|
+ padding: 15px 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; background: #f8fafc;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-progress-title span {{
|
|
|
|
|
+ font-weight: 700; color: #334155; font-size: 16px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-color-legend {{
|
|
|
|
|
+ display: flex; gap: 15px; align-items: center; margin-left: 20px; font-size: 12px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-color-legend-item {{
|
|
|
|
|
+ display: flex; align-items: center; gap: 6px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-color-legend-color {{
|
|
|
|
|
+ width: 16px; height: 16px; border-radius: 4px; border: 1px solid #d1d5db;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .legend-black {{ background: #f3f4f6; border-color: #d1d5db; }}
|
|
|
|
|
+ .legend-yellow {{ background: #fef3c7; border-color: #fcd34d; }}
|
|
|
|
|
+ .legend-green {{ background: #d1fae5; border-color: #6ee7b7; }}
|
|
|
|
|
+ .derivation-progress-toggle {{
|
|
|
|
|
+ padding: 6px 12px; background: #3b82f6; color: white; border: none;
|
|
|
|
|
+ border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500;
|
|
|
|
|
+ transition: background 0.2s;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-progress-toggle:hover {{ background: #2563eb; }}
|
|
|
|
|
+ #derivation-progress-content {{
|
|
|
|
|
+ padding: 20px; overflow-x: auto; overflow-y: auto; flex: 1;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 当推导进度面板打开时,调整画布底部边距 */
|
|
|
|
|
+ #app-container.derivation-open {{
|
|
|
|
|
+ bottom: 600px;
|
|
|
|
|
+ transition: bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ .derivation-empty {{
|
|
|
|
|
+ color: #999; font-style: italic; text-align: center; padding: 40px 20px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-timeline {{
|
|
|
|
|
+ display: flex; flex-direction: row; gap: 20px; align-items: flex-start;
|
|
|
|
|
+ min-width: max-content;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-round-block {{
|
|
|
|
|
+ border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px; background: #fafafa;
|
|
|
|
|
+ min-width: 350px; max-width: 450px; flex-shrink: 0;
|
|
|
|
|
+ display: flex; flex-direction: column;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-round-title {{
|
|
|
|
|
+ font-size: 16px; font-weight: 700; color: #1e293b; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #3b82f6;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-table {{
|
|
|
|
|
+ width: 100%; border-collapse: collapse; font-size: 11px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-table th {{
|
|
|
|
|
+ background: #f1f5f9; padding: 6px 4px; text-align: left; font-weight: 600; color: #475569; border: 1px solid #e2e8f0;
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-table td {{
|
|
|
|
|
+ padding: 4px; border: 1px solid #e2e8f0; vertical-align: top;
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-table .col-type {{ width: 50px; }}
|
|
|
|
|
+ .derivation-table .col-source {{ width: 100px; }}
|
|
|
|
|
+ .derivation-table .col-dim {{ width: auto; min-width: 80px; }}
|
|
|
|
|
+ .derivation-topic-item {{
|
|
|
|
|
+ display: inline-block; margin: 2px 4px 2px 0; padding: 2px 6px; border-radius: 4px; font-size: 11px;
|
|
|
|
|
+ cursor: pointer; transition: all 0.2s;
|
|
|
|
|
+ }}
|
|
|
|
|
+ /* 未点亮的词 - 黑色 */
|
|
|
|
|
+ .derivation-topic-underedived {{
|
|
|
|
|
+ background: #f3f4f6; color: #000000; border: 1px solid #d1d5db;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-dimension-extra {{
|
|
|
|
|
+ margin-top: 12px; padding-top: 10px; border-top: 1px dashed #e2e8f0;
|
|
|
|
|
+ font-size: 11px; line-height: 1.5;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-dim-line {{ margin-bottom: 6px; word-break: break-all; }}
|
|
|
|
|
+ .derivation-dim-label {{
|
|
|
|
|
+ display: inline-block; min-width: 72px; font-weight: 600; color: #64748b;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-dim-val.dim-derived {{ color: #15803d; }}
|
|
|
|
|
+ .derivation-dim-val.dim-underived {{ color: #b45309; }}
|
|
|
|
|
+ .derivation-dim-line.dim-muted {{ color: #94a3b8; font-style: italic; }}
|
|
|
|
|
+ .btn-dimension-patterns {{
|
|
|
|
|
+ margin-top: 8px; padding: 5px 12px; font-size: 11px;
|
|
|
|
|
+ background: #6366f1; color: white; border: none; border-radius: 6px;
|
|
|
|
|
+ cursor: pointer; font-weight: 500;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .btn-dimension-patterns:hover {{ background: #4f46e5; }}
|
|
|
|
|
+ #dimension-patterns-modal {{
|
|
|
|
|
+ display: none; position: fixed; inset: 0; z-index: 200;
|
|
|
|
|
+ background: rgba(15, 23, 42, 0.45); align-items: center; justify-content: center;
|
|
|
|
|
+ padding: 24px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #dimension-patterns-modal.active {{ display: flex; }}
|
|
|
|
|
+ .dimension-patterns-dialog {{
|
|
|
|
|
+ background: white; border-radius: 12px; max-width: 900px; width: 100%;
|
|
|
|
|
+ max-height: 80vh; display: flex; flex-direction: column;
|
|
|
|
|
+ box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
|
|
|
|
|
+ }}
|
|
|
|
|
+ .dimension-patterns-head {{
|
|
|
|
|
+ padding: 14px 18px; border-bottom: 1px solid #e2e8f0;
|
|
|
|
|
+ display: flex; justify-content: space-between; align-items: center;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .dimension-patterns-head span {{ font-weight: 700; color: #1e293b; font-size: 15px; }}
|
|
|
|
|
+ .dimension-patterns-close {{
|
|
|
|
|
+ padding: 6px 14px; background: #f1f5f9; border: none; border-radius: 6px;
|
|
|
|
|
+ cursor: pointer; font-size: 13px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .dimension-patterns-close:hover {{ background: #e2e8f0; }}
|
|
|
|
|
+ .dimension-patterns-body {{
|
|
|
|
|
+ padding: 16px 18px; overflow-y: auto; font-size: 12px; line-height: 1.6;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .dimension-patterns-title {{ font-weight: 600; color: #475569; margin-bottom: 12px; }}
|
|
|
|
|
+ .pattern-line {{
|
|
|
|
|
+ padding: 8px 10px; margin-bottom: 6px; background: #f8fafc;
|
|
|
|
|
+ border-radius: 6px; border: 1px solid #e2e8f0; word-break: break-all;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .pattern-plus {{ color: #94a3b8; font-weight: 600; margin: 0 4px; }}
|
|
|
|
|
+ .pattern-item-derived {{
|
|
|
|
|
+ color: #15803d; font-weight: 700; background: #dcfce7;
|
|
|
|
|
+ padding: 1px 4px; border-radius: 4px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ /* 当前轮次点亮的点 - 黄色 */
|
|
|
|
|
+ .derivation-topic-new {{
|
|
|
|
|
+ background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; font-weight: 600;
|
|
|
|
|
+ }}
|
|
|
|
|
+ /* 未完全推导的选题点:虚线框 */
|
|
|
|
|
+ .derivation-topic-item.derivation-topic-not-fully-derived {{
|
|
|
|
|
+ border: 1px dashed #475569 !important;
|
|
|
|
|
+ }}
|
|
|
|
|
+ /* 之前已经点亮过的点 - 绿色 */
|
|
|
|
|
+ .derivation-topic-derived {{
|
|
|
|
|
+ background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7;
|
|
|
|
|
+ }}
|
|
|
|
|
+ /* 推导结果为空时,由解构内容回填的基准选题(不可点击定位) */
|
|
|
|
|
+ .derivation-topic-baseline {{
|
|
|
|
|
+ background: #e0e7ff; color: #312e81; border: 1px solid #a5b4fc; cursor: default;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-topic-item.derivation-topic-baseline:hover {{
|
|
|
|
|
+ transform: none; box-shadow: none;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-topic-item:hover {{
|
|
|
|
|
+ transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
|
|
|
+ }}
|
|
|
|
|
+ .derivation-topic-search-icon {{ color: #2196F3; margin-left: 2px; }}
|
|
|
|
|
+ .derivation-topic-tool-icon {{ color: #ff9800; margin-left: 2px; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 待解构帖子数据 入口 */
|
|
|
|
|
+ .top-bar-left {{ display: flex; align-items: center; gap: 16px; }}
|
|
|
|
|
+ #btn-pending-decode-post {{
|
|
|
|
|
+ padding: 8px 16px; background: #8b5cf6; color: white; border: none;
|
|
|
|
|
+ border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;
|
|
|
|
|
+ transition: background 0.2s;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #btn-pending-decode-post:hover {{ background: #7c3aed; }}
|
|
|
|
|
+ .modal-overlay {{
|
|
|
|
|
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
|
|
|
+ background: rgba(0,0,0,0.4); z-index: 1000; display: none;
|
|
|
|
|
+ align-items: center; justify-content: center;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .modal-overlay.active {{ display: flex; }}
|
|
|
|
|
+ .modal-box {{
|
|
|
|
|
+ background: white; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.15);
|
|
|
|
|
+ max-width: 480px; width: 90%; max-height: 85vh; overflow: hidden;
|
|
|
|
|
+ display: flex; flex-direction: column;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .modal-box.post-detail-modal {{ max-width: 720px; }}
|
|
|
|
|
+ .modal-header {{
|
|
|
|
|
+ padding: 16px 20px; border-bottom: 1px solid #e2e8f0; display: flex;
|
|
|
|
|
+ justify-content: space-between; align-items: center; background: #f8fafc;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .modal-header span {{ font-weight: 700; font-size: 16px; color: #334155; }}
|
|
|
|
|
+ .modal-close {{ background: none; border: none; font-size: 24px; cursor: pointer; color: #94a3b8; line-height: 1; }}
|
|
|
|
|
+ .modal-close:hover {{ color: #64748b; }}
|
|
|
|
|
+ .modal-body {{ padding: 20px; overflow-y: auto; flex: 1; }}
|
|
|
|
|
+
|
|
|
|
|
+ /* 图集大图查看(灯箱) */
|
|
|
|
|
+ #image-lightbox {{
|
|
|
|
|
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
|
|
|
+ background: rgba(0,0,0,0.9); z-index: 2000; display: none;
|
|
|
|
|
+ align-items: center; justify-content: center;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #image-lightbox.active {{ display: flex; }}
|
|
|
|
|
+ #image-lightbox .lightbox-close {{
|
|
|
|
|
+ position: absolute; top: 16px; right: 20px;
|
|
|
|
|
+ background: none; border: none; color: #fff; font-size: 32px;
|
|
|
|
|
+ cursor: pointer; line-height: 1; opacity: 0.8;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #image-lightbox .lightbox-close:hover {{ opacity: 1; }}
|
|
|
|
|
+ #image-lightbox .lightbox-prev,
|
|
|
|
|
+ #image-lightbox .lightbox-next {{
|
|
|
|
|
+ position: absolute; top: 50%; transform: translateY(-50%);
|
|
|
|
|
+ width: 48px; height: 48px; border: none; border-radius: 50%;
|
|
|
|
|
+ background: rgba(255,255,255,0.2); color: #fff; font-size: 24px;
|
|
|
|
|
+ cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
|
|
|
+ transition: background 0.2s;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #image-lightbox .lightbox-prev:hover,
|
|
|
|
|
+ #image-lightbox .lightbox-next:hover {{ background: rgba(255,255,255,0.35); }}
|
|
|
|
|
+ #image-lightbox .lightbox-prev {{ left: 20px; }}
|
|
|
|
|
+ #image-lightbox .lightbox-next {{ right: 20px; }}
|
|
|
|
|
+ #image-lightbox .lightbox-img-wrap {{
|
|
|
|
|
+ max-width: 90vw; max-height: 85vh; display: flex; align-items: center; justify-content: center;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #image-lightbox .lightbox-img-wrap img {{
|
|
|
|
|
+ max-width: 100%; max-height: 85vh; object-fit: contain;
|
|
|
|
|
+ }}
|
|
|
|
|
+ #image-lightbox .lightbox-counter {{
|
|
|
|
|
+ position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
|
|
|
|
|
+ color: rgba(255,255,255,0.9); font-size: 14px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ </style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+
|
|
|
|
|
+ <div id="top-bar">
|
|
|
|
|
+ <div class="top-bar-left">
|
|
|
|
|
+ <button type="button" id="btn-pending-decode-post">待解构帖子数据</button>
|
|
|
|
|
+ <h2 style="font-size:18px; color:#1e293b; font-weight:600;">多源数据流可视化 - 完整全景版</h2>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="controls">
|
|
|
|
|
+ <select id="postSelector" onchange="switchPost(this.value)">
|
|
|
|
|
+ {"".join([f'<option value="{k}">{k}</option>' for k in data_map.keys()])}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ <input type="text" id="search-input" placeholder="输入关键字 并回车定位..." />
|
|
|
|
|
+ <button onclick="resetView()">重置视图</button>
|
|
|
|
|
+ <button onclick="toggleDerivationProgress()">推导进度</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div id="sidebar">
|
|
|
|
|
+ <div class="sidebar-header">
|
|
|
|
|
+ <div id="sidebar-title" style="font-weight:700; color:#334155;">节点详情</div>
|
|
|
|
|
+ <button onclick="closeSidebar()" style="background:none; border:none; font-size:24px; cursor:pointer; color:#94a3b8;">×</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="sidebar-content" id="sidebar-content"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="sidebar-resizer"></div>
|
|
|
|
|
+
|
|
|
|
|
+ <div id="app-container">
|
|
|
|
|
+ <div id="canvas"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div id="derivation-progress-section">
|
|
|
|
|
+ <div id="derivation-resizer"></div>
|
|
|
|
|
+ <div class="derivation-progress-title">
|
|
|
|
|
+ <div style="display: flex; align-items: center;">
|
|
|
|
|
+ <span>推导进度</span>
|
|
|
|
|
+ <div class="derivation-color-legend">
|
|
|
|
|
+ <div class="derivation-color-legend-item">
|
|
|
|
|
+ <div class="derivation-color-legend-color legend-black"></div>
|
|
|
|
|
+ <span>未点亮</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="derivation-color-legend-item">
|
|
|
|
|
+ <div class="derivation-color-legend-color legend-yellow"></div>
|
|
|
|
|
+ <span>当前轮次点亮</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="derivation-color-legend-item">
|
|
|
|
|
+ <div class="derivation-color-legend-color legend-green"></div>
|
|
|
|
|
+ <span>之前已点亮</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button class="derivation-progress-toggle" onclick="toggleDerivationProgress()">收起</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="derivation-progress-content"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 待解构帖子详情弹窗 -->
|
|
|
|
|
+ <div id="post-detail-modal" class="modal-overlay">
|
|
|
|
|
+ <div class="modal-box post-detail-modal">
|
|
|
|
|
+ <div class="modal-header">
|
|
|
|
|
+ <span>待解构帖子详情</span>
|
|
|
|
|
+ <button type="button" class="modal-close" onclick="closePostDetailModal()">×</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="modal-body" id="post-detail-modal-content"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 图集大图灯箱 -->
|
|
|
|
|
+ <div id="image-lightbox">
|
|
|
|
|
+ <button type="button" class="lightbox-close" onclick="closeImageLightbox()">×</button>
|
|
|
|
|
+ <button type="button" class="lightbox-prev" onclick="lightboxPrev()">❮</button>
|
|
|
|
|
+ <div class="lightbox-img-wrap">
|
|
|
|
|
+ <img id="lightbox-img" src="" alt="" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button type="button" class="lightbox-next" onclick="lightboxNext()">❯</button>
|
|
|
|
|
+ <div class="lightbox-counter" id="lightbox-counter"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div id="dimension-patterns-modal" onclick="if(event.target===this) closeDimensionPatternsModal()">
|
|
|
|
|
+ <div class="dimension-patterns-dialog" onclick="event.stopPropagation()">
|
|
|
|
|
+ <div class="dimension-patterns-head">
|
|
|
|
|
+ <span id="dimension-patterns-modal-title">维度 patterns</span>
|
|
|
|
|
+ <button type="button" class="dimension-patterns-close" onclick="closeDimensionPatternsModal()">关闭</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="dimension-patterns-body" id="dimension-patterns-modal-body"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <script>
|
|
|
|
|
+ const allData = {json_data_js};
|
|
|
|
|
+ const derivationData = {derivation_data_js};
|
|
|
|
|
+ const dimensionAnalyzeData = {dimension_analyze_data_js};
|
|
|
|
|
+ const postDetailMap = {post_detail_map_js};
|
|
|
|
|
+ const accountName = {account_name_js};
|
|
|
|
|
+
|
|
|
|
|
+ const CONFIG = {{
|
|
|
|
|
+ cardWidth: 320,
|
|
|
|
|
+ constWidth: 280,
|
|
|
|
|
+ colSpacing: 900,
|
|
|
|
|
+ rowSpacing: 30,
|
|
|
|
|
+ paddingX: 80,
|
|
|
|
|
+ paddingY: 100,
|
|
|
|
|
+ busOffset: 450,
|
|
|
|
|
+ forkOffset: 40
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ const canvas = document.getElementById('canvas');
|
|
|
|
|
+ let flatData = {{ nodesByLevel: {{}}, map: {{}} }};
|
|
|
|
|
+ let edgeGroups = {{}};
|
|
|
|
|
+ let currentPostKey = "{first_key}";
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 数据解析 - 适配 node_list 和 edge_list 格式
|
|
|
|
|
+ function parseData(postKey) {{
|
|
|
|
|
+ flatData = {{ nodesByLevel: {{}}, map: {{}} }};
|
|
|
|
|
+ edgeGroups = {{}};
|
|
|
|
|
+
|
|
|
|
|
+ const data = allData[postKey];
|
|
|
|
|
+ const nodesData = data.node_list || [];
|
|
|
|
|
+ const edgesData = data.edge_list || [];
|
|
|
|
|
+ const allUsedTreeNodes = data.all_used_tree_nodes || [];
|
|
|
|
|
+
|
|
|
|
|
+ // 创建节点映射
|
|
|
|
|
+ const nodeMap = {{}};
|
|
|
|
|
+ nodesData.forEach(node => {{
|
|
|
|
|
+ nodeMap[node.name] = node;
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 处理人设/全局常量节点(放在 level -1,第一轮推导左侧)
|
|
|
|
|
+ // 注意:所有节点都需要展示,不受 is_constant 和 is_local_constant 字段影响
|
|
|
|
|
+ const constantLevel = -1;
|
|
|
|
|
+ if (!flatData.nodesByLevel[constantLevel]) flatData.nodesByLevel[constantLevel] = [];
|
|
|
|
|
+
|
|
|
|
|
+ // 遍历所有节点,全部添加到列表中(不进行任何过滤,全部展示)
|
|
|
|
|
+ allUsedTreeNodes.forEach((constantNode, index) => {{
|
|
|
|
|
+ // 使用索引确保即使名称重复也能区分
|
|
|
|
|
+ const uniqueId = constantNode.name + '_const_' + index;
|
|
|
|
|
+ const item = {{
|
|
|
|
|
+ id: uniqueId,
|
|
|
|
|
+ name: constantNode.name,
|
|
|
|
|
+ data: {{
|
|
|
|
|
+ ...constantNode,
|
|
|
|
|
+ type: constantNode.type || '',
|
|
|
|
|
+ is_constant: constantNode.is_constant || false,
|
|
|
|
|
+ is_local_constant: constantNode.is_local_constant || false
|
|
|
|
|
+ }},
|
|
|
|
|
+ type: 'node',
|
|
|
|
|
+ level: constantLevel,
|
|
|
|
|
+ sources: [],
|
|
|
|
|
+ edgeName: '',
|
|
|
|
|
+ edgeScore: 0
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ // 添加到数组和映射中(数组用于渲染,确保所有节点都显示)
|
|
|
|
|
+ flatData.nodesByLevel[constantLevel].push(item);
|
|
|
|
|
+ flatData.map[item.id] = item;
|
|
|
|
|
+ // 名称映射用于查找(如果有重复名称,最后一个会覆盖,但不影响数组中的显示)
|
|
|
|
|
+ flatData.map[item.name] = item;
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 按 level 分组节点(同名节点可能出现在多轮,如「居家生活场景」level 1 与 level 2 各有一个)
|
|
|
|
|
+ nodesData.forEach(node => {{
|
|
|
|
|
+ const level = node.level || 0;
|
|
|
|
|
+ if (!flatData.nodesByLevel[level]) flatData.nodesByLevel[level] = [];
|
|
|
|
|
+ const uniqueId = node.name + '__L' + level;
|
|
|
|
|
+ const item = {{
|
|
|
|
|
+ id: node.name,
|
|
|
|
|
+ uid: uniqueId,
|
|
|
|
|
+ name: node.name,
|
|
|
|
|
+ data: node,
|
|
|
|
|
+ type: 'node',
|
|
|
|
|
+ level: level,
|
|
|
|
|
+ sources: [],
|
|
|
|
|
+ edgeName: '',
|
|
|
|
|
+ edgeScore: 0
|
|
|
|
|
+ }};
|
|
|
|
|
+ flatData.nodesByLevel[level].push(item);
|
|
|
|
|
+ flatData.map[uniqueId] = item;
|
|
|
|
|
+ flatData.map[item.name] = item;
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 处理边,建立连接关系
|
|
|
|
|
+ // 边对象有 level 字段,表示轮次;边只能连接同轮次的 output 输出节点
|
|
|
|
|
+ // output_nodes 为对象列表,每项有 name 字段表示输出节点名称
|
|
|
|
|
+ edgesData.forEach(edge => {{
|
|
|
|
|
+ const outputNodes = edge.output_nodes || [];
|
|
|
|
|
+ const inputPostNodes = edge.input_post_nodes || [];
|
|
|
|
|
+ const usedTreeNodes = edge.used_tree_nodes || edge.input_tree_nodes || [];
|
|
|
|
|
+
|
|
|
|
|
+ const edgeLevel = edge.level;
|
|
|
|
|
+
|
|
|
|
|
+ // 处理 input_post_nodes 作为输入节点(这些节点在推导过程中,不在 level -1)
|
|
|
|
|
+ const inputPostNames = inputPostNodes.map(n => n.name || n).filter(name => name);
|
|
|
|
|
+
|
|
|
|
|
+ // 处理 used_tree_nodes / input_tree_nodes,匹配到 all_used_tree_nodes 中的节点(这些节点在 level -1)
|
|
|
|
|
+ const usedTreeNames = [];
|
|
|
|
|
+ usedTreeNodes.forEach(usedNode => {{
|
|
|
|
|
+ const usedName = usedNode.name || usedNode;
|
|
|
|
|
+ // 在 all_used_tree_nodes 中查找匹配的节点(通过 name 匹配)
|
|
|
|
|
+ const matchedNode = allUsedTreeNodes.find(n => n.name === usedName);
|
|
|
|
|
+ if (matchedNode) {{
|
|
|
|
|
+ usedTreeNames.push(usedName);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 合并所有输入节点名称(但需要区分来源)
|
|
|
|
|
+ // 保存输入节点的来源信息,用于后续查找正确的节点
|
|
|
|
|
+ const allInputNames = [];
|
|
|
|
|
+ const inputSourceMap = {{}}; // 记录每个输入节点的来源:'post' 或 'tree'
|
|
|
|
|
+
|
|
|
|
|
+ inputPostNames.forEach(name => {{
|
|
|
|
|
+ allInputNames.push(name);
|
|
|
|
|
+ inputSourceMap[name] = 'post'; // 来自推导节点
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ usedTreeNames.forEach(name => {{
|
|
|
|
|
+ allInputNames.push(name);
|
|
|
|
|
+ inputSourceMap[name] = 'tree'; // 来自人设/全局常量节点(level -1)
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 先收集所有有效的输出节点:仅同轮次(边只能连接 edge.level 对应的 output 节点)
|
|
|
|
|
+ // 同名节点可能出现在多轮,需按 name + level 查找
|
|
|
|
|
+ const validOutputItems = [];
|
|
|
|
|
+ outputNodes.forEach(outputNode => {{
|
|
|
|
|
+ const outputName = (typeof outputNode === 'object' && outputNode !== null && outputNode.name != null) ? outputNode.name : outputNode;
|
|
|
|
|
+ let outputItem = null;
|
|
|
|
|
+ if (edgeLevel != null && flatData.nodesByLevel[edgeLevel]) {{
|
|
|
|
|
+ outputItem = flatData.nodesByLevel[edgeLevel].find(n => n.name === outputName) || null;
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (!outputItem) outputItem = flatData.map[outputName] || null;
|
|
|
|
|
+ if (outputItem && (edgeLevel == null || outputItem.level === edgeLevel)) {{
|
|
|
|
|
+ validOutputItems.push(outputItem);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 如果没有有效的输出节点,跳过
|
|
|
|
|
+ if (validOutputItems.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 为整个边创建一个边组(所有输出节点共享同一个边组)
|
|
|
|
|
+ const edgeName = edge.name || '';
|
|
|
|
|
+ const edgeScore = edge.score || 0;
|
|
|
|
|
+ // 收集输出节点名称用于生成唯一的 edgeKey
|
|
|
|
|
+ const outputNames = validOutputItems.map(item => item.name).sort();
|
|
|
|
|
+ // edgeKey 需要包含输入节点、输出节点和边名称,确保每条边都有唯一的 key
|
|
|
|
|
+ const inputKey = allInputNames.length > 0
|
|
|
|
|
+ ? allInputNames.slice().sort().join('|')
|
|
|
|
|
+ : 'empty';
|
|
|
|
|
+ const outputKey = outputNames.join('|');
|
|
|
|
|
+ const edgeKey = inputKey + '||' + outputKey + '||' + edgeName;
|
|
|
|
|
+
|
|
|
|
|
+ if (!edgeGroups[edgeKey]) {{
|
|
|
|
|
+ edgeGroups[edgeKey] = {{
|
|
|
|
|
+ key: edgeKey,
|
|
|
|
|
+ targets: [],
|
|
|
|
|
+ sources: allInputNames,
|
|
|
|
|
+ sourceMap: inputSourceMap, // 保存输入节点的来源映射
|
|
|
|
|
+ edgeName: edgeName,
|
|
|
|
|
+ edgeScore: edgeScore,
|
|
|
|
|
+ edgeData: edge // 保存完整的边数据
|
|
|
|
|
+ }};
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 将所有输出节点添加到同一个边组,并设置相同的边信息
|
|
|
|
|
+ validOutputItems.forEach(outputItem => {{
|
|
|
|
|
+ // 更新输出节点的边信息
|
|
|
|
|
+ outputItem.sources = allInputNames;
|
|
|
|
|
+ outputItem.edgeName = edgeName;
|
|
|
|
|
+ outputItem.edgeScore = edgeScore;
|
|
|
|
|
+ outputItem.edgeGroupKey = edgeKey;
|
|
|
|
|
+
|
|
|
|
|
+ // 添加到边组
|
|
|
|
|
+ edgeGroups[edgeKey].targets.push(outputItem);
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 布局计算(按 level 排序,但 x 用列索引排列,空缺的 level 不占位)
|
|
|
|
|
+ function calculateLayout() {{
|
|
|
|
|
+ const levels = Object.keys(flatData.nodesByLevel).map(Number).sort((a,b)=>a-b);
|
|
|
|
|
+ levels.forEach((level, colIndex) => {{
|
|
|
|
|
+ const nodes = flatData.nodesByLevel[level];
|
|
|
|
|
+ // level -1 放第一列;其余列按 colIndex 紧密排列,不因 level 空缺留白
|
|
|
|
|
+ const x = CONFIG.paddingX + colIndex * CONFIG.colSpacing;
|
|
|
|
|
+ let y = CONFIG.paddingY;
|
|
|
|
|
+
|
|
|
|
|
+ createHeader(level, x);
|
|
|
|
|
+
|
|
|
|
|
+ nodes.forEach(node => {{
|
|
|
|
|
+ const h = estimateHeight(node);
|
|
|
|
|
+ node.x = x;
|
|
|
|
|
+ node.y = y;
|
|
|
|
|
+ node.width = node.type === 'constant' ? CONFIG.constWidth : CONFIG.cardWidth;
|
|
|
|
|
+ node.height = h;
|
|
|
|
|
+ node.inputPoint = {{ x: node.x, y: node.y + h/2 }};
|
|
|
|
|
+ node.outputPoint = {{ x: node.x + node.width, y: node.y + h/2 }};
|
|
|
|
|
+ y += h + CONFIG.rowSpacing;
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function estimateHeight(node) {{
|
|
|
|
|
+ if (node.type === 'constant') return 80;
|
|
|
|
|
+ // level -1 的常量节点,只显示 name 和 type,固定高度
|
|
|
|
|
+ if (node.level === -1) {{
|
|
|
|
|
+ return 60 + 22; // name + type
|
|
|
|
|
+ }}
|
|
|
|
|
+ // 为了保证不同节点类型高度一致,这里统一按 point/dimension/root_source 的存在情况估算行数
|
|
|
|
|
+ let lines = 1; // node-header
|
|
|
|
|
+ if (node.data && node.data.point) lines++;
|
|
|
|
|
+ if (node.data && node.data.dimension) lines++;
|
|
|
|
|
+ if (node.data && node.data.root_source) lines++;
|
|
|
|
|
+ return 60 + lines * 22;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function createHeader(level, x) {{
|
|
|
|
|
+ const existing = document.querySelector(`.column-header[data-level="${{level}}"]`);
|
|
|
|
|
+ if (existing) existing.remove();
|
|
|
|
|
+
|
|
|
|
|
+ const el = document.createElement('div');
|
|
|
|
|
+ el.className = 'column-header';
|
|
|
|
|
+ el.dataset.level = level;
|
|
|
|
|
+ el.style.left = x + 'px';
|
|
|
|
|
+ el.style.top = '40px';
|
|
|
|
|
+ el.style.width = CONFIG.cardWidth + 'px';
|
|
|
|
|
+
|
|
|
|
|
+ if (level === -1) {{
|
|
|
|
|
+ el.textContent = '人设/全局常量';
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ const nums = ['一','二','三','四','五','六','七','八','九','十'];
|
|
|
|
|
+ el.textContent = `第${{nums[level-1] || level}}轮推导`;
|
|
|
|
|
+ }}
|
|
|
|
|
+ canvas.appendChild(el);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function renderNodes() {{
|
|
|
|
|
+ // 清空现有节点
|
|
|
|
|
+ document.querySelectorAll('.node-card, .constant-card').forEach(el => el.remove());
|
|
|
|
|
+ const levels = Object.keys(flatData.nodesByLevel).map(Number).sort((a,b)=>a-b);
|
|
|
|
|
+ levels.forEach(level => {{
|
|
|
|
|
+ const nodes = flatData.nodesByLevel[level] || [];
|
|
|
|
|
+ nodes.forEach(node => {{
|
|
|
|
|
+ const el = document.createElement('div');
|
|
|
|
|
+ el.dataset.id = node.uid != null ? node.uid : node.id;
|
|
|
|
|
+ el.style.left = node.x + 'px';
|
|
|
|
|
+ el.style.top = node.y + 'px';
|
|
|
|
|
+ el.style.width = node.width + 'px';
|
|
|
|
|
+
|
|
|
|
|
+ if (node.type === 'constant') {{
|
|
|
|
|
+ el.className = 'constant-card';
|
|
|
|
|
+ el.style.height = (node.height || estimateHeight(node)) + 'px';
|
|
|
|
|
+ el.innerHTML = `
|
|
|
|
|
+ <div class="constant-name">${{node.name}}</div>
|
|
|
|
|
+ <div class="constant-value">${{node.data.value || ''}}</div>
|
|
|
|
|
+ `;
|
|
|
|
|
+ }} else if (node.level === -1) {{
|
|
|
|
|
+ // level -1 的常量节点(人设/全局常量),只显示 name 和 type
|
|
|
|
|
+ el.className = 'node-card';
|
|
|
|
|
+ el.style.height = (node.height || estimateHeight(node)) + 'px';
|
|
|
|
|
+ let html = `<div class="node-header">${{node.name}}</div>`;
|
|
|
|
|
+ if (node.data.type) html += `<div class="row"><span class="key">类型</span><span class="val">${{node.data.type}}</span></div>`;
|
|
|
|
|
+ el.innerHTML = html;
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // node_list 节点:is_fully_derived=false 时用虚线框,名称显示 derivation_output_point,只显示帖子选题点
|
|
|
|
|
+ el.className = 'node-card' + (node.data.is_fully_derived === false ? ' not-fully-derived' : '');
|
|
|
|
|
+ el.style.height = (node.height || estimateHeight(node)) + 'px';
|
|
|
|
|
+ const displayName = (node.data.is_fully_derived === false && node.data.derivation_output_point != null && node.data.derivation_output_point !== '')
|
|
|
|
|
+ ? node.data.derivation_output_point : node.name;
|
|
|
|
|
+ let html = `<div class="node-header">${{displayName}}</div>`;
|
|
|
|
|
+ if (node.data.is_fully_derived === false) {{
|
|
|
|
|
+ // 未完全推导:只显示「帖子选题点」,值为原 node_list.name
|
|
|
|
|
+ if (node.data.name != null && node.data.name !== '') html += `<div class="row row-post-topic"><span class="key">帖子选题点</span><span class="val">${{node.data.name}}</span></div>`;
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // 常规节点:显示 类型(原关键点)、维度、所属选题点
|
|
|
|
|
+ if (node.data.point) html += `<div class="row"><span class="key">类型</span><span class="val">${{node.data.point}}</span></div>`;
|
|
|
|
|
+ if (node.data.dimension) html += `<div class="row"><span class="key">维度</span><span class="val">${{node.data.dimension}}</span></div>`;
|
|
|
|
|
+ if (node.data.root_source) html += `<div class="row row-root-source"><span class="key">所属选题点</span><span class="val">${{node.data.root_source}}</span></div>`;
|
|
|
|
|
+ }}
|
|
|
|
|
+ el.innerHTML = html;
|
|
|
|
|
+ }}
|
|
|
|
|
+ el.onclick = (e) => {{
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ // 保存当前选中的节点
|
|
|
|
|
+ currentSelectedNode = node;
|
|
|
|
|
+ currentSelectedEdgeGroup = null; // 清除边组选中状态
|
|
|
|
|
+ // 不自动缩放,保持当前视图大小和位置
|
|
|
|
|
+ // 立即高亮和显示侧边栏(无延迟)
|
|
|
|
|
+ highlightDirectSources(node);
|
|
|
|
|
+ const sidebarTitle = (node.data && node.data.is_fully_derived === false && node.data.derivation_output_point != null && node.data.derivation_output_point !== '')
|
|
|
|
|
+ ? `节点: ${{node.data.derivation_output_point}}` : `节点: ${{node.name}}`;
|
|
|
|
|
+ showSidebar(node.data, sidebarTitle, node, 'node');
|
|
|
|
|
+ }};
|
|
|
|
|
+ canvas.appendChild(el);
|
|
|
|
|
+ node.el = el;
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 渲染连线 - 按组渲染
|
|
|
|
|
+ function renderEdges() {{
|
|
|
|
|
+ // 移除旧的 SVG
|
|
|
|
|
+ const oldSvg = document.querySelector('.edge-layer');
|
|
|
|
|
+ if (oldSvg) oldSvg.remove();
|
|
|
|
|
+
|
|
|
|
|
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
|
|
|
+ svg.classList.add('edge-layer');
|
|
|
|
|
+ svg.setAttribute('width', '10000');
|
|
|
|
|
+ svg.setAttribute('height', '8000');
|
|
|
|
|
+
|
|
|
|
|
+ // 定义箭头
|
|
|
|
|
+ const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
|
|
|
+ const createMarker = (id, color) => {{
|
|
|
|
|
+ const m = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
|
|
|
|
+ m.setAttribute('id', id);
|
|
|
|
|
+ m.setAttribute('markerWidth', '10'); m.setAttribute('markerHeight', '7');
|
|
|
|
|
+ m.setAttribute('refX', '9'); m.setAttribute('refY', '3.5');
|
|
|
|
|
+ m.setAttribute('orient', 'auto');
|
|
|
|
|
+ const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
|
|
|
+ p.setAttribute('d', 'M0,0 L0,7 L9,3.5 z');
|
|
|
|
|
+ p.setAttribute('fill', color);
|
|
|
|
|
+ m.appendChild(p);
|
|
|
|
|
+ return m;
|
|
|
|
|
+ }};
|
|
|
|
|
+ defs.appendChild(createMarker('arrow-head', '#cbd5e1'));
|
|
|
|
|
+ defs.appendChild(createMarker('arrow-head-highlight', '#2563eb'));
|
|
|
|
|
+ svg.appendChild(defs);
|
|
|
|
|
+
|
|
|
|
|
+ Object.values(edgeGroups).forEach(group => {{
|
|
|
|
|
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
|
|
|
+ g.dataset.edgeGroup = group.key;
|
|
|
|
|
+
|
|
|
|
|
+ const targets = group.targets;
|
|
|
|
|
+ const sourceNames = group.sources;
|
|
|
|
|
+
|
|
|
|
|
+ // 如果没有目标节点,跳过
|
|
|
|
|
+ if (!targets.length) return;
|
|
|
|
|
+
|
|
|
|
|
+ const targetX = targets[0].inputPoint.x;
|
|
|
|
|
+ const busX = targetX - CONFIG.busOffset;
|
|
|
|
|
+ const forkX = targetX - CONFIG.forkOffset;
|
|
|
|
|
+
|
|
|
|
|
+ // 获取源节点(同名多轮时取低于目标层级的源)
|
|
|
|
|
+ const sourceNodes = [];
|
|
|
|
|
+ const sourceMap = group.sourceMap || {{}};
|
|
|
|
|
+ const minTargetLevel = targets.length ? Math.min(...targets.map(t => t.level)) : 0;
|
|
|
|
|
+
|
|
|
|
|
+ sourceNames.forEach(name => {{
|
|
|
|
|
+ const sourceType = sourceMap[name];
|
|
|
|
|
+ let node = null;
|
|
|
|
|
+ if (sourceType === 'tree') {{
|
|
|
|
|
+ const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
|
|
|
|
|
+ node = levelMinusOneNodes.find(n => n.name === name);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ for (let l = minTargetLevel - 1; l >= 0; l--) {{
|
|
|
|
|
+ const found = (flatData.nodesByLevel[l] || []).find(n => n.name === name);
|
|
|
|
|
+ if (found) {{ node = found; break; }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (!node) {{
|
|
|
|
|
+ const candidate = flatData.map[name];
|
|
|
|
|
+ if (candidate && candidate.level !== -1) node = candidate;
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (node) sourceNodes.push(node);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ targets.sort((a,b) => a.y - b.y);
|
|
|
|
|
+ const tMinY = targets[0].inputPoint.y;
|
|
|
|
|
+ const tMaxY = targets[targets.length - 1].inputPoint.y;
|
|
|
|
|
+
|
|
|
|
|
+ // 核心连线的 Y 坐标(使用第一个目标节点的 Y 坐标,与参考文件保持一致)
|
|
|
|
|
+ const mainY = tMinY;
|
|
|
|
|
+
|
|
|
|
|
+ // 创建点击事件处理函数
|
|
|
|
|
+ const handleGroupClick = (e) => {{
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ handleEdgeClick(group);
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ // 如果有源节点,渲染左侧部分
|
|
|
|
|
+ if (sourceNodes.length > 0) {{
|
|
|
|
|
+ let sMinY = Infinity, sMaxY = -Infinity;
|
|
|
|
|
+ sourceNodes.forEach(s => {{
|
|
|
|
|
+ sMinY = Math.min(sMinY, s.outputPoint.y);
|
|
|
|
|
+ sMaxY = Math.max(sMaxY, s.outputPoint.y);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // A. 左侧源头馈线
|
|
|
|
|
+ sourceNodes.forEach(s => {{
|
|
|
|
|
+ const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
|
|
|
+ p.setAttribute('d', `M ${{s.outputPoint.x}} ${{s.outputPoint.y}} L ${{busX}} ${{s.outputPoint.y}}`);
|
|
|
|
|
+ p.classList.add('edge-path', 'feeder');
|
|
|
|
|
+ p.style.cursor = 'pointer';
|
|
|
|
|
+ p.addEventListener('click', handleGroupClick);
|
|
|
|
|
+ g.appendChild(p);
|
|
|
|
|
+ const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
|
|
|
+ dot.setAttribute('cx', s.outputPoint.x); dot.setAttribute('cy', s.outputPoint.y);
|
|
|
|
|
+ dot.setAttribute('r', 3); dot.classList.add('connector-dot');
|
|
|
|
|
+ dot.style.cursor = 'pointer';
|
|
|
|
|
+ dot.addEventListener('click', handleGroupClick);
|
|
|
|
|
+ g.appendChild(dot);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // B. 左侧主干
|
|
|
|
|
+ if (sMinY !== sMaxY) {{
|
|
|
|
|
+ const trunk = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
|
|
|
+ trunk.setAttribute('d', `M ${{busX}} ${{sMinY}} L ${{busX}} ${{sMaxY}}`);
|
|
|
|
|
+ trunk.classList.add('edge-path', 'trunk');
|
|
|
|
|
+ trunk.style.cursor = 'pointer';
|
|
|
|
|
+ trunk.addEventListener('click', handleGroupClick);
|
|
|
|
|
+ g.appendChild(trunk);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // C. 长连接线 (连接源头区域到主线高度)
|
|
|
|
|
+ if (mainY < sMinY) {{
|
|
|
|
|
+ const link = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
|
|
|
+ link.setAttribute('d', `M ${{busX}} ${{sMinY}} L ${{busX}} ${{mainY}}`);
|
|
|
|
|
+ link.classList.add('edge-path', 'trunk');
|
|
|
|
|
+ link.style.cursor = 'pointer';
|
|
|
|
|
+ link.addEventListener('click', handleGroupClick);
|
|
|
|
|
+ g.appendChild(link);
|
|
|
|
|
+ }} else if (mainY > sMaxY) {{
|
|
|
|
|
+ const link = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
|
|
|
+ link.setAttribute('d', `M ${{busX}} ${{sMaxY}} L ${{busX}} ${{mainY}}`);
|
|
|
|
|
+ link.classList.add('edge-path', 'trunk');
|
|
|
|
|
+ link.style.cursor = 'pointer';
|
|
|
|
|
+ link.addEventListener('click', handleGroupClick);
|
|
|
|
|
+ g.appendChild(link);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 核心连线(无论是否有源节点都要渲染)
|
|
|
|
|
+ const mainLine = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
|
|
|
+ mainLine.setAttribute('d', `M ${{busX}} ${{mainY}} L ${{forkX}} ${{mainY}}`);
|
|
|
|
|
+ mainLine.classList.add('edge-path', 'main-flow');
|
|
|
|
|
+ mainLine.style.cursor = 'pointer';
|
|
|
|
|
+ mainLine.addEventListener('click', handleGroupClick);
|
|
|
|
|
+ g.appendChild(mainLine);
|
|
|
|
|
+
|
|
|
|
|
+ // D. 右侧分叉主干
|
|
|
|
|
+ if (targets.length > 1) {{
|
|
|
|
|
+ const fork = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
|
|
|
+ fork.setAttribute('d', `M ${{forkX}} ${{mainY}} L ${{forkX}} ${{tMaxY}}`);
|
|
|
|
|
+ fork.classList.add('edge-path', 'trunk');
|
|
|
|
|
+ fork.style.cursor = 'pointer';
|
|
|
|
|
+ fork.addEventListener('click', handleGroupClick);
|
|
|
|
|
+ g.appendChild(fork);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // E. 目标接入线
|
|
|
|
|
+ targets.forEach(t => {{
|
|
|
|
|
+ const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
|
|
|
+ p.setAttribute('d', `M ${{forkX}} ${{t.inputPoint.y}} L ${{t.inputPoint.x}} ${{t.inputPoint.y}}`);
|
|
|
|
|
+ p.classList.add('edge-path', 'entry');
|
|
|
|
|
+ p.setAttribute('marker-end', 'url(#arrow-head)');
|
|
|
|
|
+ p.style.cursor = 'pointer';
|
|
|
|
|
+ p.addEventListener('click', handleGroupClick);
|
|
|
|
|
+ g.appendChild(p);
|
|
|
|
|
+
|
|
|
|
|
+ if (targets.length > 1) {{
|
|
|
|
|
+ const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
|
|
|
+ dot.setAttribute('cx', forkX); dot.setAttribute('cy', t.inputPoint.y);
|
|
|
|
|
+ dot.setAttribute('r', 2); dot.classList.add('connector-dot');
|
|
|
|
|
+ dot.style.cursor = 'pointer';
|
|
|
|
|
+ dot.addEventListener('click', handleGroupClick);
|
|
|
|
|
+ g.appendChild(dot);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 连接点标记(只在有源节点时显示)
|
|
|
|
|
+ if (sourceNodes.length > 0) {{
|
|
|
|
|
+ const busDot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
|
|
|
+ busDot.setAttribute('cx', busX); busDot.setAttribute('cy', mainY);
|
|
|
|
|
+ busDot.setAttribute('r', 3); busDot.classList.add('connector-dot');
|
|
|
|
|
+ busDot.style.cursor = 'pointer';
|
|
|
|
|
+ busDot.addEventListener('click', handleGroupClick);
|
|
|
|
|
+ g.appendChild(busDot);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // F. 文字标签
|
|
|
|
|
+ if (group.edgeName) {{
|
|
|
|
|
+ const textX = (busX + forkX) / 2;
|
|
|
|
|
+ const textY = mainY - 5;
|
|
|
|
|
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
|
|
|
+ text.setAttribute('x', textX); text.setAttribute('y', textY);
|
|
|
|
|
+ text.classList.add('edge-label-text');
|
|
|
|
|
+ text.textContent = group.edgeName;
|
|
|
|
|
+ text.style.cursor = 'pointer';
|
|
|
|
|
+ text.addEventListener('click', handleGroupClick);
|
|
|
|
|
+ g.appendChild(text);
|
|
|
|
|
+
|
|
|
|
|
+ // 仅当边数据中有 score 字段时才在连线下方显示条件概率
|
|
|
|
|
+ const hasScore = group.edgeData && group.edgeData.score !== undefined && group.edgeData.score !== null;
|
|
|
|
|
+ if (hasScore) {{
|
|
|
|
|
+ const subText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
|
|
|
+ subText.setAttribute('x', textX);
|
|
|
|
|
+ subText.setAttribute('y', mainY + 14);
|
|
|
|
|
+ subText.classList.add('edge-label-sub');
|
|
|
|
|
+ let labelPrefix = "条件概率";
|
|
|
|
|
+ if (group.edgeName && group.edgeName.startsWith("外部搜索")) {{
|
|
|
|
|
+ labelPrefix = "搜索出现概率";
|
|
|
|
|
+ }}
|
|
|
|
|
+ subText.textContent = `${{labelPrefix}}:${{group.edgeScore}}`;
|
|
|
|
|
+ subText.style.cursor = 'pointer';
|
|
|
|
|
+ subText.addEventListener('click', handleGroupClick);
|
|
|
|
|
+ g.appendChild(subText);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ svg.appendChild(g);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ canvas.insertBefore(svg, canvas.firstChild);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 处理边的点击事件
|
|
|
|
|
+ function handleEdgeClick(group) {{
|
|
|
|
|
+ // 保存当前选中的边组
|
|
|
|
|
+ currentSelectedEdgeGroup = group;
|
|
|
|
|
+ currentSelectedNode = null; // 清除节点选中状态
|
|
|
|
|
+
|
|
|
|
|
+ // 获取边的详细信息
|
|
|
|
|
+ const edgeData = group.edgeData || {{}};
|
|
|
|
|
+ const edgeDetail = edgeData.detail || {{}};
|
|
|
|
|
+ const edgeName = group.edgeName || '';
|
|
|
|
|
+ const edgeScore = group.edgeScore || 0;
|
|
|
|
|
+
|
|
|
|
|
+ // 获取目标节点名称(用于外部边和工具边的展示)
|
|
|
|
|
+ const targetNames = group.targets.map(t => t.name) || [];
|
|
|
|
|
+ const targetNodeName = targetNames.length > 0 ? targetNames[0] : '';
|
|
|
|
|
+
|
|
|
|
|
+ // 构建边的完整数据对象
|
|
|
|
|
+ const fullEdgeData = {{
|
|
|
|
|
+ name: targetNodeName, // 用于外部边和工具边的展示
|
|
|
|
|
+ edgeName: edgeName,
|
|
|
|
|
+ edgeScore: edgeScore,
|
|
|
|
|
+ sources: group.sources || [],
|
|
|
|
|
+ targets: targetNames,
|
|
|
|
|
+ type: edgeName.includes('外部搜索') || edgeName.includes('外部寻找') ? '外部边' :
|
|
|
|
|
+ edgeName.includes('工具') ? '工具边' : '普通边',
|
|
|
|
|
+ ...edgeData
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ // 高亮相关的节点和边
|
|
|
|
|
+ highlightEdgeGroup(group);
|
|
|
|
|
+
|
|
|
|
|
+ // 显示边的详情(先打开侧边栏)
|
|
|
|
|
+ const sourceNames = group.sources.join('、');
|
|
|
|
|
+ const targetNamesStr = targetNames.join('、');
|
|
|
|
|
+ const title = `连线: ${{sourceNames}} → ${{targetNamesStr}}`;
|
|
|
|
|
+ showSidebar(edgeDetail, title, fullEdgeData, 'edge');
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 计算边组相关节点的边界框并缩放显示
|
|
|
|
|
+ function fitEdgeGroupToView(group, useAnimation = true) {{
|
|
|
|
|
+ if (!group) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 收集所有相关节点(源节点和目标节点)
|
|
|
|
|
+ const relatedNodes = new Set();
|
|
|
|
|
+
|
|
|
|
|
+ // 添加所有源节点(需要根据来源区分查找)
|
|
|
|
|
+ const sourceMap = group.sourceMap || {{}};
|
|
|
|
|
+ group.sources.forEach(sName => {{
|
|
|
|
|
+ const sourceType = sourceMap[sName];
|
|
|
|
|
+ let sNode = null;
|
|
|
|
|
+
|
|
|
|
|
+ if (sourceType === 'tree') {{
|
|
|
|
|
+ // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
|
|
|
|
|
+ const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
|
|
|
|
|
+ sNode = levelMinusOneNodes.find(n => n.name === sName);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // input_post_nodes:从推导节点中查找(排除 level -1)
|
|
|
|
|
+ const candidate = flatData.map[sName];
|
|
|
|
|
+ if (candidate && candidate.level !== -1) {{
|
|
|
|
|
+ sNode = candidate;
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
|
|
|
|
|
+ for (let level in flatData.nodesByLevel) {{
|
|
|
|
|
+ const levelNum = parseInt(level);
|
|
|
|
|
+ if (levelNum !== -1) {{
|
|
|
|
|
+ const found = flatData.nodesByLevel[levelNum].find(n => n.name === sName);
|
|
|
|
|
+ if (found) {{
|
|
|
|
|
+ sNode = found;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ if (sNode && sNode.x !== undefined) {{
|
|
|
|
|
+ relatedNodes.add(sNode);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 添加所有目标节点
|
|
|
|
|
+ group.targets.forEach(t => {{
|
|
|
|
|
+ if (t && t.x !== undefined) {{
|
|
|
|
|
+ relatedNodes.add(t);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 如果没有任何节点,直接返回
|
|
|
|
|
+ if (relatedNodes.size === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 计算边界框(包括节点和连线可能延伸的区域)
|
|
|
|
|
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
|
|
|
+ relatedNodes.forEach(node => {{
|
|
|
|
|
+ if (node.x !== undefined && node.y !== undefined) {{
|
|
|
|
|
+ // 节点本身的边界
|
|
|
|
|
+ minX = Math.min(minX, node.x);
|
|
|
|
|
+ minY = Math.min(minY, node.y);
|
|
|
|
|
+ maxX = Math.max(maxX, node.x + (node.width || 0));
|
|
|
|
|
+ maxY = Math.max(maxY, node.y + (node.height || 0));
|
|
|
|
|
+
|
|
|
|
|
+ // 考虑连线可能延伸到左侧(busOffset)
|
|
|
|
|
+ if (node.inputPoint) {{
|
|
|
|
|
+ const leftExtend = node.inputPoint.x - CONFIG.busOffset;
|
|
|
|
|
+ minX = Math.min(minX, leftExtend);
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (node.outputPoint) {{
|
|
|
|
|
+ const rightExtend = node.outputPoint.x + CONFIG.busOffset;
|
|
|
|
|
+ maxX = Math.max(maxX, rightExtend);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 如果边界框无效,直接返回
|
|
|
|
|
+ if (minX === Infinity) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 添加一些边距,确保内容不会贴边(减少边距让内容显示更大)
|
|
|
|
|
+ const padding = 40;
|
|
|
|
|
+ const contentWidth = maxX - minX + padding * 2;
|
|
|
|
|
+ const contentHeight = maxY - minY + padding * 2;
|
|
|
|
|
+ const contentCenterX = (minX + maxX) / 2;
|
|
|
|
|
+ const contentCenterY = (minY + maxY) / 2;
|
|
|
|
|
+
|
|
|
|
|
+ // 获取视口大小(考虑侧边栏是否打开)
|
|
|
|
|
+ const sidebar = document.getElementById('sidebar');
|
|
|
|
|
+ const isSidebarOpen = sidebar && sidebar.classList.contains('active');
|
|
|
|
|
+ // 当侧边栏打开时,画布宽度会缩小(减去侧边栏实际宽度)
|
|
|
|
|
+ const sidebarWidth = isSidebarOpen ? sidebar.offsetWidth : 0;
|
|
|
|
|
+ const viewW = isSidebarOpen ? (container.offsetWidth - sidebarWidth) : container.offsetWidth;
|
|
|
|
|
+ const viewH = container.offsetHeight;
|
|
|
|
|
+
|
|
|
|
|
+ // 计算缩放比例,确保内容能完全显示
|
|
|
|
|
+ const scaleX = (viewW - padding * 2) / contentWidth;
|
|
|
|
|
+ const scaleY = (viewH - padding * 2) / contentHeight;
|
|
|
|
|
+ // 允许放大到2.0,让节点尽可能大,但不超过2.0避免过大
|
|
|
|
|
+ scale = Math.min(scaleX, scaleY, 2.0);
|
|
|
|
|
+
|
|
|
|
|
+ // 计算偏移,使内容居中(考虑侧边栏打开时的偏移)
|
|
|
|
|
+ const offsetX = isSidebarOpen ? 0 : 0; // 侧边栏打开时,画布已经通过CSS向右移动了
|
|
|
|
|
+ translateX = (viewW / 2) - (contentCenterX * scale) + offsetX;
|
|
|
|
|
+ translateY = (viewH / 2) - (contentCenterY * scale);
|
|
|
|
|
+
|
|
|
|
|
+ // 根据参数决定是否使用动画
|
|
|
|
|
+ if (useAnimation) {{
|
|
|
|
|
+ canvas.classList.add('animating');
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // 移除动画类,确保瞬间完成
|
|
|
|
|
+ canvas.classList.remove('animating');
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 高亮边组
|
|
|
|
|
+ function highlightEdgeGroup(group) {{
|
|
|
|
|
+ // Reset
|
|
|
|
|
+ document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
|
|
|
|
|
+ el.classList.remove('highlight');
|
|
|
|
|
+ el.classList.add('dimmed');
|
|
|
|
|
+ }});
|
|
|
|
|
+ document.querySelectorAll('.edge-path, .connector-dot, .edge-label-text, .edge-label-sub').forEach(el => {{
|
|
|
|
|
+ el.classList.remove('highlight');
|
|
|
|
|
+ el.classList.add('dimmed');
|
|
|
|
|
+ }});
|
|
|
|
|
+ document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
|
|
|
|
|
+ document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
|
|
|
|
|
+
|
|
|
|
|
+ // 高亮源节点(根据来源区分查找)
|
|
|
|
|
+ const sourceMap = group.sourceMap || {{}};
|
|
|
|
|
+ group.sources.forEach(sourceName => {{
|
|
|
|
|
+ const sourceType = sourceMap[sourceName];
|
|
|
|
|
+ let sourceNode = null;
|
|
|
|
|
+
|
|
|
|
|
+ if (sourceType === 'tree') {{
|
|
|
|
|
+ // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
|
|
|
|
|
+ const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
|
|
|
|
|
+ sourceNode = levelMinusOneNodes.find(n => n.name === sourceName);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // input_post_nodes:从推导节点中查找(排除 level -1)
|
|
|
|
|
+ const candidate = flatData.map[sourceName];
|
|
|
|
|
+ if (candidate && candidate.level !== -1) {{
|
|
|
|
|
+ sourceNode = candidate;
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
|
|
|
|
|
+ for (let level in flatData.nodesByLevel) {{
|
|
|
|
|
+ const levelNum = parseInt(level);
|
|
|
|
|
+ if (levelNum !== -1) {{
|
|
|
|
|
+ const found = flatData.nodesByLevel[levelNum].find(n => n.name === sourceName);
|
|
|
|
|
+ if (found) {{
|
|
|
|
|
+ sourceNode = found;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ if (sourceNode && sourceNode.el) {{
|
|
|
|
|
+ sourceNode.el.classList.remove('dimmed');
|
|
|
|
|
+ sourceNode.el.classList.add('highlight');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 高亮目标节点
|
|
|
|
|
+ group.targets.forEach(target => {{
|
|
|
|
|
+ if (target.el) {{
|
|
|
|
|
+ target.el.classList.remove('dimmed');
|
|
|
|
|
+ target.el.classList.add('highlight');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 高亮边组
|
|
|
|
|
+ const edgeGroupEl = document.querySelector(`g[data-edge-group="${{group.key}}"]`);
|
|
|
|
|
+ if (edgeGroupEl) {{
|
|
|
|
|
+ Array.from(edgeGroupEl.children).forEach(child => {{
|
|
|
|
|
+ child.classList.remove('dimmed');
|
|
|
|
|
+ child.classList.add('highlight');
|
|
|
|
|
+ if (child.classList.contains('entry')) {{
|
|
|
|
|
+ child.setAttribute('marker-end', 'url(#arrow-head-highlight)');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 交互:高亮组
|
|
|
|
|
+ function highlightDirectSources(targetNode) {{
|
|
|
|
|
+ // Reset
|
|
|
|
|
+ document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
|
|
|
|
|
+ el.classList.remove('highlight');
|
|
|
|
|
+ el.classList.add('dimmed');
|
|
|
|
|
+ }});
|
|
|
|
|
+ document.querySelectorAll('.edge-path, .connector-dot, .edge-label-text, .edge-label-sub').forEach(el => {{
|
|
|
|
|
+ el.classList.remove('highlight');
|
|
|
|
|
+ el.classList.add('dimmed');
|
|
|
|
|
+ }});
|
|
|
|
|
+ document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
|
|
|
|
|
+ document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
|
|
|
|
|
+
|
|
|
|
|
+ let nodesToHighlight = [targetNode];
|
|
|
|
|
+
|
|
|
|
|
+ if (targetNode.edgeGroupKey) {{
|
|
|
|
|
+ const group = edgeGroups[targetNode.edgeGroupKey];
|
|
|
|
|
+ if (group) {{
|
|
|
|
|
+ nodesToHighlight = group.targets;
|
|
|
|
|
+ const edgeGroupEl = document.querySelector(`g[data-edge-group="${{group.key}}"]`);
|
|
|
|
|
+ if (edgeGroupEl) {{
|
|
|
|
|
+ Array.from(edgeGroupEl.children).forEach(child => {{
|
|
|
|
|
+ child.classList.remove('dimmed');
|
|
|
|
|
+ child.classList.add('highlight');
|
|
|
|
|
+ if(child.classList.contains('entry')) {{
|
|
|
|
|
+ child.setAttribute('marker-end', 'url(#arrow-head-highlight)');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ // 高亮源节点(根据来源区分查找;同名多轮时取作为“源”的那一轮)
|
|
|
|
|
+ const sourceMap = group.sourceMap || {{}};
|
|
|
|
|
+ const minTargetLevel = group.targets.length ? Math.min(...group.targets.map(t => t.level)) : 0;
|
|
|
|
|
+ group.sources.forEach(sName => {{
|
|
|
|
|
+ const sourceType = sourceMap[sName];
|
|
|
|
|
+ let sNode = null;
|
|
|
|
|
+
|
|
|
|
|
+ if (sourceType === 'tree') {{
|
|
|
|
|
+ const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
|
|
|
|
|
+ sNode = levelMinusOneNodes.find(n => n.name === sName);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // input_post_nodes:取层级低于目标且同名的节点(从最接近目标的一轮开始找)
|
|
|
|
|
+ for (let l = minTargetLevel - 1; l >= 0; l--) {{
|
|
|
|
|
+ const found = (flatData.nodesByLevel[l] || []).find(n => n.name === sName);
|
|
|
|
|
+ if (found) {{ sNode = found; break; }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (!sNode) {{
|
|
|
|
|
+ const candidate = flatData.map[sName];
|
|
|
|
|
+ if (candidate && candidate.level !== -1) sNode = candidate;
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ if (sNode && sNode.el) {{
|
|
|
|
|
+ sNode.el.classList.remove('dimmed');
|
|
|
|
|
+ sNode.el.classList.add('highlight');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ nodesToHighlight.forEach(n => {{
|
|
|
|
|
+ if (n.el) {{
|
|
|
|
|
+ n.el.classList.remove('dimmed');
|
|
|
|
|
+ n.el.classList.add('highlight');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // --- 视图控制 ---
|
|
|
|
|
+ let scale = 0.8, translateX = 50, translateY = 50;
|
|
|
|
|
+ let isDragging = false, startClientX, startClientY, startTranslateX, startTranslateY;
|
|
|
|
|
+ const DRAG_SENSITIVITY = 1.35; // 拖拽灵敏度,>1 更跟手
|
|
|
|
|
+ let currentSelectedNode = null; // 跟踪当前选中的节点
|
|
|
|
|
+ let currentSelectedEdgeGroup = null; // 跟踪当前选中的边组
|
|
|
|
|
+ const container = document.getElementById('app-container');
|
|
|
|
|
+
|
|
|
|
|
+ function updateTransform() {{
|
|
|
|
|
+ // 采用先缩放再平移的顺序,使平移量与缩放无关,便于以视图中心进行缩放和平移
|
|
|
|
|
+ canvas.style.transform = `scale(${{scale}}) translate(${{translateX}}px, ${{translateY}}px)`;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 计算相关节点的边界框并缩放显示(当前节点及其连线上的节点)
|
|
|
|
|
+ function fitRelatedNodesToView(targetNode, useAnimation = true) {{
|
|
|
|
|
+ if (!targetNode || targetNode.x === undefined) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 收集所有相关节点
|
|
|
|
|
+ const relatedNodes = new Set();
|
|
|
|
|
+ relatedNodes.add(targetNode);
|
|
|
|
|
+
|
|
|
|
|
+ // 如果节点有边组,添加同组的其他目标节点
|
|
|
|
|
+ if (targetNode.edgeGroupKey) {{
|
|
|
|
|
+ const group = edgeGroups[targetNode.edgeGroupKey];
|
|
|
|
|
+ if (group) {{
|
|
|
|
|
+ // 添加同组的所有目标节点
|
|
|
|
|
+ group.targets.forEach(t => {{
|
|
|
|
|
+ if (t && t.x !== undefined) relatedNodes.add(t);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 添加所有源节点(需要根据来源区分查找)
|
|
|
|
|
+ const sourceMap = group.sourceMap || {{}};
|
|
|
|
|
+ group.sources.forEach(sName => {{
|
|
|
|
|
+ const sourceType = sourceMap[sName];
|
|
|
|
|
+ let sNode = null;
|
|
|
|
|
+
|
|
|
|
|
+ if (sourceType === 'tree') {{
|
|
|
|
|
+ // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
|
|
|
|
|
+ const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
|
|
|
|
|
+ sNode = levelMinusOneNodes.find(n => n.name === sName);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // input_post_nodes:从推导节点中查找(排除 level -1)
|
|
|
|
|
+ const candidate = flatData.map[sName];
|
|
|
|
|
+ if (candidate && candidate.level !== -1) {{
|
|
|
|
|
+ sNode = candidate;
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
|
|
|
|
|
+ for (let level in flatData.nodesByLevel) {{
|
|
|
|
|
+ const levelNum = parseInt(level);
|
|
|
|
|
+ if (levelNum !== -1) {{
|
|
|
|
|
+ const found = flatData.nodesByLevel[levelNum].find(n => n.name === sName);
|
|
|
|
|
+ if (found) {{
|
|
|
|
|
+ sNode = found;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ if (sNode && sNode.x !== undefined) {{
|
|
|
|
|
+ relatedNodes.add(sNode);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ const sidebar = document.getElementById('sidebar');
|
|
|
|
|
+ const isSidebarOpen = sidebar && sidebar.classList.contains('active');
|
|
|
|
|
+ const sidebarWidth = isSidebarOpen ? sidebar.offsetWidth : 0;
|
|
|
|
|
+ const viewW = isSidebarOpen ? (container.offsetWidth - sidebarWidth) : container.offsetWidth;
|
|
|
|
|
+ const viewH = container.offsetHeight;
|
|
|
|
|
+
|
|
|
|
|
+ // 仅当节点没有连线(只有一个相关节点)时:只平移使节点居中,不改变缩放,避免视图被放大超出
|
|
|
|
|
+ if (relatedNodes.size === 1) {{
|
|
|
|
|
+ const nodeCenterX = targetNode.x + (targetNode.width || 0) / 2;
|
|
|
|
|
+ const nodeCenterY = targetNode.y + (targetNode.height || 0) / 2;
|
|
|
|
|
+ const offsetX = isSidebarOpen ? 0 : 0;
|
|
|
|
|
+ translateX = (viewW / 2) - (nodeCenterX * scale) + offsetX;
|
|
|
|
|
+ translateY = (viewH / 2) - (nodeCenterY * scale);
|
|
|
|
|
+ if (useAnimation) {{
|
|
|
|
|
+ canvas.classList.add('animating');
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ canvas.classList.remove('animating');
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ }}
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 计算边界框(包括节点和连线可能延伸的区域)
|
|
|
|
|
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
|
|
|
+ relatedNodes.forEach(node => {{
|
|
|
|
|
+ if (node.x !== undefined && node.y !== undefined) {{
|
|
|
|
|
+ // 节点本身的边界
|
|
|
|
|
+ minX = Math.min(minX, node.x);
|
|
|
|
|
+ minY = Math.min(minY, node.y);
|
|
|
|
|
+ maxX = Math.max(maxX, node.x + (node.width || 0));
|
|
|
|
|
+ maxY = Math.max(maxY, node.y + (node.height || 0));
|
|
|
|
|
+
|
|
|
|
|
+ // 考虑连线可能延伸到左侧(busOffset)
|
|
|
|
|
+ if (node.inputPoint) {{
|
|
|
|
|
+ const leftExtend = node.inputPoint.x - CONFIG.busOffset;
|
|
|
|
|
+ minX = Math.min(minX, leftExtend);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 如果边界框无效,直接返回
|
|
|
|
|
+ if (minX === Infinity) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 添加一些边距,确保内容不会贴边(减少边距让内容显示更大)
|
|
|
|
|
+ const padding = 40;
|
|
|
|
|
+ const contentWidth = maxX - minX + padding * 2;
|
|
|
|
|
+ const contentHeight = maxY - minY + padding * 2;
|
|
|
|
|
+ const contentCenterX = (minX + maxX) / 2;
|
|
|
|
|
+ const contentCenterY = (minY + maxY) / 2;
|
|
|
|
|
+
|
|
|
|
|
+ // 计算缩放比例,确保内容能完全显示
|
|
|
|
|
+ const scaleX = (viewW - padding * 2) / contentWidth;
|
|
|
|
|
+ const scaleY = (viewH - padding * 2) / contentHeight;
|
|
|
|
|
+ // 允许放大到2.0,让节点尽可能大,但不超过2.0避免过大
|
|
|
|
|
+ scale = Math.min(scaleX, scaleY, 2.0);
|
|
|
|
|
+
|
|
|
|
|
+ // 计算偏移,使内容居中(考虑侧边栏打开时的偏移)
|
|
|
|
|
+ const offsetX = isSidebarOpen ? 0 : 0; // 侧边栏打开时,画布已经通过CSS向右移动了
|
|
|
|
|
+ translateX = (viewW / 2) - (contentCenterX * scale) + offsetX;
|
|
|
|
|
+ translateY = (viewH / 2) - (contentCenterY * scale);
|
|
|
|
|
+
|
|
|
|
|
+ // 根据参数决定是否使用动画
|
|
|
|
|
+ if (useAnimation) {{
|
|
|
|
|
+ canvas.classList.add('animating');
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // 移除动画类,确保瞬间完成
|
|
|
|
|
+ canvas.classList.remove('animating');
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ let hasDragged = false; // 标记是否发生了拖动
|
|
|
|
|
+
|
|
|
|
|
+ container.addEventListener('click', e => {{
|
|
|
|
|
+ // 只有在没有拖动的情况下才重置视图
|
|
|
|
|
+ if (!hasDragged && (e.target.id === 'app-container' || e.target.id === 'canvas' || e.target.classList.contains('edge-layer'))) {{
|
|
|
|
|
+ resetView();
|
|
|
|
|
+ }}
|
|
|
|
|
+ // 处理完点击事件后重置标志
|
|
|
|
|
+ hasDragged = false;
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ function resetView() {{
|
|
|
|
|
+ document.querySelectorAll('.highlight').forEach(el => el.classList.remove('highlight'));
|
|
|
|
|
+ document.querySelectorAll('.dimmed').forEach(el => el.classList.remove('dimmed'));
|
|
|
|
|
+ document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
|
|
|
|
|
+ document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
|
|
|
|
|
+ document.getElementById('search-input').value = '';
|
|
|
|
|
+ currentSelectedNode = null; // 清除当前选中的节点
|
|
|
|
|
+ currentSelectedEdgeGroup = null; // 清除当前选中的边组
|
|
|
|
|
+ closeSidebar();
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ const searchInput = document.getElementById('search-input');
|
|
|
|
|
+ searchInput.addEventListener('input', (e) => {{
|
|
|
|
|
+ const val = e.target.value.toLowerCase();
|
|
|
|
|
+ document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
|
|
|
|
|
+ const name = el.innerText.toLowerCase();
|
|
|
|
|
+ if(!val) el.classList.remove('dimmed');
|
|
|
|
|
+ else if (name.includes(val)) el.classList.remove('dimmed');
|
|
|
|
|
+ else el.classList.add('dimmed');
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+ searchInput.addEventListener('keydown', (e) => {{
|
|
|
|
|
+ if (e.key === 'Enter') {{
|
|
|
|
|
+ const val = searchInput.value.toLowerCase();
|
|
|
|
|
+ if (!val) return;
|
|
|
|
|
+ const match = Object.values(flatData.map).find(n => n.name.toLowerCase().includes(val));
|
|
|
|
|
+ if (match) focusOnNode(match);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ function focusOnNode(node) {{
|
|
|
|
|
+ const nodeCenterX = node.x + node.width / 2;
|
|
|
|
|
+ const nodeCenterY = node.y + node.height / 2;
|
|
|
|
|
+ const viewW = container.offsetWidth;
|
|
|
|
|
+ const viewH = container.offsetHeight;
|
|
|
|
|
+ translateX = (viewW / 2) - (nodeCenterX * scale);
|
|
|
|
|
+ translateY = (viewH / 2) - (nodeCenterY * scale);
|
|
|
|
|
+ canvas.classList.add('animating');
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
|
|
|
|
|
+ highlightDirectSources(node);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ container.addEventListener('mousedown', e => {{
|
|
|
|
|
+ if (e.target === container || e.target.id === 'canvas' || e.target.classList.contains('edge-layer')) {{
|
|
|
|
|
+ isDragging = true;
|
|
|
|
|
+ hasDragged = false; // 重置拖动标志
|
|
|
|
|
+ startClientX = e.clientX;
|
|
|
|
|
+ startClientY = e.clientY;
|
|
|
|
|
+ startTranslateX = translateX;
|
|
|
|
|
+ startTranslateY = translateY;
|
|
|
|
|
+ container.classList.add('grabbing');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ window.addEventListener('mousemove', e => {{
|
|
|
|
|
+ if (isDragging) {{
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ translateX = startTranslateX + (e.clientX - startClientX) * DRAG_SENSITIVITY;
|
|
|
|
|
+ translateY = startTranslateY + (e.clientY - startClientY) * DRAG_SENSITIVITY;
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ hasDragged = true; // 标记发生了拖动
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ window.addEventListener('mouseup', () => {{
|
|
|
|
|
+ isDragging = false;
|
|
|
|
|
+ container.classList.remove('grabbing');
|
|
|
|
|
+ }});
|
|
|
|
|
+ container.addEventListener('wheel', e => {{
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+
|
|
|
|
|
+ // 以当前视图中心为缩放中心,保证缩放时画面不会“往左上角跑”
|
|
|
|
|
+ const viewW = container.offsetWidth;
|
|
|
|
|
+ const viewH = container.offsetHeight;
|
|
|
|
|
+ const centerScreenX = viewW / 2;
|
|
|
|
|
+ const centerScreenY = viewH / 2;
|
|
|
|
|
+
|
|
|
|
|
+ // 当前视图中心对应的画布坐标(world coords)
|
|
|
|
|
+ const centerWorldX = (centerScreenX - translateX) / scale;
|
|
|
|
|
+ const centerWorldY = (centerScreenY - translateY) / scale;
|
|
|
|
|
+
|
|
|
|
|
+ // 更小的缩放步长,让滚轮缩放更平滑
|
|
|
|
|
+ const zoomStep = 0.05; // 每次滚轮约 5% 的缩放变化
|
|
|
|
|
+ const zoomFactor = e.deltaY > 0 ? (1 - zoomStep) : (1 + zoomStep);
|
|
|
|
|
+ const newScale = Math.max(0.1, Math.min(3, scale * zoomFactor));
|
|
|
|
|
+
|
|
|
|
|
+ // 根据新的缩放比例,调整平移量,使视图中心保持不动
|
|
|
|
|
+ translateX = centerScreenX - centerWorldX * newScale;
|
|
|
|
|
+ translateY = centerScreenY - centerWorldY * newScale;
|
|
|
|
|
+ scale = newScale;
|
|
|
|
|
+
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ }}, {{ passive: false }});
|
|
|
|
|
+
|
|
|
|
|
+ function escapeHtml(text) {{
|
|
|
|
|
+ if (!text) return "";
|
|
|
|
|
+ const div = document.createElement("div");
|
|
|
|
|
+ div.textContent = text;
|
|
|
|
|
+ return div.innerHTML;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function showSidebar(detail, title, fullData, sidebarType) {{
|
|
|
|
|
+ const container = document.getElementById('sidebar-content');
|
|
|
|
|
+ const sidebar = document.getElementById('sidebar');
|
|
|
|
|
+ const appContainer = document.getElementById('app-container');
|
|
|
|
|
+ const titleEl = document.getElementById('sidebar-title');
|
|
|
|
|
+ if (titleEl) titleEl.textContent = (sidebarType === 'edge') ? '边详情' : '节点详情';
|
|
|
|
|
+ container.innerHTML = '';
|
|
|
|
|
+
|
|
|
|
|
+ if (fullData && (fullData.id === "root" || fullData.name === "root")) {{
|
|
|
|
|
+ renderRootDetail(detail, container);
|
|
|
|
|
+ sidebar.classList.add('active');
|
|
|
|
|
+ appContainer.classList.add('sidebar-open');
|
|
|
|
|
+ updateCanvasWidth();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 添加标题
|
|
|
|
|
+ const titleDiv = document.createElement('div');
|
|
|
|
|
+ titleDiv.className = 'detail-item';
|
|
|
|
|
+ const titleLabel = document.createElement('label');
|
|
|
|
|
+ titleLabel.textContent = title || '节点详情';
|
|
|
|
|
+ titleDiv.appendChild(titleLabel);
|
|
|
|
|
+ container.appendChild(titleDiv);
|
|
|
|
|
+
|
|
|
|
|
+ // 检查是否是外部边或工具边(通过 edgeName 判断)
|
|
|
|
|
+ const edgeName = fullData?.edgeName || '';
|
|
|
|
|
+ const isExternalEdge = edgeName && (edgeName.includes('外部搜索') || edgeName.includes('外部寻找'));
|
|
|
|
|
+ const isToolEdge = edgeName && edgeName.includes('工具');
|
|
|
|
|
+
|
|
|
|
|
+ // 外部边:特殊展示逻辑
|
|
|
|
|
+ if (isExternalEdge) {{
|
|
|
|
|
+ renderExternalEdgeDetail(detail, container, fullData?.name || '');
|
|
|
|
|
+ if (fullData && fullData.edgeScore !== undefined && fullData.edgeScore !== null) {{
|
|
|
|
|
+ const scoreItem = document.createElement('div');
|
|
|
|
|
+ scoreItem.className = 'detail-item';
|
|
|
|
|
+ const scoreLabel = document.createElement('label');
|
|
|
|
|
+ scoreLabel.textContent = 'Score:';
|
|
|
|
|
+ scoreItem.appendChild(scoreLabel);
|
|
|
|
|
+ const scoreVal = document.createElement('div');
|
|
|
|
|
+ scoreVal.className = 'detail-val';
|
|
|
|
|
+ scoreVal.textContent = fullData.edgeScore.toFixed(4);
|
|
|
|
|
+ scoreItem.appendChild(scoreVal);
|
|
|
|
|
+ container.appendChild(scoreItem);
|
|
|
|
|
+ }}
|
|
|
|
|
+ sidebar.classList.add('active');
|
|
|
|
|
+ appContainer.classList.add('sidebar-open');
|
|
|
|
|
+ updateCanvasWidth();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 工具边:特殊展示逻辑
|
|
|
|
|
+ if (isToolEdge) {{
|
|
|
|
|
+ renderToolEdgeDetail(detail, container, fullData?.name || '');
|
|
|
|
|
+ if (fullData && fullData.edgeScore !== undefined && fullData.edgeScore !== null) {{
|
|
|
|
|
+ const scoreItem = document.createElement('div');
|
|
|
|
|
+ scoreItem.className = 'detail-item';
|
|
|
|
|
+ const scoreLabel = document.createElement('label');
|
|
|
|
|
+ scoreLabel.textContent = 'Score:';
|
|
|
|
|
+ scoreItem.appendChild(scoreLabel);
|
|
|
|
|
+ const scoreVal = document.createElement('div');
|
|
|
|
|
+ scoreVal.className = 'detail-val';
|
|
|
|
|
+ scoreVal.textContent = fullData.edgeScore.toFixed(4);
|
|
|
|
|
+ scoreItem.appendChild(scoreVal);
|
|
|
|
|
+ container.appendChild(scoreItem);
|
|
|
|
|
+ }}
|
|
|
|
|
+ sidebar.classList.add('active');
|
|
|
|
|
+ appContainer.classList.add('sidebar-open');
|
|
|
|
|
+ updateCanvasWidth();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 过滤掉路径相关的字段
|
|
|
|
|
+ const pathFields = ['source', 'target', 'id', 'originalData', 'internal', 'external', 'internal_edge', 'external_edge', 'children', 'parent_edge', 'sources', 'edgeName', 'edgeScore', 'edgeGroupKey'];
|
|
|
|
|
+
|
|
|
|
|
+ // 过滤detail中的路径字段
|
|
|
|
|
+ const filteredDetail = {{}};
|
|
|
|
|
+ if (detail && typeof detail === "object") {{
|
|
|
|
|
+ Object.entries(detail).forEach(([key, value]) => {{
|
|
|
|
|
+ if (!pathFields.includes(key)) {{
|
|
|
|
|
+ filteredDetail[key] = value;
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 如果没有detail内容
|
|
|
|
|
+ if (!filteredDetail || Object.keys(filteredDetail).length === 0) {{
|
|
|
|
|
+ const emptyDiv = document.createElement('div');
|
|
|
|
|
+ emptyDiv.className = 'detail-empty';
|
|
|
|
|
+ emptyDiv.textContent = '暂无详情信息';
|
|
|
|
|
+ container.appendChild(emptyDiv);
|
|
|
|
|
+ sidebar.classList.add('active');
|
|
|
|
|
+ appContainer.classList.add('sidebar-open');
|
|
|
|
|
+ updateCanvasWidth();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 显示detail内容
|
|
|
|
|
+ Object.entries(filteredDetail).forEach(([key, value]) => {{
|
|
|
|
|
+ // 跳过空值
|
|
|
|
|
+ if (value === null || value === undefined || value === "") return;
|
|
|
|
|
+
|
|
|
|
|
+ const item = document.createElement('div');
|
|
|
|
|
+ item.className = 'detail-item';
|
|
|
|
|
+
|
|
|
|
|
+ const label = document.createElement('label');
|
|
|
|
|
+ label.textContent = key + ':';
|
|
|
|
|
+ item.appendChild(label);
|
|
|
|
|
+
|
|
|
|
|
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {{
|
|
|
|
|
+ // 对象结构,展示 KV 列表
|
|
|
|
|
+ const subContainer = document.createElement('div');
|
|
|
|
|
+ subContainer.className = 'detail-val';
|
|
|
|
|
+ subContainer.style.paddingLeft = '15px';
|
|
|
|
|
+ subContainer.style.borderLeft = '3px solid #eee';
|
|
|
|
|
+ subContainer.style.marginTop = '10px';
|
|
|
|
|
+ subContainer.style.fontSize = '14px';
|
|
|
|
|
+
|
|
|
|
|
+ Object.entries(value).forEach(([subKey, subValue]) => {{
|
|
|
|
|
+ if (subValue === null || subValue === undefined || subValue === "") return;
|
|
|
|
|
+ const subItem = document.createElement('div');
|
|
|
|
|
+ subItem.style.marginBottom = '8px';
|
|
|
|
|
+ const subKeySpan = document.createElement('span');
|
|
|
|
|
+ subKeySpan.style.color = '#666';
|
|
|
|
|
+ subKeySpan.textContent = subKey + ': ';
|
|
|
|
|
+ subItem.appendChild(subKeySpan);
|
|
|
|
|
+ const subValSpan = document.createElement('span');
|
|
|
|
|
+ subValSpan.textContent = typeof subValue === 'object' ? JSON.stringify(subValue) : subValue;
|
|
|
|
|
+ subItem.appendChild(subValSpan);
|
|
|
|
|
+ subContainer.appendChild(subItem);
|
|
|
|
|
+ }});
|
|
|
|
|
+ item.appendChild(subContainer);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ const valueContainer = document.createElement('div');
|
|
|
|
|
+ valueContainer.className = 'detail-val';
|
|
|
|
|
+
|
|
|
|
|
+ if (Array.isArray(value)) {{
|
|
|
|
|
+ if (value.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 检查数组元素是否为对象
|
|
|
|
|
+ const isArrayOfObjects = value.length > 0 && typeof value[0] === 'object' && value[0] !== null;
|
|
|
|
|
+
|
|
|
|
|
+ if (isArrayOfObjects) {{
|
|
|
|
|
+ // 数组元素为对象时,使用表格展示
|
|
|
|
|
+ const table = document.createElement('table');
|
|
|
|
|
+ table.style.width = '100%';
|
|
|
|
|
+ table.style.borderCollapse = 'collapse';
|
|
|
|
|
+ table.style.fontSize = '13px';
|
|
|
|
|
+ table.style.marginTop = '5px';
|
|
|
|
|
+
|
|
|
|
|
+ // 统计所有列名(字段)
|
|
|
|
|
+ const columnsSet = new Set();
|
|
|
|
|
+ value.forEach(v => {{
|
|
|
|
|
+ if (v && typeof v === 'object') {{
|
|
|
|
|
+ Object.keys(v).forEach(k => columnsSet.add(k));
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ const columns = Array.from(columnsSet);
|
|
|
|
|
+
|
|
|
|
|
+ // 表头
|
|
|
|
|
+ const thead = document.createElement('thead');
|
|
|
|
|
+ const headerRow = document.createElement('tr');
|
|
|
|
|
+ const thIndex = document.createElement('th');
|
|
|
|
|
+ thIndex.textContent = '#';
|
|
|
|
|
+ thIndex.style.borderBottom = '1px solid #eee';
|
|
|
|
|
+ thIndex.style.padding = '4px 6px';
|
|
|
|
|
+ thIndex.style.textAlign = 'left';
|
|
|
|
|
+ thIndex.style.color = '#888';
|
|
|
|
|
+ headerRow.appendChild(thIndex);
|
|
|
|
|
+ columns.forEach(col => {{
|
|
|
|
|
+ const th = document.createElement('th');
|
|
|
|
|
+ th.textContent = col;
|
|
|
|
|
+ th.style.borderBottom = '1px solid #eee';
|
|
|
|
|
+ th.style.padding = '4px 6px';
|
|
|
|
|
+ th.style.textAlign = 'left';
|
|
|
|
|
+ th.style.color = '#555';
|
|
|
|
|
+ headerRow.appendChild(th);
|
|
|
|
|
+ }});
|
|
|
|
|
+ thead.appendChild(headerRow);
|
|
|
|
|
+ table.appendChild(thead);
|
|
|
|
|
+
|
|
|
|
|
+ // 表体
|
|
|
|
|
+ const tbody = document.createElement('tbody');
|
|
|
|
|
+ value.forEach((v, i) => {{
|
|
|
|
|
+ const row = document.createElement('tr');
|
|
|
|
|
+ row.style.borderBottom = '1px dashed #f0f0f0';
|
|
|
|
|
+ const tdIndex = document.createElement('td');
|
|
|
|
|
+ tdIndex.textContent = i + 1;
|
|
|
|
|
+ tdIndex.style.padding = '4px 6px';
|
|
|
|
|
+ tdIndex.style.color = '#999';
|
|
|
|
|
+ row.appendChild(tdIndex);
|
|
|
|
|
+ columns.forEach(col => {{
|
|
|
|
|
+ const cellVal = v && typeof v === 'object' ? v[col] : undefined;
|
|
|
|
|
+ const td = document.createElement('td');
|
|
|
|
|
+ td.textContent = cellVal === undefined || cellVal === null
|
|
|
|
|
+ ? ""
|
|
|
|
|
+ : (typeof cellVal === 'object' ? JSON.stringify(cellVal) : cellVal);
|
|
|
|
|
+ td.style.padding = '4px 6px';
|
|
|
|
|
+ td.style.color = '#666';
|
|
|
|
|
+ td.style.verticalAlign = 'top';
|
|
|
|
|
+ row.appendChild(td);
|
|
|
|
|
+ }});
|
|
|
|
|
+ tbody.appendChild(row);
|
|
|
|
|
+ }});
|
|
|
|
|
+ table.appendChild(tbody);
|
|
|
|
|
+ valueContainer.appendChild(table);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // 普通数组:每个元素一行的单列表格
|
|
|
|
|
+ const table = document.createElement('table');
|
|
|
|
|
+ table.style.width = '100%';
|
|
|
|
|
+ table.style.borderCollapse = 'collapse';
|
|
|
|
|
+ table.style.fontSize = '13px';
|
|
|
|
|
+ table.style.marginTop = '5px';
|
|
|
|
|
+
|
|
|
|
|
+ const thead = document.createElement('thead');
|
|
|
|
|
+ const headerRow = document.createElement('tr');
|
|
|
|
|
+ const thIndex = document.createElement('th');
|
|
|
|
|
+ thIndex.textContent = '#';
|
|
|
|
|
+ thIndex.style.borderBottom = '1px solid #eee';
|
|
|
|
|
+ thIndex.style.padding = '4px 6px';
|
|
|
|
|
+ thIndex.style.textAlign = 'left';
|
|
|
|
|
+ thIndex.style.color = '#888';
|
|
|
|
|
+ headerRow.appendChild(thIndex);
|
|
|
|
|
+ const thValue = document.createElement('th');
|
|
|
|
|
+ thValue.textContent = 'value';
|
|
|
|
|
+ thValue.style.borderBottom = '1px solid #eee';
|
|
|
|
|
+ thValue.style.padding = '4px 6px';
|
|
|
|
|
+ thValue.style.textAlign = 'left';
|
|
|
|
|
+ thValue.style.color = '#555';
|
|
|
|
|
+ headerRow.appendChild(thValue);
|
|
|
|
|
+ thead.appendChild(headerRow);
|
|
|
|
|
+ table.appendChild(thead);
|
|
|
|
|
+
|
|
|
|
|
+ const tbody = document.createElement('tbody');
|
|
|
|
|
+ value.forEach((v, i) => {{
|
|
|
|
|
+ const row = document.createElement('tr');
|
|
|
|
|
+ row.style.borderBottom = '1px dashed #f0f0f0';
|
|
|
|
|
+ const tdIndex = document.createElement('td');
|
|
|
|
|
+ tdIndex.textContent = i + 1;
|
|
|
|
|
+ tdIndex.style.padding = '4px 6px';
|
|
|
|
|
+ tdIndex.style.color = '#999';
|
|
|
|
|
+ row.appendChild(tdIndex);
|
|
|
|
|
+ const tdValue = document.createElement('td');
|
|
|
|
|
+ tdValue.textContent = typeof v === 'object' ? JSON.stringify(v) : v;
|
|
|
|
|
+ tdValue.style.padding = '4px 6px';
|
|
|
|
|
+ tdValue.style.color = '#666';
|
|
|
|
|
+ tdValue.style.verticalAlign = 'top';
|
|
|
|
|
+ row.appendChild(tdValue);
|
|
|
|
|
+ tbody.appendChild(row);
|
|
|
|
|
+ }});
|
|
|
|
|
+ table.appendChild(tbody);
|
|
|
|
|
+ valueContainer.appendChild(table);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ let displayValue = value;
|
|
|
|
|
+ if (typeof value === "string" && value.length > 500) {{
|
|
|
|
|
+ displayValue = value.substring(0, 500) + "...";
|
|
|
|
|
+ }}
|
|
|
|
|
+ valueContainer.textContent = displayValue;
|
|
|
|
|
+ }}
|
|
|
|
|
+ item.appendChild(valueContainer);
|
|
|
|
|
+ }}
|
|
|
|
|
+ container.appendChild(item);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 如果有score,也显示
|
|
|
|
|
+ if (fullData && fullData.score !== undefined && fullData.score !== null) {{
|
|
|
|
|
+ const scoreItem = document.createElement('div');
|
|
|
|
|
+ scoreItem.className = 'detail-item';
|
|
|
|
|
+ const scoreLabel = document.createElement('label');
|
|
|
|
|
+ scoreLabel.textContent = 'Score:';
|
|
|
|
|
+ scoreItem.appendChild(scoreLabel);
|
|
|
|
|
+ const scoreVal = document.createElement('div');
|
|
|
|
|
+ scoreVal.className = 'detail-val';
|
|
|
|
|
+ scoreVal.textContent = fullData.score.toFixed(4);
|
|
|
|
|
+ scoreItem.appendChild(scoreVal);
|
|
|
|
|
+ container.appendChild(scoreItem);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ sidebar.classList.add('active');
|
|
|
|
|
+ appContainer.classList.add('sidebar-open');
|
|
|
|
|
+ updateCanvasWidth();
|
|
|
|
|
+ }}
|
|
|
|
|
+ function renderExternalEdgeDetail(detail, container, targetNodeName) {{
|
|
|
|
|
+ if (!detail) return;
|
|
|
|
|
+
|
|
|
|
|
+ const targetName = targetNodeName || "";
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 全局常量
|
|
|
|
|
+ const globalConstants = detail["全局常量"] || [];
|
|
|
|
|
+ if (Array.isArray(globalConstants) && globalConstants.length > 0) {{
|
|
|
|
|
+ const globalSection = document.createElement('div');
|
|
|
|
|
+ globalSection.className = 'detail-item';
|
|
|
|
|
+ const globalLabel = document.createElement('label');
|
|
|
|
|
+ globalLabel.textContent = '全局常量:';
|
|
|
|
|
+ globalSection.appendChild(globalLabel);
|
|
|
|
|
+ const globalValue = document.createElement('div');
|
|
|
|
|
+ globalValue.className = 'detail-val';
|
|
|
|
|
+ globalValue.style.marginTop = '8px';
|
|
|
|
|
+ globalValue.textContent = globalConstants.join('、');
|
|
|
|
|
+ globalSection.appendChild(globalValue);
|
|
|
|
|
+ container.appendChild(globalSection);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 局部常量
|
|
|
|
|
+ const localConstants = detail["局部常量"] || [];
|
|
|
|
|
+ if (Array.isArray(localConstants) && localConstants.length > 0) {{
|
|
|
|
|
+ const localSection = document.createElement('div');
|
|
|
|
|
+ localSection.className = 'detail-item';
|
|
|
|
|
+ const localLabel = document.createElement('label');
|
|
|
|
|
+ localLabel.textContent = '局部常量:';
|
|
|
|
|
+ localSection.appendChild(localLabel);
|
|
|
|
|
+ const localValue = document.createElement('div');
|
|
|
|
|
+ localValue.className = 'detail-val';
|
|
|
|
|
+ localValue.style.marginTop = '8px';
|
|
|
|
|
+ localValue.textContent = localConstants.join('、');
|
|
|
|
|
+ localSection.appendChild(localValue);
|
|
|
|
|
+ container.appendChild(localSection);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 匹配到的模式
|
|
|
|
|
+ const matchedPatterns = detail["匹配到的模式"] || [];
|
|
|
|
|
+ if (Array.isArray(matchedPatterns) && matchedPatterns.length > 0) {{
|
|
|
|
|
+ const patternSection = document.createElement('div');
|
|
|
|
|
+ patternSection.className = 'detail-item';
|
|
|
|
|
+ const patternLabel = document.createElement('label');
|
|
|
|
|
+ patternLabel.textContent = '匹配到的模式:';
|
|
|
|
|
+ patternSection.appendChild(patternLabel);
|
|
|
|
|
+ const patternValue = document.createElement('div');
|
|
|
|
|
+ patternValue.className = 'detail-val';
|
|
|
|
|
+ patternValue.style.marginTop = '8px';
|
|
|
|
|
+ container.appendChild(patternSection);
|
|
|
|
|
+ patternSection.appendChild(patternValue);
|
|
|
|
|
+
|
|
|
|
|
+ matchedPatterns.forEach((pattern, idx) => {{
|
|
|
|
|
+ const patternBlock = document.createElement('div');
|
|
|
|
|
+ patternBlock.style.marginBottom = idx < matchedPatterns.length - 1 ? '12px' : '0';
|
|
|
|
|
+ patternBlock.style.paddingBottom = idx < matchedPatterns.length - 1 ? '12px' : '0';
|
|
|
|
|
+ patternBlock.style.borderBottom = idx < matchedPatterns.length - 1 ? '1px solid #eee' : 'none';
|
|
|
|
|
+
|
|
|
|
|
+ // 显示 id 和 support
|
|
|
|
|
+ const patternHeader = document.createElement('div');
|
|
|
|
|
+ patternHeader.style.fontWeight = 'bold';
|
|
|
|
|
+ patternHeader.style.marginBottom = '6px';
|
|
|
|
|
+ patternHeader.style.fontSize = '13px';
|
|
|
|
|
+ patternHeader.textContent = '模式 ID: ' + (pattern.id || "") + ', Support: ' + (pattern.support !== undefined ? pattern.support.toFixed(4) : "");
|
|
|
|
|
+ patternBlock.appendChild(patternHeader);
|
|
|
|
|
+
|
|
|
|
|
+ // items 展示成表格
|
|
|
|
|
+ const items = pattern.items || [];
|
|
|
|
|
+ if (items.length > 0) {{
|
|
|
|
|
+ const itemsTable = document.createElement('table');
|
|
|
|
|
+ itemsTable.style.width = '100%';
|
|
|
|
|
+ itemsTable.style.borderCollapse = 'collapse';
|
|
|
|
|
+ itemsTable.style.fontSize = '13px';
|
|
|
|
|
+ itemsTable.style.marginTop = '6px';
|
|
|
|
|
+ itemsTable.style.marginBottom = '6px';
|
|
|
|
|
+
|
|
|
|
|
+ const thead = document.createElement('thead');
|
|
|
|
|
+ const headerRow = document.createElement('tr');
|
|
|
|
|
+ ["name", "point", "dimension", "type"].forEach(col => {{
|
|
|
|
|
+ const th = document.createElement('th');
|
|
|
|
|
+ th.textContent = col;
|
|
|
|
|
+ th.style.border = '1px solid #ddd';
|
|
|
|
|
+ th.style.padding = '6px 8px';
|
|
|
|
|
+ th.style.textAlign = 'left';
|
|
|
|
|
+ th.style.background = '#f5f5f5';
|
|
|
|
|
+ headerRow.appendChild(th);
|
|
|
|
|
+ }});
|
|
|
|
|
+ thead.appendChild(headerRow);
|
|
|
|
|
+ itemsTable.appendChild(thead);
|
|
|
|
|
+
|
|
|
|
|
+ const tbody = document.createElement('tbody');
|
|
|
|
|
+ items.forEach(item => {{
|
|
|
|
|
+ const tr = document.createElement('tr');
|
|
|
|
|
+ ["name", "point", "dimension", "type"].forEach(col => {{
|
|
|
|
|
+ const td = document.createElement('td');
|
|
|
|
|
+ td.textContent = item[col] || "";
|
|
|
|
|
+ td.style.border = '1px solid #ddd';
|
|
|
|
|
+ td.style.padding = '6px 8px';
|
|
|
|
|
+ tr.appendChild(td);
|
|
|
|
|
+ }});
|
|
|
|
|
+ tbody.appendChild(tr);
|
|
|
|
|
+ }});
|
|
|
|
|
+ itemsTable.appendChild(tbody);
|
|
|
|
|
+ patternBlock.appendChild(itemsTable);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // match_points
|
|
|
|
|
+ const matchPoints = pattern.match_points || [];
|
|
|
|
|
+ if (matchPoints.length > 0) {{
|
|
|
|
|
+ const matchPointsDiv = document.createElement('div');
|
|
|
|
|
+ matchPointsDiv.style.fontSize = '13px';
|
|
|
|
|
+ matchPointsDiv.style.color = '#666';
|
|
|
|
|
+ matchPointsDiv.style.marginTop = '4px';
|
|
|
|
|
+ matchPointsDiv.textContent = '匹配点: ' + matchPoints.join('、');
|
|
|
|
|
+ patternBlock.appendChild(matchPointsDiv);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ patternValue.appendChild(patternBlock);
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 动态常量
|
|
|
|
|
+ const dynamicConstants = detail["动态常量"] || [];
|
|
|
|
|
+ if (Array.isArray(dynamicConstants) && dynamicConstants.length > 0) {{
|
|
|
|
|
+ const dynamicSection = document.createElement('div');
|
|
|
|
|
+ dynamicSection.className = 'detail-item';
|
|
|
|
|
+ const dynamicLabel = document.createElement('label');
|
|
|
|
|
+ dynamicLabel.textContent = '动态常量:';
|
|
|
|
|
+ dynamicSection.appendChild(dynamicLabel);
|
|
|
|
|
+ const dynamicValue = document.createElement('div');
|
|
|
|
|
+ dynamicValue.className = 'detail-val';
|
|
|
|
|
+ dynamicValue.style.marginTop = '8px';
|
|
|
|
|
+ container.appendChild(dynamicSection);
|
|
|
|
|
+ dynamicSection.appendChild(dynamicValue);
|
|
|
|
|
+
|
|
|
|
|
+ const dynamicTable = document.createElement('table');
|
|
|
|
|
+ dynamicTable.style.width = '100%';
|
|
|
|
|
+ dynamicTable.style.borderCollapse = 'collapse';
|
|
|
|
|
+ dynamicTable.style.fontSize = '13px';
|
|
|
|
|
+
|
|
|
|
|
+ const thead = document.createElement('thead');
|
|
|
|
|
+ const headerRow = document.createElement('tr');
|
|
|
|
|
+ const columns = ["point", "tree_parent_node", "match_score", "tree_child_node", "relative_ratio"];
|
|
|
|
|
+ columns.forEach(col => {{
|
|
|
|
|
+ const th = document.createElement('th');
|
|
|
|
|
+ th.textContent = col;
|
|
|
|
|
+ th.style.border = '1px solid #ddd';
|
|
|
|
|
+ th.style.padding = '6px 8px';
|
|
|
|
|
+ th.style.textAlign = 'left';
|
|
|
|
|
+ th.style.background = '#f5f5f5';
|
|
|
|
|
+ headerRow.appendChild(th);
|
|
|
|
|
+ }});
|
|
|
|
|
+ thead.appendChild(headerRow);
|
|
|
|
|
+ dynamicTable.appendChild(thead);
|
|
|
|
|
+
|
|
|
|
|
+ // 计算每列的合并信息
|
|
|
|
|
+ const getValue = (dc, col) => {{
|
|
|
|
|
+ if (col === "match_score") return dc.match_score !== undefined ? dc.match_score.toFixed(4) : "";
|
|
|
|
|
+ if (col === "relative_ratio") return dc.relative_ratio !== undefined ? dc.relative_ratio.toFixed(4) : "";
|
|
|
|
|
+ return dc[col] || "";
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ const getRowspan = (colIdx, rowIdx) => {{
|
|
|
|
|
+ const currentValue = getValue(dynamicConstants[rowIdx], columns[colIdx]);
|
|
|
|
|
+ let span = 1;
|
|
|
|
|
+ for (let i = rowIdx + 1; i < dynamicConstants.length; i++) {{
|
|
|
|
|
+ const nextValue = getValue(dynamicConstants[i], columns[colIdx]);
|
|
|
|
|
+ if (nextValue === currentValue) {{
|
|
|
|
|
+ span++;
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ break;
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ return span;
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ const shouldSkipCell = (colIdx, rowIdx) => {{
|
|
|
|
|
+ if (rowIdx === 0) return false;
|
|
|
|
|
+ const currentValue = getValue(dynamicConstants[rowIdx], columns[colIdx]);
|
|
|
|
|
+ const prevValue = getValue(dynamicConstants[rowIdx - 1], columns[colIdx]);
|
|
|
|
|
+ return currentValue === prevValue;
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ const tbody = document.createElement('tbody');
|
|
|
|
|
+ dynamicConstants.forEach((dc, rowIdx) => {{
|
|
|
|
|
+ const tr = document.createElement('tr');
|
|
|
|
|
+ columns.forEach((col, colIdx) => {{
|
|
|
|
|
+ if (shouldSkipCell(colIdx, rowIdx)) {{
|
|
|
|
|
+ // 跳过,因为会被上一行的 rowspan 覆盖
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+ const cellValue = getValue(dc, col);
|
|
|
|
|
+ const span = getRowspan(colIdx, rowIdx);
|
|
|
|
|
+ const td = document.createElement('td');
|
|
|
|
|
+ td.textContent = cellValue;
|
|
|
|
|
+ td.style.border = '1px solid #ddd';
|
|
|
|
|
+ td.style.padding = '6px 8px';
|
|
|
|
|
+ if (span > 1) {{
|
|
|
|
|
+ td.setAttribute('rowspan', span);
|
|
|
|
|
+ }}
|
|
|
|
|
+ tr.appendChild(td);
|
|
|
|
|
+ }});
|
|
|
|
|
+ tbody.appendChild(tr);
|
|
|
|
|
+ }});
|
|
|
|
|
+ dynamicTable.appendChild(tbody);
|
|
|
|
|
+ dynamicValue.appendChild(dynamicTable);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 4.5. 推导成功的选题点
|
|
|
|
|
+ const successfulPointsRaw = detail["推导成功的选题点"] || [];
|
|
|
|
|
+ if (Array.isArray(successfulPointsRaw) && successfulPointsRaw.length > 0) {{
|
|
|
|
|
+ // 处理字符串数组或对象数组(对象需有 name 字段)
|
|
|
|
|
+ const successfulPoints = successfulPointsRaw.map(item => {{
|
|
|
|
|
+ if (typeof item === "string") {{
|
|
|
|
|
+ return item;
|
|
|
|
|
+ }} else if (item && typeof item === "object" && item.name) {{
|
|
|
|
|
+ return item.name;
|
|
|
|
|
+ }}
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }}).filter(Boolean);
|
|
|
|
|
+
|
|
|
|
|
+ if (successfulPoints.length > 0) {{
|
|
|
|
|
+ const successfulSection = document.createElement('div');
|
|
|
|
|
+ successfulSection.className = 'detail-item';
|
|
|
|
|
+ const successfulLabel = document.createElement('label');
|
|
|
|
|
+ successfulLabel.textContent = '推导成功的选题点:';
|
|
|
|
|
+ successfulSection.appendChild(successfulLabel);
|
|
|
|
|
+ const successfulValue = document.createElement('div');
|
|
|
|
|
+ successfulValue.className = 'detail-val';
|
|
|
|
|
+ successfulValue.style.marginTop = '8px';
|
|
|
|
|
+ successfulValue.textContent = successfulPoints.join('、');
|
|
|
|
|
+ successfulSection.appendChild(successfulValue);
|
|
|
|
|
+ container.appendChild(successfulSection);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 5. Query列表
|
|
|
|
|
+ const querySection = document.createElement('div');
|
|
|
|
|
+ querySection.className = 'detail-item';
|
|
|
|
|
+ const queryLabelRow = document.createElement('div');
|
|
|
|
|
+ queryLabelRow.style.display = 'flex';
|
|
|
|
|
+ queryLabelRow.style.justifyContent = 'space-between';
|
|
|
|
|
+ queryLabelRow.style.alignItems = 'center';
|
|
|
|
|
+ queryLabelRow.style.marginBottom = '8px';
|
|
|
|
|
+ const queryLabel = document.createElement('label');
|
|
|
|
|
+ queryLabel.style.margin = '0';
|
|
|
|
|
+ queryLabel.textContent = 'Query列表:';
|
|
|
|
|
+ queryLabelRow.appendChild(queryLabel);
|
|
|
|
|
+ querySection.appendChild(queryLabelRow);
|
|
|
|
|
+ const queryList = detail.query_list || [];
|
|
|
|
|
+ const queryStrs = queryList.map(q => q && q.query_str ? q.query_str : (typeof q === "string" ? q : "")).filter(Boolean);
|
|
|
|
|
+ const queryValueDiv = document.createElement('div');
|
|
|
|
|
+ queryValueDiv.className = 'detail-val';
|
|
|
|
|
+ queryValueDiv.style.marginTop = '4px';
|
|
|
|
|
+ queryValueDiv.style.whiteSpace = 'pre-wrap';
|
|
|
|
|
+ if (queryStrs.length) {{
|
|
|
|
|
+ queryValueDiv.textContent = queryStrs.join('\\n');
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ queryValueDiv.textContent = '暂无';
|
|
|
|
|
+ }}
|
|
|
|
|
+ querySection.appendChild(queryValueDiv);
|
|
|
|
|
+ container.appendChild(querySection);
|
|
|
|
|
+
|
|
|
|
|
+ // 6. 外部寻找结果 (match_result 为数组,按匹配率倒序显示)
|
|
|
|
|
+ const matchSection = document.createElement('div');
|
|
|
|
|
+ matchSection.className = 'detail-item';
|
|
|
|
|
+ const matchLabelRow = document.createElement('div');
|
|
|
|
|
+ matchLabelRow.style.display = 'flex';
|
|
|
|
|
+ matchLabelRow.style.justifyContent = 'space-between';
|
|
|
|
|
+ matchLabelRow.style.alignItems = 'center';
|
|
|
|
|
+ matchLabelRow.style.marginBottom = '8px';
|
|
|
|
|
+ const matchLabel = document.createElement('label');
|
|
|
|
|
+ matchLabel.style.margin = '0';
|
|
|
|
|
+ matchLabel.textContent = '外部寻找结果:';
|
|
|
|
|
+ matchLabelRow.appendChild(matchLabel);
|
|
|
|
|
+ matchSection.appendChild(matchLabelRow);
|
|
|
|
|
+ const matchResultArr = detail.match_result || [];
|
|
|
|
|
+
|
|
|
|
|
+ const matchWithStats = matchResultArr.map(mr => {{
|
|
|
|
|
+ const nodeList = mr.node_list || [];
|
|
|
|
|
+ const searchCount = nodeList.length;
|
|
|
|
|
+ const matchCount = nodeList.filter(n => n.eval_result && n.eval_result.匹配类型 === "完全匹配").length;
|
|
|
|
|
+ const matchRate = searchCount > 0 ? (matchCount / searchCount * 100) : 0;
|
|
|
|
|
+ return {{ ...mr, searchCount, matchCount, matchRate }};
|
|
|
|
|
+ }});
|
|
|
|
|
+ const sortedMatchResult = [...matchWithStats].sort((a, b) => b.matchRate - a.matchRate);
|
|
|
|
|
+
|
|
|
|
|
+ const matchValue = document.createElement('div');
|
|
|
|
|
+ matchValue.className = 'detail-val';
|
|
|
|
|
+ matchValue.style.marginTop = '8px';
|
|
|
|
|
+ matchSection.appendChild(matchValue);
|
|
|
|
|
+ container.appendChild(matchSection);
|
|
|
|
|
+
|
|
|
|
|
+ sortedMatchResult.forEach((matchResult, mrIdx) => {{
|
|
|
|
|
+ const nodeList = matchResult.node_list || [];
|
|
|
|
|
+ const sortedNodes = [...nodeList].sort((a, b) => {{
|
|
|
|
|
+ const scoreA = a.eval_result && a.eval_result.综合得分 !== undefined ? a.eval_result.综合得分 : -1;
|
|
|
|
|
+ const scoreB = b.eval_result && b.eval_result.综合得分 !== undefined ? b.eval_result.综合得分 : -1;
|
|
|
|
|
+ return scoreB - scoreA;
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ const queryBlock = document.createElement('div');
|
|
|
|
|
+ queryBlock.className = 'query-block';
|
|
|
|
|
+ queryBlock.style.marginBottom = mrIdx < sortedMatchResult.length - 1 ? '12px' : '0';
|
|
|
|
|
+ queryBlock.style.paddingBottom = mrIdx < sortedMatchResult.length - 1 ? '12px' : '0';
|
|
|
|
|
+ queryBlock.style.borderBottom = mrIdx < sortedMatchResult.length - 1 ? '1px solid #eee' : 'none';
|
|
|
|
|
+
|
|
|
|
|
+ const queryBody = document.createElement('div');
|
|
|
|
|
+ queryBody.className = 'query-block-body';
|
|
|
|
|
+
|
|
|
|
|
+ const headerDiv = document.createElement('div');
|
|
|
|
|
+ headerDiv.className = 'query-block-header';
|
|
|
|
|
+ headerDiv.style.fontWeight = 'bold';
|
|
|
|
|
+ headerDiv.style.fontSize = '14px';
|
|
|
|
|
+ headerDiv.style.cursor = 'pointer';
|
|
|
|
|
+ const toggleSpan = document.createElement('span');
|
|
|
|
|
+ toggleSpan.className = 'query-toggle';
|
|
|
|
|
+ toggleSpan.style.marginRight = '6px';
|
|
|
|
|
+ toggleSpan.style.display = 'inline-block';
|
|
|
|
|
+ toggleSpan.style.width = '16px';
|
|
|
|
|
+ toggleSpan.textContent = '▼';
|
|
|
|
|
+ headerDiv.appendChild(toggleSpan);
|
|
|
|
|
+ const querySpan = document.createElement('span');
|
|
|
|
|
+ querySpan.textContent = 'Query: ' + (matchResult.query_str || "暂无");
|
|
|
|
|
+ headerDiv.appendChild(querySpan);
|
|
|
|
|
+ const statsDiv = document.createElement('div');
|
|
|
|
|
+ statsDiv.style.fontSize = '13px';
|
|
|
|
|
+ statsDiv.style.color = '#666';
|
|
|
|
|
+ statsDiv.style.fontWeight = 'normal';
|
|
|
|
|
+ statsDiv.style.marginTop = '4px';
|
|
|
|
|
+ statsDiv.textContent = '搜索帖子数: ' + matchResult.searchCount + ',匹配帖子数: ' + matchResult.matchCount + ',匹配率: ' + matchResult.matchRate.toFixed(1) + '%';
|
|
|
|
|
+ headerDiv.appendChild(statsDiv);
|
|
|
|
|
+
|
|
|
|
|
+ let isExpanded = true;
|
|
|
|
|
+ headerDiv.addEventListener('click', function() {{
|
|
|
|
|
+ isExpanded = !isExpanded;
|
|
|
|
|
+ queryBody.style.display = isExpanded ? 'block' : 'none';
|
|
|
|
|
+ toggleSpan.textContent = isExpanded ? '▼' : '▶';
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ queryBlock.insertBefore(headerDiv, queryBlock.firstChild);
|
|
|
|
|
+ queryBlock.appendChild(queryBody);
|
|
|
|
|
+
|
|
|
|
|
+ sortedNodes.forEach((node, i) => {{
|
|
|
|
|
+ const postCard = document.createElement('div');
|
|
|
|
|
+ postCard.className = 'external-post-card';
|
|
|
|
|
+ postCard.style.border = '1px solid #eee';
|
|
|
|
|
+ postCard.style.borderRadius = '8px';
|
|
|
|
|
+ postCard.style.padding = '12px';
|
|
|
|
|
+ postCard.style.marginTop = '12px';
|
|
|
|
|
+ postCard.style.background = '#fafafa';
|
|
|
|
|
+
|
|
|
|
|
+ const titleDiv = document.createElement('div');
|
|
|
|
|
+ titleDiv.style.fontWeight = 'bold';
|
|
|
|
|
+ titleDiv.style.marginBottom = '6px';
|
|
|
|
|
+ titleDiv.style.fontSize = '14px';
|
|
|
|
|
+ titleDiv.textContent = (i + 1) + '. ' + (node.title || "无标题");
|
|
|
|
|
+ postCard.appendChild(titleDiv);
|
|
|
|
|
+
|
|
|
|
|
+ const bodyText = node.body_text || "";
|
|
|
|
|
+ const bodyDiv = document.createElement('div');
|
|
|
|
|
+ bodyDiv.style.fontSize = '13px';
|
|
|
|
|
+ bodyDiv.style.color = '#666';
|
|
|
|
|
+ bodyDiv.style.marginBottom = '8px';
|
|
|
|
|
+ bodyDiv.style.whiteSpace = 'pre-wrap';
|
|
|
|
|
+ bodyDiv.style.maxHeight = '100px';
|
|
|
|
|
+ bodyDiv.style.overflowY = 'auto';
|
|
|
|
|
+ bodyDiv.textContent = bodyText.length > 200 ? bodyText.substring(0, 200) + "..." : bodyText;
|
|
|
|
|
+ postCard.appendChild(bodyDiv);
|
|
|
|
|
+
|
|
|
|
|
+ const imgList = node.image_url_list || [];
|
|
|
|
|
+ const urlList = imgList.map(img => (img && img.image_url) ? img.image_url : (typeof img === "string" ? img : "")).filter(Boolean);
|
|
|
|
|
+ if (urlList.length > 0) {{
|
|
|
|
|
+ const gallery = document.createElement('div');
|
|
|
|
|
+ gallery.style.display = 'flex';
|
|
|
|
|
+ gallery.style.flexWrap = 'wrap';
|
|
|
|
|
+ gallery.style.gap = '6px';
|
|
|
|
|
+ gallery.style.marginBottom = '8px';
|
|
|
|
|
+ urlList.forEach((url, idx) => {{
|
|
|
|
|
+ const thumb = document.createElement('div');
|
|
|
|
|
+ thumb.style.display = 'block';
|
|
|
|
|
+ thumb.style.cursor = 'pointer';
|
|
|
|
|
+ const img = document.createElement('img');
|
|
|
|
|
+ img.src = url;
|
|
|
|
|
+ img.alt = '图' + (idx + 1);
|
|
|
|
|
+ img.setAttribute('data-url', url);
|
|
|
|
|
+ img.style.width = '60px';
|
|
|
|
|
+ img.style.height = '60px';
|
|
|
|
|
+ img.style.objectFit = 'cover';
|
|
|
|
|
+ img.style.borderRadius = '4px';
|
|
|
|
|
+ img.style.pointerEvents = 'none';
|
|
|
|
|
+ thumb.appendChild(img);
|
|
|
|
|
+ gallery.appendChild(thumb);
|
|
|
|
|
+ }});
|
|
|
|
|
+ postCard.appendChild(gallery);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ const evalResult = node.eval_result || {{}};
|
|
|
|
|
+ if (evalResult && Object.keys(evalResult).length > 0) {{
|
|
|
|
|
+ const evalDiv = document.createElement('div');
|
|
|
|
|
+ evalDiv.style.marginTop = '8px';
|
|
|
|
|
+ evalDiv.style.padding = '8px';
|
|
|
|
|
+ evalDiv.style.background = '#fff';
|
|
|
|
|
+ evalDiv.style.borderRadius = '4px';
|
|
|
|
|
+ evalDiv.style.borderLeft = '3px solid #2196F3';
|
|
|
|
|
+ const matchType = evalResult.匹配类型 || "无";
|
|
|
|
|
+ const matchTypeColor = matchType === "完全匹配" ? "#5ba85f" : "inherit";
|
|
|
|
|
+ const matchTypeDiv = document.createElement('div');
|
|
|
|
|
+ matchTypeDiv.style.fontSize = '13px';
|
|
|
|
|
+ matchTypeDiv.style.marginBottom = '4px';
|
|
|
|
|
+ matchTypeDiv.innerHTML = '<strong>匹配类型:</strong> <strong style="color:' + matchTypeColor + '">' + escapeHtml(matchType) + '</strong>';
|
|
|
|
|
+ evalDiv.appendChild(matchTypeDiv);
|
|
|
|
|
+ const reasonDiv = document.createElement('div');
|
|
|
|
|
+ reasonDiv.style.fontSize = '13px';
|
|
|
|
|
+ reasonDiv.style.marginBottom = '4px';
|
|
|
|
|
+ reasonDiv.innerHTML = '<strong>评分说明:</strong> ' + (evalResult.评分说明 || "无");
|
|
|
|
|
+ evalDiv.appendChild(reasonDiv);
|
|
|
|
|
+ const keyPoints = evalResult.关键匹配点;
|
|
|
|
|
+ if (keyPoints && Array.isArray(keyPoints) && keyPoints.length > 0) {{
|
|
|
|
|
+ const keyPointsLabel = document.createElement('div');
|
|
|
|
|
+ keyPointsLabel.style.fontSize = '13px';
|
|
|
|
|
+ keyPointsLabel.style.marginBottom = '4px';
|
|
|
|
|
+ keyPointsLabel.style.fontWeight = 'bold';
|
|
|
|
|
+ keyPointsLabel.textContent = '关键匹配点:';
|
|
|
|
|
+ evalDiv.appendChild(keyPointsLabel);
|
|
|
|
|
+ keyPoints.forEach(kp => {{
|
|
|
|
|
+ const kpDiv = document.createElement('div');
|
|
|
|
|
+ kpDiv.style.fontSize = '14px';
|
|
|
|
|
+ kpDiv.style.fontWeight = 'bold';
|
|
|
|
|
+ kpDiv.style.color = '#555';
|
|
|
|
|
+ kpDiv.style.marginLeft = '12px';
|
|
|
|
|
+ kpDiv.textContent = '• ' + kp;
|
|
|
|
|
+ evalDiv.appendChild(kpDiv);
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ postCard.appendChild(evalDiv);
|
|
|
|
|
+ }}
|
|
|
|
|
+ queryBody.appendChild(postCard);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ matchValue.appendChild(queryBlock);
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function renderToolEdgeDetail(detail, container, targetNodeName) {{
|
|
|
|
|
+ if (!detail) return;
|
|
|
|
|
+
|
|
|
|
|
+ const toolDataList = detail.tool_data_list || [];
|
|
|
|
|
+ if (toolDataList.length === 0) {{
|
|
|
|
|
+ const emptyDiv = document.createElement('div');
|
|
|
|
|
+ emptyDiv.className = 'detail-empty';
|
|
|
|
|
+ emptyDiv.textContent = '暂无工具数据';
|
|
|
|
|
+ container.appendChild(emptyDiv);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 添加列表标题
|
|
|
|
|
+ const listHeader = document.createElement('div');
|
|
|
|
|
+ listHeader.style.fontSize = '18px';
|
|
|
|
|
+ listHeader.style.fontWeight = 'bold';
|
|
|
|
|
+ listHeader.style.color = '#333';
|
|
|
|
|
+ listHeader.style.marginBottom = '15px';
|
|
|
|
|
+ listHeader.style.paddingBottom = '10px';
|
|
|
|
|
+ listHeader.style.borderBottom = '2px solid #2196F3';
|
|
|
|
|
+ listHeader.textContent = '工具数据列表 (共 ' + toolDataList.length + ' 项)';
|
|
|
|
|
+ container.appendChild(listHeader);
|
|
|
|
|
+
|
|
|
|
|
+ // 遍历每个工具数据
|
|
|
|
|
+ toolDataList.forEach((toolData, idx) => {{
|
|
|
|
|
+ const toolBlock = document.createElement('div');
|
|
|
|
|
+ toolBlock.className = 'tool-block';
|
|
|
|
|
+ toolBlock.style.marginBottom = idx < toolDataList.length - 1 ? '12px' : '0';
|
|
|
|
|
+ toolBlock.style.paddingBottom = idx < toolDataList.length - 1 ? '12px' : '0';
|
|
|
|
|
+ toolBlock.style.borderBottom = idx < toolDataList.length - 1 ? '1px solid #eee' : 'none';
|
|
|
|
|
+
|
|
|
|
|
+ const toolBody = document.createElement('div');
|
|
|
|
|
+ toolBody.className = 'tool-block-body';
|
|
|
|
|
+ toolBody.style.display = 'block'; // 默认展开
|
|
|
|
|
+
|
|
|
|
|
+ // 获取关键信息用于标题显示
|
|
|
|
|
+ const toolInfo = toolData.tool_info || {{}};
|
|
|
|
|
+ const toolName = toolInfo.name || "未知工具";
|
|
|
|
|
+ const eval = toolData.evaluation || {{}};
|
|
|
|
|
+ const matchLevel = eval.match_level || "未评估";
|
|
|
|
|
+ const matchLevelColor = matchLevel === "完全匹配" ? "#5ba85f" :
|
|
|
|
|
+ matchLevel === "部分匹配" ? "#ff9800" : "#999";
|
|
|
|
|
+
|
|
|
|
|
+ // 添加可点击的标题栏(显示关键信息)
|
|
|
|
|
+ const headerDiv = document.createElement('div');
|
|
|
|
|
+ headerDiv.className = 'tool-block-header';
|
|
|
|
|
+ headerDiv.style.cursor = 'pointer';
|
|
|
|
|
+ headerDiv.style.userSelect = 'none';
|
|
|
|
|
+ headerDiv.style.fontWeight = 'bold';
|
|
|
|
|
+ headerDiv.style.fontSize = '14px';
|
|
|
|
|
+ headerDiv.style.padding = '10px 12px';
|
|
|
|
|
+ headerDiv.style.background = '#f5f5f5';
|
|
|
|
|
+ headerDiv.style.borderRadius = '6px';
|
|
|
|
|
+ headerDiv.style.borderLeft = '4px solid #2196F3';
|
|
|
|
|
+ headerDiv.style.marginBottom = '8px';
|
|
|
|
|
+ headerDiv.style.transition = 'background 0.2s';
|
|
|
|
|
+ headerDiv.addEventListener('mouseenter', function() {{
|
|
|
|
|
+ this.style.background = '#e8f4f8';
|
|
|
|
|
+ }});
|
|
|
|
|
+ headerDiv.addEventListener('mouseleave', function() {{
|
|
|
|
|
+ this.style.background = '#f5f5f5';
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ const toggleSpan = document.createElement('span');
|
|
|
|
|
+ toggleSpan.className = 'tool-toggle';
|
|
|
|
|
+ toggleSpan.style.marginRight = '8px';
|
|
|
|
|
+ toggleSpan.style.display = 'inline-block';
|
|
|
|
|
+ toggleSpan.style.width = '16px';
|
|
|
|
|
+ toggleSpan.style.color = '#2196F3';
|
|
|
|
|
+ toggleSpan.textContent = '▼';
|
|
|
|
|
+ headerDiv.appendChild(toggleSpan);
|
|
|
|
|
+
|
|
|
|
|
+ const headerContent = document.createElement('span');
|
|
|
|
|
+ const toolNumSpan = document.createElement('span');
|
|
|
|
|
+ toolNumSpan.style.color = '#333';
|
|
|
|
|
+ toolNumSpan.textContent = '工具 ' + (idx + 1) + ' / ' + toolDataList.length + ': ';
|
|
|
|
|
+ headerContent.appendChild(toolNumSpan);
|
|
|
|
|
+ const toolNameSpan = document.createElement('span');
|
|
|
|
|
+ toolNameSpan.style.color = '#2196F3';
|
|
|
|
|
+ toolNameSpan.style.fontWeight = 'bold';
|
|
|
|
|
+ toolNameSpan.textContent = toolName;
|
|
|
|
|
+ headerContent.appendChild(toolNameSpan);
|
|
|
|
|
+ headerDiv.appendChild(headerContent);
|
|
|
|
|
+
|
|
|
|
|
+ // 添加关键信息摘要
|
|
|
|
|
+ const summaryDiv = document.createElement('div');
|
|
|
|
|
+ summaryDiv.style.fontSize = '13px';
|
|
|
|
|
+ summaryDiv.style.color = '#666';
|
|
|
|
|
+ summaryDiv.style.fontWeight = 'normal';
|
|
|
|
|
+ summaryDiv.style.marginTop = '6px';
|
|
|
|
|
+ summaryDiv.style.paddingLeft = '24px';
|
|
|
|
|
+
|
|
|
|
|
+ if (matchLevel && matchLevel !== "未评估") {{
|
|
|
|
|
+ const matchLevelSpan = document.createElement('span');
|
|
|
|
|
+ matchLevelSpan.style.marginRight = '15px';
|
|
|
|
|
+ matchLevelSpan.innerHTML = '匹配级别: <strong style="color:' + matchLevelColor + '">' + escapeHtml(matchLevel) + '</strong>';
|
|
|
|
|
+ summaryDiv.appendChild(matchLevelSpan);
|
|
|
|
|
+ }}
|
|
|
|
|
+ headerDiv.appendChild(summaryDiv);
|
|
|
|
|
+
|
|
|
|
|
+ headerDiv.addEventListener('click', function() {{
|
|
|
|
|
+ const isExpanded = toolBody.style.display !== 'none';
|
|
|
|
|
+ toolBody.style.display = isExpanded ? 'none' : 'block';
|
|
|
|
|
+ toggleSpan.textContent = isExpanded ? '▶' : '▼';
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ toolBlock.insertBefore(headerDiv, toolBlock.firstChild);
|
|
|
|
|
+ toolBlock.appendChild(toolBody);
|
|
|
|
|
+
|
|
|
|
|
+ const toolSection = document.createElement('div');
|
|
|
|
|
+ toolSection.className = 'detail-item';
|
|
|
|
|
+ toolSection.style.paddingTop = '10px';
|
|
|
|
|
+ toolBody.appendChild(toolSection);
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 工具信息
|
|
|
|
|
+ if (Object.keys(toolInfo).length > 0) {{
|
|
|
|
|
+ const toolInfoSection = document.createElement('div');
|
|
|
|
|
+ toolInfoSection.style.marginBottom = '15px';
|
|
|
|
|
+ const toolInfoTitle = document.createElement('div');
|
|
|
|
|
+ toolInfoTitle.style.fontSize = '15px';
|
|
|
|
|
+ toolInfoTitle.style.fontWeight = 'bold';
|
|
|
|
|
+ toolInfoTitle.style.color = '#333';
|
|
|
|
|
+ toolInfoTitle.style.marginBottom = '12px';
|
|
|
|
|
+ toolInfoTitle.textContent = '工具信息';
|
|
|
|
|
+ toolInfoSection.appendChild(toolInfoTitle);
|
|
|
|
|
+
|
|
|
|
|
+ const toolInfoTable = document.createElement('table');
|
|
|
|
|
+ toolInfoTable.style.width = '100%';
|
|
|
|
|
+ toolInfoTable.style.borderCollapse = 'collapse';
|
|
|
|
|
+ toolInfoTable.style.fontSize = '13px';
|
|
|
|
|
+ toolInfoTable.style.background = '#fafafa';
|
|
|
|
|
+ toolInfoTable.style.borderRadius = '6px';
|
|
|
|
|
+ toolInfoTable.style.overflow = 'hidden';
|
|
|
|
|
+
|
|
|
|
|
+ const toolInfoFields = [
|
|
|
|
|
+ {{ key: "name", label: "工具名称" }},
|
|
|
|
|
+ {{ key: "tool_description", label: "工具描述" }}
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ toolInfoFields.forEach(field => {{
|
|
|
|
|
+ const value = toolInfo[field.key];
|
|
|
|
|
+ if (value !== undefined && value !== null && value !== "") {{
|
|
|
|
|
+ const row = document.createElement('tr');
|
|
|
|
|
+ const labelTd = document.createElement('td');
|
|
|
|
|
+ labelTd.textContent = field.label + ':';
|
|
|
|
|
+ labelTd.style.padding = '8px 12px';
|
|
|
|
|
+ labelTd.style.fontWeight = 'bold';
|
|
|
|
|
+ labelTd.style.color = '#555';
|
|
|
|
|
+ labelTd.style.width = '120px';
|
|
|
|
|
+ labelTd.style.verticalAlign = 'top';
|
|
|
|
|
+ labelTd.style.background = '#f0f0f0';
|
|
|
|
|
+ row.appendChild(labelTd);
|
|
|
|
|
+ const valueTd = document.createElement('td');
|
|
|
|
|
+ valueTd.textContent = String(value);
|
|
|
|
|
+ valueTd.style.padding = '8px 12px';
|
|
|
|
|
+ valueTd.style.color = '#666';
|
|
|
|
|
+ valueTd.style.whiteSpace = 'pre-wrap';
|
|
|
|
|
+ valueTd.style.wordBreak = 'break-word';
|
|
|
|
|
+ row.appendChild(valueTd);
|
|
|
|
|
+ toolInfoTable.appendChild(row);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ toolInfoSection.appendChild(toolInfoTable);
|
|
|
|
|
+ toolSection.appendChild(toolInfoSection);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 工具参数
|
|
|
|
|
+ if (toolData.params && toolData.params.prompt) {{
|
|
|
|
|
+ const paramsSection = document.createElement('div');
|
|
|
|
|
+ paramsSection.style.marginBottom = '15px';
|
|
|
|
|
+ const paramsTitle = document.createElement('div');
|
|
|
|
|
+ paramsTitle.style.fontSize = '15px';
|
|
|
|
|
+ paramsTitle.style.fontWeight = 'bold';
|
|
|
|
|
+ paramsTitle.style.color = '#333';
|
|
|
|
|
+ paramsTitle.style.marginBottom = '8px';
|
|
|
|
|
+ paramsTitle.textContent = '工具参数';
|
|
|
|
|
+ paramsSection.appendChild(paramsTitle);
|
|
|
|
|
+
|
|
|
|
|
+ const paramsDiv = document.createElement('div');
|
|
|
|
|
+ paramsDiv.className = 'detail-val';
|
|
|
|
|
+ paramsDiv.style.background = '#f0f7ff';
|
|
|
|
|
+ paramsDiv.style.padding = '12px';
|
|
|
|
|
+ paramsDiv.style.borderRadius = '6px';
|
|
|
|
|
+ paramsDiv.style.borderLeft = '3px solid #2196F3';
|
|
|
|
|
+ paramsDiv.style.whiteSpace = 'pre-wrap';
|
|
|
|
|
+ paramsDiv.style.wordBreak = 'break-word';
|
|
|
|
|
+ paramsDiv.style.fontSize = '14px';
|
|
|
|
|
+ paramsDiv.style.lineHeight = '1.6';
|
|
|
|
|
+ paramsDiv.style.color = '#333';
|
|
|
|
|
+ paramsDiv.textContent = toolData.params.prompt;
|
|
|
|
|
+ paramsSection.appendChild(paramsDiv);
|
|
|
|
|
+ toolSection.appendChild(paramsSection);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 工具内容
|
|
|
|
|
+ if (toolData.content) {{
|
|
|
|
|
+ const contentSection = document.createElement('div');
|
|
|
|
|
+ contentSection.style.marginBottom = '15px';
|
|
|
|
|
+ const contentTitle = document.createElement('div');
|
|
|
|
|
+ contentTitle.style.fontSize = '16px';
|
|
|
|
|
+ contentTitle.style.fontWeight = 'bold';
|
|
|
|
|
+ contentTitle.style.color = '#333';
|
|
|
|
|
+ contentTitle.style.marginBottom = '8px';
|
|
|
|
|
+ contentTitle.textContent = '工具返回内容';
|
|
|
|
|
+ contentSection.appendChild(contentTitle);
|
|
|
|
|
+
|
|
|
|
|
+ const contentDiv = document.createElement('div');
|
|
|
|
|
+ contentDiv.className = 'detail-val';
|
|
|
|
|
+ contentDiv.style.background = '#f9f9f9';
|
|
|
|
|
+ contentDiv.style.padding = '12px';
|
|
|
|
|
+ contentDiv.style.borderRadius = '6px';
|
|
|
|
|
+ contentDiv.style.borderLeft = '3px solid #2196F3';
|
|
|
|
|
+ contentDiv.style.whiteSpace = 'pre-wrap';
|
|
|
|
|
+ contentDiv.style.wordBreak = 'break-word';
|
|
|
|
|
+ contentDiv.style.fontSize = '14px';
|
|
|
|
|
+ contentDiv.style.lineHeight = '1.6';
|
|
|
|
|
+ contentDiv.style.color = '#333';
|
|
|
|
|
+ contentDiv.style.maxHeight = '400px';
|
|
|
|
|
+ contentDiv.style.overflowY = 'auto';
|
|
|
|
|
+ contentDiv.textContent = toolData.content;
|
|
|
|
|
+ contentSection.appendChild(contentDiv);
|
|
|
|
|
+ toolSection.appendChild(contentSection);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 评估结果
|
|
|
|
|
+ if (toolData.evaluation) {{
|
|
|
|
|
+ const evalSection = document.createElement('div');
|
|
|
|
|
+ const evalTitle = document.createElement('div');
|
|
|
|
|
+ evalTitle.style.fontSize = '16px';
|
|
|
|
|
+ evalTitle.style.fontWeight = 'bold';
|
|
|
|
|
+ evalTitle.style.color = '#333';
|
|
|
|
|
+ evalTitle.style.marginBottom = '8px';
|
|
|
|
|
+ evalTitle.textContent = '评估结果';
|
|
|
|
|
+ evalSection.appendChild(evalTitle);
|
|
|
|
|
+
|
|
|
|
|
+ const evalDiv = document.createElement('div');
|
|
|
|
|
+ evalDiv.style.background = '#fff';
|
|
|
|
|
+ evalDiv.style.padding = '12px';
|
|
|
|
|
+ evalDiv.style.borderRadius = '6px';
|
|
|
|
|
+ evalDiv.style.borderLeft = '3px solid #2196F3';
|
|
|
|
|
+ evalDiv.style.marginTop = '8px';
|
|
|
|
|
+
|
|
|
|
|
+ const eval = toolData.evaluation;
|
|
|
|
|
+
|
|
|
|
|
+ // 匹配级别
|
|
|
|
|
+ if (eval.match_level) {{
|
|
|
|
|
+ const matchLevelColor = eval.match_level === "完全匹配" ? "#5ba85f" :
|
|
|
|
|
+ eval.match_level === "部分匹配" ? "#ff9800" : "#999";
|
|
|
|
|
+ const matchLevelDiv = document.createElement('div');
|
|
|
|
|
+ matchLevelDiv.style.fontSize = '14px';
|
|
|
|
|
+ matchLevelDiv.style.marginBottom = '8px';
|
|
|
|
|
+ matchLevelDiv.innerHTML = '<strong>匹配级别:</strong> <strong style="color:' + matchLevelColor + '">' + escapeHtml(eval.match_level) + '</strong>';
|
|
|
|
|
+ evalDiv.appendChild(matchLevelDiv);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 核心主体
|
|
|
|
|
+ if (eval.core_subject) {{
|
|
|
|
|
+ const coreSubjectDiv = document.createElement('div');
|
|
|
|
|
+ coreSubjectDiv.style.fontSize = '14px';
|
|
|
|
|
+ coreSubjectDiv.style.marginBottom = '8px';
|
|
|
|
|
+ coreSubjectDiv.innerHTML = '<strong>核心主体:</strong> ' + escapeHtml(eval.core_subject);
|
|
|
|
|
+ evalDiv.appendChild(coreSubjectDiv);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 核心事件
|
|
|
|
|
+ if (eval.core_event) {{
|
|
|
|
|
+ const coreEventDiv = document.createElement('div');
|
|
|
|
|
+ coreEventDiv.style.fontSize = '14px';
|
|
|
|
|
+ coreEventDiv.style.marginBottom = '8px';
|
|
|
|
|
+ coreEventDiv.innerHTML = '<strong>核心事件:</strong> ' + escapeHtml(eval.core_event);
|
|
|
|
|
+ evalDiv.appendChild(coreEventDiv);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 原因说明
|
|
|
|
|
+ if (eval.reason) {{
|
|
|
|
|
+ const reasonDiv = document.createElement('div');
|
|
|
|
|
+ reasonDiv.style.fontSize = '14px';
|
|
|
|
|
+ reasonDiv.style.marginTop = '8px';
|
|
|
|
|
+ reasonDiv.style.paddingTop = '8px';
|
|
|
|
|
+ reasonDiv.style.borderTop = '1px solid #eee';
|
|
|
|
|
+ reasonDiv.innerHTML = '<strong>原因说明:</strong> ' + escapeHtml(eval.reason);
|
|
|
|
|
+ evalDiv.appendChild(reasonDiv);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ evalSection.appendChild(evalDiv);
|
|
|
|
|
+ toolSection.appendChild(evalSection);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 工具统计信息
|
|
|
|
|
+ if (detail.tools_count !== undefined || detail.successful_tools_count !== undefined) {{
|
|
|
|
|
+ const statsSection = document.createElement('div');
|
|
|
|
|
+ statsSection.style.marginTop = '12px';
|
|
|
|
|
+ statsSection.style.paddingTop = '12px';
|
|
|
|
|
+ statsSection.style.borderTop = '1px solid #eee';
|
|
|
|
|
+ statsSection.style.fontSize = '13px';
|
|
|
|
|
+ statsSection.style.color = '#666';
|
|
|
|
|
+
|
|
|
|
|
+ if (detail.tools_count !== undefined) {{
|
|
|
|
|
+ const toolsCountSpan = document.createElement('span');
|
|
|
|
|
+ toolsCountSpan.textContent = '工具总数: ' + detail.tools_count;
|
|
|
|
|
+ toolsCountSpan.style.marginRight = '15px';
|
|
|
|
|
+ statsSection.appendChild(toolsCountSpan);
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (detail.successful_tools_count !== undefined) {{
|
|
|
|
|
+ const successfulToolsCountSpan = document.createElement('span');
|
|
|
|
|
+ successfulToolsCountSpan.textContent = '成功工具数: ' + detail.successful_tools_count;
|
|
|
|
|
+ statsSection.appendChild(successfulToolsCountSpan);
|
|
|
|
|
+ }}
|
|
|
|
|
+ toolSection.appendChild(statsSection);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ container.appendChild(toolBlock);
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function renderRootDetail(detail, container) {{
|
|
|
|
|
+ if (!detail) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 帖子详情
|
|
|
|
|
+ const postSection = document.createElement('div');
|
|
|
|
|
+ postSection.className = 'root-detail-section';
|
|
|
|
|
+ const postTitle = document.createElement('div');
|
|
|
|
|
+ postTitle.className = 'root-detail-title';
|
|
|
|
|
+ postTitle.textContent = '1. 帖子详情';
|
|
|
|
|
+ postSection.appendChild(postTitle);
|
|
|
|
|
+
|
|
|
|
|
+ // 显示帖子 ID(尝试多个可能的字段名)
|
|
|
|
|
+ const postId = detail.id || detail.post_id || detail.帖子id || detail.channel_content_id || "";
|
|
|
|
|
+ if (postId) {{
|
|
|
|
|
+ const postIdDiv = document.createElement('div');
|
|
|
|
|
+ postIdDiv.style.marginBottom = '10px';
|
|
|
|
|
+ postIdDiv.style.fontSize = '14px';
|
|
|
|
|
+ postIdDiv.style.color = '#666';
|
|
|
|
|
+ const postIdLabel = document.createElement('span');
|
|
|
|
|
+ postIdLabel.style.fontWeight = 'bold';
|
|
|
|
|
+ postIdLabel.textContent = '帖子 ID: ';
|
|
|
|
|
+ postIdDiv.appendChild(postIdLabel);
|
|
|
|
|
+ const postIdValue = document.createElement('span');
|
|
|
|
|
+ postIdValue.textContent = postId;
|
|
|
|
|
+ postIdDiv.appendChild(postIdValue);
|
|
|
|
|
+ postSection.appendChild(postIdDiv);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ if (detail.title) {{
|
|
|
|
|
+ const titleDiv = document.createElement('div');
|
|
|
|
|
+ titleDiv.className = 'post-title';
|
|
|
|
|
+ titleDiv.textContent = detail.title;
|
|
|
|
|
+ postSection.appendChild(titleDiv);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ if (detail.body_text) {{
|
|
|
|
|
+ const bodyDiv = document.createElement('div');
|
|
|
|
|
+ bodyDiv.className = 'post-body';
|
|
|
|
|
+ bodyDiv.textContent = detail.body_text;
|
|
|
|
|
+ postSection.appendChild(bodyDiv);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ const stats = document.createElement('div');
|
|
|
|
|
+ stats.className = 'post-stats';
|
|
|
|
|
+ if (detail.like_count !== null && detail.like_count !== undefined) {{
|
|
|
|
|
+ const likeSpan = document.createElement('span');
|
|
|
|
|
+ likeSpan.textContent = `❤️ ${{detail.like_count}}`;
|
|
|
|
|
+ stats.appendChild(likeSpan);
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (detail.collect_count !== null && detail.collect_count !== undefined) {{
|
|
|
|
|
+ const collectSpan = document.createElement('span');
|
|
|
|
|
+ collectSpan.textContent = `⭐ ${{detail.collect_count}}`;
|
|
|
|
|
+ stats.appendChild(collectSpan);
|
|
|
|
|
+ }}
|
|
|
|
|
+ postSection.appendChild(stats);
|
|
|
|
|
+
|
|
|
|
|
+ if (detail.images && Array.isArray(detail.images) && detail.images.length > 0) {{
|
|
|
|
|
+ const gallery = document.createElement('div');
|
|
|
|
|
+ gallery.className = 'image-gallery';
|
|
|
|
|
+ detail.images.forEach(imgUrl => {{
|
|
|
|
|
+ const img = document.createElement('img');
|
|
|
|
|
+ img.className = 'image-item';
|
|
|
|
|
+ img.src = imgUrl;
|
|
|
|
|
+ img.addEventListener('click', function() {{
|
|
|
|
|
+ // 可以在这里添加图片查看功能
|
|
|
|
|
+ }});
|
|
|
|
|
+ gallery.appendChild(img);
|
|
|
|
|
+ }});
|
|
|
|
|
+ postSection.appendChild(gallery);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ container.appendChild(postSection);
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 选题结果
|
|
|
|
|
+ const topicSection = document.createElement('div');
|
|
|
|
|
+ topicSection.className = 'root-detail-section';
|
|
|
|
|
+ const topicTitle = document.createElement('div');
|
|
|
|
|
+ topicTitle.className = 'root-detail-title';
|
|
|
|
|
+ topicTitle.textContent = '2. 选题结果';
|
|
|
|
|
+ topicSection.appendChild(topicTitle);
|
|
|
|
|
+ const topicLink = document.createElement('a');
|
|
|
|
|
+ topicLink.className = 'jump-link';
|
|
|
|
|
+ topicLink.href = `${{accountName}}_标签匹配可视化.html`;
|
|
|
|
|
+ topicLink.target = '_blank';
|
|
|
|
|
+ topicLink.textContent = '选题匹配结果';
|
|
|
|
|
+ topicSection.appendChild(topicLink);
|
|
|
|
|
+ container.appendChild(topicSection);
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 选题点拆解(选题点: list[dict])
|
|
|
|
|
+ const selectionPoints = detail["选题点"];
|
|
|
|
|
+ if (Array.isArray(selectionPoints) && selectionPoints.length > 0) {{
|
|
|
|
|
+ const selectionSection = document.createElement('div');
|
|
|
|
|
+ selectionSection.className = 'root-detail-section';
|
|
|
|
|
+ const selectionTitle = document.createElement('div');
|
|
|
|
|
+ selectionTitle.className = 'root-detail-title';
|
|
|
|
|
+ selectionTitle.textContent = '3. 选题点拆解';
|
|
|
|
|
+ selectionSection.appendChild(selectionTitle);
|
|
|
|
|
+
|
|
|
|
|
+ const table = document.createElement('table');
|
|
|
|
|
+ table.style.width = '100%';
|
|
|
|
|
+ table.style.borderCollapse = 'collapse';
|
|
|
|
|
+ table.style.fontSize = '13px';
|
|
|
|
|
+ table.style.marginTop = '8px';
|
|
|
|
|
+
|
|
|
|
|
+ // 表头
|
|
|
|
|
+ const thead = document.createElement('thead');
|
|
|
|
|
+ const headerRow = document.createElement('tr');
|
|
|
|
|
+ const headerStyle = (th) => {{
|
|
|
|
|
+ th.style.borderBottom = '1px solid #eee';
|
|
|
|
|
+ th.style.padding = '6px 8px';
|
|
|
|
|
+ th.style.textAlign = 'left';
|
|
|
|
|
+ th.style.color = '#555';
|
|
|
|
|
+ th.style.background = '#fafafa';
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ const thType = document.createElement('th');
|
|
|
|
|
+ thType.textContent = '类型';
|
|
|
|
|
+ headerStyle(thType);
|
|
|
|
|
+ headerRow.appendChild(thType);
|
|
|
|
|
+ const thTopic = document.createElement('th');
|
|
|
|
|
+ thTopic.textContent = '选题点';
|
|
|
|
|
+ headerStyle(thTopic);
|
|
|
|
|
+ headerRow.appendChild(thTopic);
|
|
|
|
|
+ const thSubstantial = document.createElement('th');
|
|
|
|
|
+ thSubstantial.textContent = '实质';
|
|
|
|
|
+ headerStyle(thSubstantial);
|
|
|
|
|
+ headerRow.appendChild(thSubstantial);
|
|
|
|
|
+ const thForm = document.createElement('th');
|
|
|
|
|
+ thForm.textContent = '形式';
|
|
|
|
|
+ headerStyle(thForm);
|
|
|
|
|
+ headerRow.appendChild(thForm);
|
|
|
|
|
+ const thIntent = document.createElement('th');
|
|
|
|
|
+ thIntent.textContent = '意图';
|
|
|
|
|
+ headerStyle(thIntent);
|
|
|
|
|
+ headerRow.appendChild(thIntent);
|
|
|
|
|
+ thead.appendChild(headerRow);
|
|
|
|
|
+ table.appendChild(thead);
|
|
|
|
|
+
|
|
|
|
|
+ // 表体
|
|
|
|
|
+ const tbody = document.createElement('tbody');
|
|
|
|
|
+ selectionPoints.forEach((item, idx) => {{
|
|
|
|
|
+ if (!item || typeof item !== "object") return;
|
|
|
|
|
+ const row = document.createElement('tr');
|
|
|
|
|
+ row.style.borderBottom = idx === selectionPoints.length - 1 ? 'none' : '1px dashed #f0f0f0';
|
|
|
|
|
+
|
|
|
|
|
+ const rowCellStyle = (td) => {{
|
|
|
|
|
+ td.style.padding = '6px 8px';
|
|
|
|
|
+ td.style.verticalAlign = 'top';
|
|
|
|
|
+ td.style.color = '#666';
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ const toJoined = (v) => {{
|
|
|
|
|
+ if (Array.isArray(v)) return v.join('、');
|
|
|
|
|
+ if (v === null || v === undefined) return "";
|
|
|
|
|
+ return String(v);
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ const tdType = document.createElement('td');
|
|
|
|
|
+ tdType.textContent = item["类型"] || "";
|
|
|
|
|
+ rowCellStyle(tdType);
|
|
|
|
|
+ row.appendChild(tdType);
|
|
|
|
|
+ const tdTopic = document.createElement('td');
|
|
|
|
|
+ tdTopic.textContent = item["选题点"] || "";
|
|
|
|
|
+ rowCellStyle(tdTopic);
|
|
|
|
|
+ row.appendChild(tdTopic);
|
|
|
|
|
+ const tdSubstantial = document.createElement('td');
|
|
|
|
|
+ tdSubstantial.textContent = toJoined(item["实质"]);
|
|
|
|
|
+ rowCellStyle(tdSubstantial);
|
|
|
|
|
+ row.appendChild(tdSubstantial);
|
|
|
|
|
+ const tdForm = document.createElement('td');
|
|
|
|
|
+ tdForm.textContent = toJoined(item["形式"]);
|
|
|
|
|
+ rowCellStyle(tdForm);
|
|
|
|
|
+ row.appendChild(tdForm);
|
|
|
|
|
+ const tdIntent = document.createElement('td');
|
|
|
|
|
+ tdIntent.textContent = toJoined(item["意图"]);
|
|
|
|
|
+ rowCellStyle(tdIntent);
|
|
|
|
|
+ row.appendChild(tdIntent);
|
|
|
|
|
+ tbody.appendChild(row);
|
|
|
|
|
+ }});
|
|
|
|
|
+ table.appendChild(tbody);
|
|
|
|
|
+ selectionSection.appendChild(table);
|
|
|
|
|
+ container.appendChild(selectionSection);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 更新画布宽度以适应侧边栏
|
|
|
|
|
+ function updateCanvasWidth() {{
|
|
|
|
|
+ const sidebar = document.getElementById('sidebar');
|
|
|
|
|
+ const appContainer = document.getElementById('app-container');
|
|
|
|
|
+ const sidebarResizer = document.getElementById('sidebar-resizer');
|
|
|
|
|
+
|
|
|
|
|
+ if (sidebar.classList.contains('active')) {{
|
|
|
|
|
+ const sidebarWidth = sidebar.offsetWidth;
|
|
|
|
|
+ appContainer.style.right = sidebarWidth + 'px';
|
|
|
|
|
+ appContainer.style.width = `calc(100% - ${{sidebarWidth}}px)`;
|
|
|
|
|
+ sidebarResizer.style.right = sidebarWidth + 'px';
|
|
|
|
|
+ sidebarResizer.classList.add('active');
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ appContainer.style.right = '';
|
|
|
|
|
+ appContainer.style.width = '';
|
|
|
|
|
+ sidebarResizer.style.right = '';
|
|
|
|
|
+ sidebarResizer.classList.remove('active');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function closeSidebar() {{
|
|
|
|
|
+ const sidebar = document.getElementById('sidebar');
|
|
|
|
|
+ const appContainer = document.getElementById('app-container');
|
|
|
|
|
+ sidebar.classList.remove('active');
|
|
|
|
|
+ appContainer.classList.remove('sidebar-open');
|
|
|
|
|
+ updateCanvasWidth();
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 侧边栏拖拽调整宽度
|
|
|
|
|
+ const sidebarResizer = document.getElementById('sidebar-resizer');
|
|
|
|
|
+ const sidebar = document.getElementById('sidebar');
|
|
|
|
|
+ let isSidebarResizing = false;
|
|
|
|
|
+ let sidebarStartX = 0;
|
|
|
|
|
+ let sidebarStartWidth = 0;
|
|
|
|
|
+
|
|
|
|
|
+ sidebarResizer.addEventListener('mousedown', function(e) {{
|
|
|
|
|
+ if (!sidebar.classList.contains('active')) return;
|
|
|
|
|
+ isSidebarResizing = true;
|
|
|
|
|
+ sidebarStartX = e.clientX;
|
|
|
|
|
+ sidebarStartWidth = sidebar.offsetWidth;
|
|
|
|
|
+ document.body.classList.add('resizing');
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ document.addEventListener('mousemove', function(e) {{
|
|
|
|
|
+ if (!isSidebarResizing) return;
|
|
|
|
|
+
|
|
|
|
|
+ const deltaX = sidebarStartX - e.clientX; // 向左拖拽增加宽度
|
|
|
|
|
+ const newWidth = sidebarStartWidth + deltaX;
|
|
|
|
|
+ const minWidth = 250;
|
|
|
|
|
+ const maxWidth = window.innerWidth * 0.6;
|
|
|
|
|
+
|
|
|
|
|
+ if (newWidth >= minWidth && newWidth <= maxWidth) {{
|
|
|
|
|
+ sidebar.style.width = newWidth + 'px';
|
|
|
|
|
+ updateCanvasWidth();
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ document.addEventListener('mouseup', function() {{
|
|
|
|
|
+ if (isSidebarResizing) {{
|
|
|
|
|
+ isSidebarResizing = false;
|
|
|
|
|
+ document.body.classList.remove('resizing');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ function openPostDetailModal() {{
|
|
|
|
|
+ const detail = postDetailMap[currentPostKey] || null;
|
|
|
|
|
+ const container = document.getElementById('post-detail-modal-content');
|
|
|
|
|
+ container.innerHTML = '';
|
|
|
|
|
+ if (!detail) {{
|
|
|
|
|
+ const empty = document.createElement('div');
|
|
|
|
|
+ empty.className = 'derivation-empty';
|
|
|
|
|
+ empty.textContent = '暂无当前帖子的详情数据';
|
|
|
|
|
+ container.appendChild(empty);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ renderPostDetailForModal(detail, container);
|
|
|
|
|
+ }}
|
|
|
|
|
+ document.getElementById('post-detail-modal').classList.add('active');
|
|
|
|
|
+ }}
|
|
|
|
|
+ function closePostDetailModal() {{
|
|
|
|
|
+ document.getElementById('post-detail-modal').classList.remove('active');
|
|
|
|
|
+ }}
|
|
|
|
|
+ function renderPostDetailForModal(detail, container) {{
|
|
|
|
|
+ if (!detail) return;
|
|
|
|
|
+ const postId = detail.id || detail.post_id || detail.帖子id || detail.channel_content_id || "";
|
|
|
|
|
+ if (postId) {{
|
|
|
|
|
+ const div = document.createElement('div');
|
|
|
|
|
+ div.style.marginBottom = '10px'; div.style.fontSize = '14px'; div.style.color = '#666';
|
|
|
|
|
+ div.innerHTML = '<span style="font-weight:bold;">帖子 ID: </span>' + escapeHtml(postId);
|
|
|
|
|
+ container.appendChild(div);
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (detail.title) {{
|
|
|
|
|
+ const titleDiv = document.createElement('div');
|
|
|
|
|
+ titleDiv.className = 'post-title';
|
|
|
|
|
+ titleDiv.textContent = detail.title;
|
|
|
|
|
+ container.appendChild(titleDiv);
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (detail.body_text) {{
|
|
|
|
|
+ const bodyDiv = document.createElement('div');
|
|
|
|
|
+ bodyDiv.className = 'post-body';
|
|
|
|
|
+ bodyDiv.textContent = detail.body_text;
|
|
|
|
|
+ container.appendChild(bodyDiv);
|
|
|
|
|
+ }}
|
|
|
|
|
+ const stats = document.createElement('div');
|
|
|
|
|
+ stats.className = 'post-stats';
|
|
|
|
|
+ if (detail.like_count != null) {{ const s = document.createElement('span'); s.textContent = '❤️ ' + detail.like_count; stats.appendChild(s); }}
|
|
|
|
|
+ if (detail.collect_count != null) {{ const s = document.createElement('span'); s.textContent = '⭐ ' + detail.collect_count; stats.appendChild(s); }}
|
|
|
|
|
+ if (stats.childNodes.length) container.appendChild(stats);
|
|
|
|
|
+ if (detail.images && Array.isArray(detail.images) && detail.images.length > 0) {{
|
|
|
|
|
+ const gallery = document.createElement('div');
|
|
|
|
|
+ gallery.className = 'image-gallery';
|
|
|
|
|
+ detail.images.forEach((imgUrl, idx) => {{
|
|
|
|
|
+ const img = document.createElement('img');
|
|
|
|
|
+ img.className = 'image-item';
|
|
|
|
|
+ img.src = imgUrl;
|
|
|
|
|
+ img.addEventListener('click', function() {{ openImageLightbox(detail.images, idx); }});
|
|
|
|
|
+ gallery.appendChild(img);
|
|
|
|
|
+ }});
|
|
|
|
|
+ container.appendChild(gallery);
|
|
|
|
|
+ }}
|
|
|
|
|
+ const selectionPoints = detail["选题点"];
|
|
|
|
|
+ if (Array.isArray(selectionPoints) && selectionPoints.length > 0) {{
|
|
|
|
|
+ const sectionTitle = document.createElement('div');
|
|
|
|
|
+ sectionTitle.className = 'root-detail-title';
|
|
|
|
|
+ sectionTitle.textContent = '帖子选题表';
|
|
|
|
|
+ sectionTitle.style.marginTop = '16px';
|
|
|
|
|
+ container.appendChild(sectionTitle);
|
|
|
|
|
+ const table = document.createElement('table');
|
|
|
|
|
+ table.style.width = '100%'; table.style.borderCollapse = 'collapse'; table.style.fontSize = '13px'; table.style.marginTop = '8px';
|
|
|
|
|
+ const thead = document.createElement('thead');
|
|
|
|
|
+ const headerRow = document.createElement('tr');
|
|
|
|
|
+ ['类型','选题点','实质','形式','意图'].forEach(txt => {{
|
|
|
|
|
+ const th = document.createElement('th');
|
|
|
|
|
+ th.textContent = txt;
|
|
|
|
|
+ th.style.borderBottom = '1px solid #eee'; th.style.padding = '6px 8px'; th.style.textAlign = 'left'; th.style.background = '#fafafa';
|
|
|
|
|
+ headerRow.appendChild(th);
|
|
|
|
|
+ }});
|
|
|
|
|
+ thead.appendChild(headerRow); table.appendChild(thead);
|
|
|
|
|
+ const tbody = document.createElement('tbody');
|
|
|
|
|
+ const toJoined = (v) => Array.isArray(v) ? v.join('、') : (v == null || v === undefined ? '' : String(v));
|
|
|
|
|
+ selectionPoints.forEach((item, idx) => {{
|
|
|
|
|
+ if (!item || typeof item !== 'object') return;
|
|
|
|
|
+ const row = document.createElement('tr');
|
|
|
|
|
+ row.style.borderBottom = idx === selectionPoints.length - 1 ? 'none' : '1px dashed #f0f0f0';
|
|
|
|
|
+ const cellStyle = td => {{ td.style.padding = '6px 8px'; td.style.verticalAlign = 'top'; td.style.color = '#666'; }};
|
|
|
|
|
+ [item["类型"]||"", item["选题点"]||"", toJoined(item["实质"]), toJoined(item["形式"]), toJoined(item["意图"])].forEach(text => {{
|
|
|
|
|
+ const td = document.createElement('td');
|
|
|
|
|
+ td.textContent = text;
|
|
|
|
|
+ cellStyle(td);
|
|
|
|
|
+ row.appendChild(td);
|
|
|
|
|
+ }});
|
|
|
|
|
+ tbody.appendChild(row);
|
|
|
|
|
+ }});
|
|
|
|
|
+ table.appendChild(tbody);
|
|
|
|
|
+ container.appendChild(table);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById('btn-pending-decode-post').addEventListener('click', openPostDetailModal);
|
|
|
|
|
+ document.getElementById('post-detail-modal').addEventListener('click', function(e) {{
|
|
|
|
|
+ if (e.target === this) closePostDetailModal();
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // --- 图集大图灯箱(左右切换)---
|
|
|
|
|
+ let currentLightboxImages = [];
|
|
|
|
|
+ let currentLightboxIndex = 0;
|
|
|
|
|
+
|
|
|
|
|
+ function openImageLightbox(images, index) {{
|
|
|
|
|
+ if (!images || !images.length) return;
|
|
|
|
|
+ currentLightboxImages = images;
|
|
|
|
|
+ currentLightboxIndex = (index >= 0 && index < images.length) ? index : 0;
|
|
|
|
|
+ updateLightboxImage();
|
|
|
|
|
+ document.getElementById('image-lightbox').classList.add('active');
|
|
|
|
|
+ document.addEventListener('keydown', lightboxKeydown);
|
|
|
|
|
+ }}
|
|
|
|
|
+ function closeImageLightbox() {{
|
|
|
|
|
+ document.getElementById('image-lightbox').classList.remove('active');
|
|
|
|
|
+ document.removeEventListener('keydown', lightboxKeydown);
|
|
|
|
|
+ }}
|
|
|
|
|
+ function updateLightboxImage() {{
|
|
|
|
|
+ const img = document.getElementById('lightbox-img');
|
|
|
|
|
+ const counter = document.getElementById('lightbox-counter');
|
|
|
|
|
+ if (!currentLightboxImages.length) return;
|
|
|
|
|
+ const idx = ((currentLightboxIndex % currentLightboxImages.length) + currentLightboxImages.length) % currentLightboxImages.length;
|
|
|
|
|
+ currentLightboxIndex = idx;
|
|
|
|
|
+ img.src = currentLightboxImages[idx];
|
|
|
|
|
+ counter.textContent = (idx + 1) + ' / ' + currentLightboxImages.length;
|
|
|
|
|
+ }}
|
|
|
|
|
+ function lightboxPrev() {{
|
|
|
|
|
+ if (!currentLightboxImages.length) return;
|
|
|
|
|
+ currentLightboxIndex = (currentLightboxIndex - 1 + currentLightboxImages.length) % currentLightboxImages.length;
|
|
|
|
|
+ updateLightboxImage();
|
|
|
|
|
+ }}
|
|
|
|
|
+ function lightboxNext() {{
|
|
|
|
|
+ if (!currentLightboxImages.length) return;
|
|
|
|
|
+ currentLightboxIndex = (currentLightboxIndex + 1) % currentLightboxImages.length;
|
|
|
|
|
+ updateLightboxImage();
|
|
|
|
|
+ }}
|
|
|
|
|
+ function lightboxKeydown(e) {{
|
|
|
|
|
+ if (e.key === 'Escape') {{ closeImageLightbox(); return; }}
|
|
|
|
|
+ if (e.key === 'ArrowLeft') {{ lightboxPrev(); e.preventDefault(); return; }}
|
|
|
|
|
+ if (e.key === 'ArrowRight') {{ lightboxNext(); e.preventDefault(); return; }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ document.getElementById('image-lightbox').addEventListener('click', function(e) {{
|
|
|
|
|
+ if (e.target === this) closeImageLightbox();
|
|
|
|
|
+ }});
|
|
|
|
|
+ document.querySelector('#image-lightbox .lightbox-img-wrap').addEventListener('click', function(e) {{ e.stopPropagation(); }});
|
|
|
|
|
+
|
|
|
|
|
+ function switchPost(val) {{
|
|
|
|
|
+ currentPostKey = val;
|
|
|
|
|
+ parseData(val);
|
|
|
|
|
+ calculateLayout();
|
|
|
|
|
+ renderNodes();
|
|
|
|
|
+ renderEdges();
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ resetView();
|
|
|
|
|
+ renderDerivationProgress(val);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ function closeDimensionPatternsModal() {{
|
|
|
|
|
+ const modal = document.getElementById('dimension-patterns-modal');
|
|
|
|
|
+ if (modal) modal.classList.remove('active');
|
|
|
|
|
+ }}
|
|
|
|
|
+ function showDimensionPatternsModal(postId, roundNum) {{
|
|
|
|
|
+ const modal = document.getElementById('dimension-patterns-modal');
|
|
|
|
|
+ const body = document.getElementById('dimension-patterns-modal-body');
|
|
|
|
|
+ const titleEl = document.getElementById('dimension-patterns-modal-title');
|
|
|
|
|
+ if (!modal || !body) return;
|
|
|
|
|
+ const doc = dimensionAnalyzeData[postId];
|
|
|
|
|
+ if (!doc || !doc.rounds) {{
|
|
|
|
|
+ if (titleEl) titleEl.textContent = '维度 patterns';
|
|
|
|
|
+ body.innerHTML = '<p style="color:#64748b;">暂无整体推导维度分析数据</p>';
|
|
|
|
|
+ modal.classList.add('active');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+ const r = doc.rounds.find(function(x) {{ return x.round === roundNum; }});
|
|
|
|
|
+ const label = (roundNum === 0) ? '选起点' : ('第' + roundNum + '轮');
|
|
|
|
|
+ if (titleEl) titleEl.textContent = '维度patterns · ' + label;
|
|
|
|
|
+ if (!r || !r.patterns || !r.patterns.length) {{
|
|
|
|
|
+ body.innerHTML = '<p style="color:#64748b;">该轮暂无 patterns 数据</p>';
|
|
|
|
|
+ modal.classList.add('active');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+ let parts = [];
|
|
|
|
|
+ parts.push('<div class="dimension-patterns-title">共 ' + r.patterns.length + ' 条 pattern(is_derived 已高亮)</div>');
|
|
|
|
|
+ r.patterns.forEach(function(pat) {{
|
|
|
|
|
+ const items = pat.items || [];
|
|
|
|
|
+ const segs = items.map(function(it) {{
|
|
|
|
|
+ const nm = escapeHtml(it.name || '');
|
|
|
|
|
+ return it.is_derived ? '<span class="pattern-item-derived">' + nm + '</span>' : nm;
|
|
|
|
|
+ }});
|
|
|
|
|
+ parts.push('<div class="pattern-line">' + segs.join('<span class="pattern-plus"> + </span>') + '</div>');
|
|
|
|
|
+ }});
|
|
|
|
|
+ body.innerHTML = parts.join('');
|
|
|
|
|
+ modal.classList.add('active');
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 渲染推导进度
|
|
|
|
|
+ function renderDerivationProgress(fileKey) {{
|
|
|
|
|
+ const container = document.getElementById('derivation-progress-content');
|
|
|
|
|
+ // 从文件名中提取文件ID(去掉.json扩展名)
|
|
|
|
|
+ const fileId = fileKey.replace(/\.json$/, '');
|
|
|
|
|
+ const rounds = derivationData[fileId] || derivationData[fileKey];
|
|
|
|
|
+
|
|
|
|
|
+ if (!rounds || !Array.isArray(rounds) || rounds.length === 0) {{
|
|
|
|
|
+ container.innerHTML = '<div class="derivation-empty">暂无推导进度数据</div>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 收集所有已推导成功的选题点名称(用于判断是否为之前已点亮)
|
|
|
|
|
+ const allDerivedNames = new Set();
|
|
|
|
|
+ rounds.forEach(round => {{
|
|
|
|
|
+ const derived = round.推导成功的选题点 || [];
|
|
|
|
|
+ derived.forEach(p => {{
|
|
|
|
|
+ if (p.name) allDerivedNames.add(p.name);
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ let html = '<div class="derivation-timeline">';
|
|
|
|
|
+
|
|
|
|
|
+ for (let ri = 0; ri < rounds.length; ri++) {{
|
|
|
|
|
+ const round = rounds[ri];
|
|
|
|
|
+ // 推导结果数据中轮次从 1 开始(第一轮=1);轮次 0 表示选起点
|
|
|
|
|
+ const roundLabel = (round.轮次 === 0) ? "选起点" : ("第" + round.轮次 + "轮");
|
|
|
|
|
+ const derived = round.推导成功的选题点 || [];
|
|
|
|
|
+ const underived = round.未推导成功的选题点 || [];
|
|
|
|
|
+
|
|
|
|
|
+ // 获取当前轮次新推导成功的选题点名称
|
|
|
|
|
+ const newInRoundRaw = round.本次新推导成功的选题点 || [];
|
|
|
|
|
+ const newInRoundNames = new Set(newInRoundRaw.map(p => p.name));
|
|
|
|
|
+
|
|
|
|
|
+ // 如果是第一轮(轮次0),所有推导成功的都是新点亮的
|
|
|
|
|
+ if (ri === 0) {{
|
|
|
|
|
+ derived.forEach(p => {{ if (p.name) newInRoundNames.add(p.name); }});
|
|
|
|
|
+ }} else if (newInRoundNames.size === 0) {{
|
|
|
|
|
+ // 如果没有本次新推导成功的,则找出在当前轮次首次出现的
|
|
|
|
|
+ const prevDerivedNames = new Set();
|
|
|
|
|
+ for (let i = 0; i < ri; i++) {{
|
|
|
|
|
+ const prevDerived = rounds[i].推导成功的选题点 || [];
|
|
|
|
|
+ prevDerived.forEach(p => {{ if (p.name) prevDerivedNames.add(p.name); }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ derived.forEach(p => {{
|
|
|
|
|
+ if (p.name && !prevDerivedNames.has(p.name)) {{
|
|
|
|
|
+ newInRoundNames.add(p.name);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 收集所有root_source
|
|
|
|
|
+ const allRootSources = new Set();
|
|
|
|
|
+ derived.forEach(p => {{ if (p.root_source) allRootSources.add(p.root_source); }});
|
|
|
|
|
+ underived.forEach(p => {{ if (p.root_source) allRootSources.add(p.root_source); }});
|
|
|
|
|
+
|
|
|
|
|
+ const pointsByRoot = {{}};
|
|
|
|
|
+ const dimDataByRoot = {{}};
|
|
|
|
|
+
|
|
|
|
|
+ // 处理推导成功的选题点
|
|
|
|
|
+ derived.forEach(p => {{
|
|
|
|
|
+ if (!p.root_source) return;
|
|
|
|
|
+ if (!pointsByRoot[p.root_source]) pointsByRoot[p.root_source] = p.point || "";
|
|
|
|
|
+ if (!dimDataByRoot[p.root_source]) dimDataByRoot[p.root_source] = {{ 实质: [], 形式: [], 意图: [] }};
|
|
|
|
|
+ const dim = p.dimension || "实质";
|
|
|
|
|
+ // 判断颜色:当前轮次新点亮的为黄色,之前已点亮的为绿色
|
|
|
|
|
+ const cls = newInRoundNames.has(p.name) ? "derivation-topic-new" : "derivation-topic-derived";
|
|
|
|
|
+ dimDataByRoot[p.root_source][dim].push({{ name: p.name, cls: cls, derivation_type: p.derivation_type || "", is_fully_derived: p.is_fully_derived }});
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 处理未推导成功的选题点(黑色)
|
|
|
|
|
+ underived.forEach(p => {{
|
|
|
|
|
+ if (!p.root_source) return;
|
|
|
|
|
+ if (!pointsByRoot[p.root_source]) pointsByRoot[p.root_source] = p.point || "";
|
|
|
|
|
+ if (!dimDataByRoot[p.root_source]) dimDataByRoot[p.root_source] = {{ 实质: [], 形式: [], 意图: [] }};
|
|
|
|
|
+ const dim = p.dimension || "实质";
|
|
|
|
|
+ dimDataByRoot[p.root_source][dim].push({{ name: p.name, cls: "derivation-topic-underedived", derivation_type: p.derivation_type || "" }});
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 按point类型排序
|
|
|
|
|
+ const pointOrder = {{ "灵感点": 0, "目的点": 1, "关键点": 2 }};
|
|
|
|
|
+ let rootSourceList = Array.from(allRootSources).sort((a, b) => {{
|
|
|
|
|
+ const pa = pointOrder[pointsByRoot[a] || ""] ?? 99;
|
|
|
|
|
+ const pb = pointOrder[pointsByRoot[b] || ""] ?? 99;
|
|
|
|
|
+ if (pa !== pb) return pa - pb;
|
|
|
|
|
+ return (a || "").localeCompare(b || "");
|
|
|
|
|
+ }});
|
|
|
|
|
+ // 整体推导结果里若未写入选题点(或解析不到分词),用待解构帖子详情中的选题表回填,避免表格无行
|
|
|
|
|
+ if (rootSourceList.length === 0) {{
|
|
|
|
|
+ const pd = postDetailMap[fileKey];
|
|
|
|
|
+ const rows = (pd && Array.isArray(pd["选题点"])) ? pd["选题点"] : [];
|
|
|
|
|
+ for (let ri = 0; ri < rows.length; ri++) {{
|
|
|
|
|
+ const row = rows[ri];
|
|
|
|
|
+ if (!row || typeof row !== "object") continue;
|
|
|
|
|
+ const rs = String(row["选题点"] || "").trim();
|
|
|
|
|
+ if (!rs) continue;
|
|
|
|
|
+ const pt = row["类型"] || "";
|
|
|
|
|
+ pointsByRoot[rs] = pt;
|
|
|
|
|
+ if (!dimDataByRoot[rs]) dimDataByRoot[rs] = {{ 实质: [], 形式: [], 意图: [] }};
|
|
|
|
|
+ ["实质", "形式", "意图"].forEach(function(dim) {{
|
|
|
|
|
+ const arr = row[dim];
|
|
|
|
|
+ const list = Array.isArray(arr) ? arr : [];
|
|
|
|
|
+ list.forEach(function(nm) {{
|
|
|
|
|
+ const s = (typeof nm === "string") ? nm.trim() : String(nm || "").trim();
|
|
|
|
|
+ if (!s) return;
|
|
|
|
|
+ dimDataByRoot[rs][dim].push({{
|
|
|
|
|
+ name: s,
|
|
|
|
|
+ cls: "derivation-topic-baseline",
|
|
|
|
|
+ derivation_type: "",
|
|
|
|
|
+ is_fully_derived: undefined
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+ rootSourceList = Object.keys(pointsByRoot).sort((a, b) => {{
|
|
|
|
|
+ const pa = pointOrder[pointsByRoot[a] || ""] ?? 99;
|
|
|
|
|
+ const pb = pointOrder[pointsByRoot[b] || ""] ?? 99;
|
|
|
|
|
+ if (pa !== pb) return pa - pb;
|
|
|
|
|
+ return (a || "").localeCompare(b || "");
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ html += '<div class="derivation-round-block">';
|
|
|
|
|
+ html += '<div class="derivation-round-title">' + escapeHtml(roundLabel) + '</div>';
|
|
|
|
|
+ html += '<table class="derivation-table"><thead><tr>';
|
|
|
|
|
+ html += '<th class="col-type">类型</th><th class="col-source">选题点</th>';
|
|
|
|
|
+ html += '<th class="col-dim">实质</th><th class="col-dim">形式</th><th class="col-dim">意图</th>';
|
|
|
|
|
+ html += '</tr></thead><tbody>';
|
|
|
|
|
+
|
|
|
|
|
+ rootSourceList.forEach(rs => {{
|
|
|
|
|
+ const point = pointsByRoot[rs] || "";
|
|
|
|
|
+ const dimData = dimDataByRoot[rs] || {{ 实质: [], 形式: [], 意图: [] }};
|
|
|
|
|
+ html += '<tr>';
|
|
|
|
|
+ html += '<td class="col-type">' + escapeHtml(point) + '</td>';
|
|
|
|
|
+ html += '<td class="col-source">' + escapeHtml(rs) + '</td>';
|
|
|
|
|
+ for (const dim of ["实质", "形式", "意图"]) {{
|
|
|
|
|
+ const items = (dimData[dim] || []).sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
|
|
|
|
+ html += '<td class="col-dim">';
|
|
|
|
|
+ items.forEach(it => {{
|
|
|
|
|
+ const searchIcon = (it.derivation_type === "search") ? ' <span class="derivation-topic-search-icon" title="外部寻找">🔍</span>' : '';
|
|
|
|
|
+ const toolIcon = (it.derivation_type === "tool") ? ' <span class="derivation-topic-tool-icon" title="工具调用">🔧</span>' : '';
|
|
|
|
|
+ // 只有推导成功的选题点可以点击(黄色和绿色),未推导成功的(黑色)不可点击
|
|
|
|
|
+ const isClickable = it.cls === "derivation-topic-derived" || it.cls === "derivation-topic-new";
|
|
|
|
|
+ const dataAttr = isClickable ? ' data-topic-name="' + (it.name || "").replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>') + '"' : '';
|
|
|
|
|
+ const notFullyClass = (it.is_fully_derived === false) ? ' derivation-topic-not-fully-derived' : '';
|
|
|
|
|
+ html += '<span class="derivation-topic-item ' + it.cls + notFullyClass + '"' + dataAttr + '>' + escapeHtml(it.name) + searchIcon + toolIcon + '</span>';
|
|
|
|
|
+ }});
|
|
|
|
|
+ if (items.length === 0) html += '<span style="color:#999;">-</span>';
|
|
|
|
|
+ html += '</td>';
|
|
|
|
|
+ }}
|
|
|
|
|
+ html += '</tr>';
|
|
|
|
|
+ }});
|
|
|
|
|
+ if (rootSourceList.length === 0) {{
|
|
|
|
|
+ html += '<tr><td colspan="5" style="color:#94a3b8;text-align:center;padding:12px;">暂无选题表数据(请检查整体推导结果与 input 解构内容)</td></tr>';
|
|
|
|
|
+ }}
|
|
|
|
|
+ html += '</tbody></table>';
|
|
|
|
|
+ const _dimDoc = (dimensionAnalyzeData && dimensionAnalyzeData[fileId]) ? dimensionAnalyzeData[fileId] : null;
|
|
|
|
|
+ const _dimRounds = (_dimDoc && _dimDoc.rounds) ? _dimDoc.rounds : [];
|
|
|
|
|
+ const _dimForRound = _dimRounds.find(function(dr) {{ return dr.round === round.轮次; }}) || null;
|
|
|
|
|
+ html += '<div class="derivation-dimension-extra">';
|
|
|
|
|
+ if (_dimForRound) {{
|
|
|
|
|
+ const _dd = (_dimForRound.derived_dims || []).map(function(d) {{
|
|
|
|
|
+ if (d && typeof d === 'object') {{
|
|
|
|
|
+ const tn = d.tree_node_name || '';
|
|
|
|
|
+ const dim = d.dimension || '';
|
|
|
|
|
+ const mp = d.matched_point || '';
|
|
|
|
|
+ let s = tn;
|
|
|
|
|
+ if (dim) {{
|
|
|
|
|
+ s += '->' + dim;
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (mp) {{
|
|
|
|
|
+ s += '(' + mp + ')';
|
|
|
|
|
+ }}
|
|
|
|
|
+ return escapeHtml(s);
|
|
|
|
|
+ }}
|
|
|
|
|
+ return escapeHtml(String(d));
|
|
|
|
|
+ }}).join('、');
|
|
|
|
|
+ const _ud = (_dimForRound.underived_dims || []).map(function(d) {{
|
|
|
|
|
+ if (d && typeof d === 'object') {{
|
|
|
|
|
+ const tn = d.tree_node_name || '';
|
|
|
|
|
+ const dim = d.dimension || '';
|
|
|
|
|
+ const mp = d.matched_point || '';
|
|
|
|
|
+ let s = tn;
|
|
|
|
|
+ if (dim) {{
|
|
|
|
|
+ s += '->' + dim;
|
|
|
|
|
+ }}
|
|
|
|
|
+ if (mp) {{
|
|
|
|
|
+ s += '(' + mp + ')';
|
|
|
|
|
+ }}
|
|
|
|
|
+ return escapeHtml(s);
|
|
|
|
|
+ }}
|
|
|
|
|
+ return escapeHtml(String(d));
|
|
|
|
|
+ }}).join('、');
|
|
|
|
|
+ html += '<div class="derivation-dim-line"><span class="derivation-dim-label">已推导维度</span> <span class="derivation-dim-val dim-derived">' + (_dd || '—') + '</span></div>';
|
|
|
|
|
+ html += '<div class="derivation-dim-line"><span class="derivation-dim-label">未推导维度</span> <span class="derivation-dim-val dim-underived">' + (_ud || '—') + '</span></div>';
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ html += '<div class="derivation-dim-line dim-muted">暂无与本轮对应的整体推导维度分析</div>';
|
|
|
|
|
+ }}
|
|
|
|
|
+ const _pidAttr = String(fileId).replace(/&/g, '&').replace(/"/g, '"');
|
|
|
|
|
+ html += '<button type="button" class="btn-dimension-patterns" data-post-id="' + _pidAttr + '" data-round="' + String(round.轮次) + '">维度patterns</button>';
|
|
|
|
|
+ html += '</div>';
|
|
|
|
|
+ html += '</div>';
|
|
|
|
|
+ }}
|
|
|
|
|
+ html += '</div>';
|
|
|
|
|
+ container.innerHTML = html;
|
|
|
|
|
+
|
|
|
|
|
+ container.querySelectorAll('.btn-dimension-patterns').forEach(function(el) {{
|
|
|
|
|
+ el.addEventListener('click', function() {{
|
|
|
|
|
+ const pid = this.getAttribute('data-post-id');
|
|
|
|
|
+ const rn = parseInt(this.getAttribute('data-round'), 10);
|
|
|
|
|
+ showDimensionPatternsModal(pid, rn);
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+ // 添加点击事件:点击已推导成功的选题点,在画布中定位(只关联node_list中的节点)
|
|
|
|
|
+ container.querySelectorAll('.derivation-topic-item[data-topic-name]').forEach(el => {{
|
|
|
|
|
+ el.addEventListener('click', function() {{
|
|
|
|
|
+ const topicName = this.getAttribute('data-topic-name');
|
|
|
|
|
+ if (topicName) {{
|
|
|
|
|
+ focusOnNodeByName(topicName);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 根据选题点名称定位节点(只关联node_list中的节点,不关联all_used_tree_nodes)
|
|
|
|
|
+ function focusOnNodeByName(topicName) {{
|
|
|
|
|
+ // 只在node_list中查找,排除level -1的节点(all_used_tree_nodes)
|
|
|
|
|
+ let node = null;
|
|
|
|
|
+ for (let level in flatData.nodesByLevel) {{
|
|
|
|
|
+ const levelNum = parseInt(level);
|
|
|
|
|
+ if (levelNum !== -1) {{ // 排除level -1的节点
|
|
|
|
|
+ const found = flatData.nodesByLevel[levelNum].find(n => n.name === topicName);
|
|
|
|
|
+ if (found) {{
|
|
|
|
|
+ node = found;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ if (node) {{
|
|
|
|
|
+ focusOnNode(node);
|
|
|
|
|
+ highlightDirectSources(node);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // 如果在当前数据中找不到,尝试搜索
|
|
|
|
|
+ const searchInput = document.getElementById('search-input');
|
|
|
|
|
+ if (searchInput) {{
|
|
|
|
|
+ searchInput.value = topicName;
|
|
|
|
|
+ // 再次搜索,排除level -1
|
|
|
|
|
+ for (let level in flatData.nodesByLevel) {{
|
|
|
|
|
+ const levelNum = parseInt(level);
|
|
|
|
|
+ if (levelNum !== -1) {{
|
|
|
|
|
+ const match = flatData.nodesByLevel[levelNum].find(n => n.name.toLowerCase().includes(topicName.toLowerCase()));
|
|
|
|
|
+ if (match) {{
|
|
|
|
|
+ focusOnNode(match);
|
|
|
|
|
+ highlightDirectSources(match);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 切换推导进度显示
|
|
|
|
|
+ function toggleDerivationProgress() {{
|
|
|
|
|
+ const section = document.getElementById('derivation-progress-section');
|
|
|
|
|
+ const appContainer = document.getElementById('app-container');
|
|
|
|
|
+ const btn = document.querySelector('.derivation-progress-toggle');
|
|
|
|
|
+ if (section.classList.contains('active')) {{
|
|
|
|
|
+ section.classList.remove('active');
|
|
|
|
|
+ appContainer.classList.remove('derivation-open');
|
|
|
|
|
+ appContainer.style.bottom = '';
|
|
|
|
|
+ btn.textContent = '展开';
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ section.classList.add('active');
|
|
|
|
|
+ appContainer.classList.add('derivation-open');
|
|
|
|
|
+ // 设置画布底部边距为推导进度面板的高度
|
|
|
|
|
+ const sectionHeight = section.offsetHeight;
|
|
|
|
|
+ appContainer.style.bottom = sectionHeight + 'px';
|
|
|
|
|
+ btn.textContent = '收起';
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 推导进度面板高度拖拽调整
|
|
|
|
|
+ (function() {{
|
|
|
|
|
+ const resizer = document.getElementById('derivation-resizer');
|
|
|
|
|
+ const section = document.getElementById('derivation-progress-section');
|
|
|
|
|
+ const appContainer = document.getElementById('app-container');
|
|
|
|
|
+ let isResizing = false;
|
|
|
|
|
+ let startY = 0;
|
|
|
|
|
+ let startHeight = 0;
|
|
|
|
|
+
|
|
|
|
|
+ resizer.addEventListener('mousedown', function(e) {{
|
|
|
|
|
+ isResizing = true;
|
|
|
|
|
+ startY = e.clientY;
|
|
|
|
|
+ startHeight = section.offsetHeight;
|
|
|
|
|
+ resizer.classList.add('active');
|
|
|
|
|
+ document.body.classList.add('resizing');
|
|
|
|
|
+ section.style.transition = 'none';
|
|
|
|
|
+ appContainer.style.transition = 'none';
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ document.addEventListener('mousemove', function(e) {{
|
|
|
|
|
+ if (!isResizing) return;
|
|
|
|
|
+ const delta = startY - e.clientY;
|
|
|
|
|
+ const minH = 200;
|
|
|
|
|
+ const maxH = Math.floor(window.innerHeight * 0.8);
|
|
|
|
|
+ const newHeight = Math.min(maxH, Math.max(minH, startHeight + delta));
|
|
|
|
|
+ section.style.height = newHeight + 'px';
|
|
|
|
|
+ if (section.classList.contains('active')) {{
|
|
|
|
|
+ appContainer.style.bottom = newHeight + 'px';
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ document.addEventListener('mouseup', function() {{
|
|
|
|
|
+ if (isResizing) {{
|
|
|
|
|
+ isResizing = false;
|
|
|
|
|
+ resizer.classList.remove('active');
|
|
|
|
|
+ document.body.classList.remove('resizing');
|
|
|
|
|
+ section.style.transition = '';
|
|
|
|
|
+ appContainer.style.transition = '';
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }})();
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化
|
|
|
|
|
+ parseData(currentPostKey);
|
|
|
|
|
+ calculateLayout();
|
|
|
|
|
+ renderNodes();
|
|
|
|
|
+ renderEdges();
|
|
|
|
|
+ updateTransform();
|
|
|
|
|
+ renderDerivationProgress(currentPostKey);
|
|
|
|
|
+ </script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|
|
|
|
|
+'''
|
|
|
|
|
+ with open(output_path, 'w', encoding='utf-8') as f:
|
|
|
|
|
+ f.write(html_content)
|
|
|
|
|
+ print(f"最终结果可视化已生成: {output_path}")
|
|
|
|
|
+
|
|
|
|
|
+def main(account_name) -> None:
|
|
|
|
|
+ name = account_name
|
|
|
|
|
+
|
|
|
|
|
+ base = Path(__file__).resolve().parent
|
|
|
|
|
+ output_base = base / "output" / name
|
|
|
|
|
+ data_dir = output_base / "整体推导路径可视化"
|
|
|
|
|
+
|
|
|
|
|
+ if not data_dir.exists():
|
|
|
|
|
+ print(f"错误: 找不到数据目录 {data_dir}")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ json_files = sorted(f for f in os.listdir(data_dir) if f.endswith(".json"))
|
|
|
|
|
+ if not json_files:
|
|
|
|
|
+ print(f"在目录 {data_dir} 中未找到 .json 文件。")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ data_map: Dict[str, dict] = {}
|
|
|
|
|
+ print("\n" + "=" * 50)
|
|
|
|
|
+ print(f"账号: {name}")
|
|
|
|
|
+ print(f"数据目录: {data_dir}")
|
|
|
|
|
+ print(f"正在读取 {len(json_files)} 个帖子数据...")
|
|
|
|
|
+
|
|
|
|
|
+ for filename in json_files:
|
|
|
|
|
+ json_path = data_dir / filename
|
|
|
|
|
+ try:
|
|
|
|
|
+ with open(json_path, "r", encoding="utf-8") as f:
|
|
|
|
|
+ data_map[filename] = json.load(f)
|
|
|
|
|
+ print(f" -> 已读取: {filename}")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ print(f" [错误] 读取 {filename} 时出错: {e}")
|
|
|
|
|
+
|
|
|
|
|
+ if not data_map:
|
|
|
|
|
+ print("没有成功读取到任何数据。")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ post_detail_map: Dict[str, dict] = {}
|
|
|
|
|
+ for filename in data_map.keys():
|
|
|
|
|
+ post_id = Path(filename).stem
|
|
|
|
|
+ try:
|
|
|
|
|
+ detail = load_post_detail_for_visualization(name, post_id)
|
|
|
|
|
+ if detail is not None:
|
|
|
|
|
+ post_detail_map[filename] = detail
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ print(f" [警告] 加载帖子详情 {filename} 时出错: {e}")
|
|
|
|
|
+
|
|
|
|
|
+ derivation_dir = output_base / "整体推导结果"
|
|
|
|
|
+ derivation_data: Dict[str, list] = {}
|
|
|
|
|
+ if derivation_dir.exists():
|
|
|
|
|
+ print("\n正在读取推导进度数据...")
|
|
|
|
|
+ for json_file in derivation_dir.glob("*.json"):
|
|
|
|
|
+ try:
|
|
|
|
|
+ with open(json_file, "r", encoding="utf-8") as f:
|
|
|
|
|
+ derivation_data[json_file.stem] = json.load(f)
|
|
|
|
|
+ print(f" -> 已加载推导进度: {json_file.name}")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ print(f" [警告] 读取推导进度 {json_file.name} 时出错: {e}")
|
|
|
|
|
+ else:
|
|
|
|
|
+ print(f" [提示] 推导结果目录不存在: {derivation_dir}")
|
|
|
|
|
+
|
|
|
|
|
+ dimension_analyze_dir = output_base / "整体推导维度分析"
|
|
|
|
|
+ dimension_analyze_map: Dict[str, dict] = {}
|
|
|
|
|
+ if dimension_analyze_dir.exists():
|
|
|
|
|
+ print("\n正在读取整体推导维度分析...")
|
|
|
|
|
+ suf = "_pattern_dimension_analyze"
|
|
|
|
|
+ for json_file in sorted(dimension_analyze_dir.glob(f"*{suf}.json")):
|
|
|
|
|
+ stem = json_file.stem
|
|
|
|
|
+ if not stem.endswith(suf):
|
|
|
|
|
+ continue
|
|
|
|
|
+ post_id_key = stem[: -len(suf)]
|
|
|
|
|
+ try:
|
|
|
|
|
+ with open(json_file, "r", encoding="utf-8") as f:
|
|
|
|
|
+ dimension_analyze_map[post_id_key] = json.load(f)
|
|
|
|
|
+ print(f" -> 已加载维度分析: {json_file.name}")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ print(f" [警告] 读取维度分析 {json_file.name} 时出错: {e}")
|
|
|
|
|
+ else:
|
|
|
|
|
+ print(f" [提示] 整体推导维度分析目录不存在: {dimension_analyze_dir}")
|
|
|
|
|
+
|
|
|
|
|
+ output_base.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ ts = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
|
|
|
+ output_path = output_base / f"{name}_how推导可视化_{ts}.html"
|
|
|
|
|
+
|
|
|
|
|
+ generate_all_in_one_visualization(
|
|
|
|
|
+ data_map,
|
|
|
|
|
+ str(output_path),
|
|
|
|
|
+ name,
|
|
|
|
|
+ derivation_data=derivation_data,
|
|
|
|
|
+ post_detail_map=post_detail_map,
|
|
|
|
|
+ dimension_analyze_map=dimension_analyze_map,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ print("\n" + "=" * 50)
|
|
|
|
|
+ print("处理完成!")
|
|
|
|
|
+ print(f"输出文件: {output_path}")
|
|
|
|
|
+ print("=" * 50 + "\n")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ main(account_name="空间点阵设计研究室")
|