| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269 |
- import json
- import sys
- from pathlib import Path
- def generate_html_visualization(result_data: dict, cases_data: dict) -> str:
- merged_demands = result_data.get("merged_demands", [])
- mount_decisions = result_data.get("mount_decisions", [])
- # 构建 decision_map:demand_name → mounted_nodes
- decision_map = {}
- for d in mount_decisions:
- dec = d.get("decision", {})
- if isinstance(dec, dict):
- decision_map[d["demand_name"]] = dec.get("mounted_nodes", [])
- else:
- decision_map[d["demand_name"]] = []
- # 优先从 result_data 读取 case_info,如果没有则从 cases_data 构建
- if "case_info" in result_data:
- case_map = result_data["case_info"]
- else:
- case_map = {}
- for c in cases_data.get("cases", []):
- case_map[c["case_id"]] = {
- "title": c.get("title") or c.get("video_title") or c.get("post_title", ""),
- "images": c.get("images") or c.get("effect_images", []),
- "link": c.get("source_link") or c.get("video_url") or c.get("post_url", "")
- }
- nodes_js = []
- edges_js = []
- node_id = 0
- COLOR_CASE_BG = "#e3f2fd"
- COLOR_CASE_BORDER = "#2196f3"
- COLOR_DEMAND_BG = "#fff3e0"
- COLOR_DEMAND_BORDER = "#ff9800"
- for md in merged_demands:
- demand_id = node_id
- node_id += 1
- dn = md["demand_name"]
-
- # 从挂载决策中获取最终选择的节点
- mounted = decision_map.get(dn, [])
- node_tags = []
- for n in mounted:
- node_tags.append(f"{n['name']}")
- tags_str = " | ".join(node_tags) if node_tags else "无挂载节点"
- demand_label = f"<b>{dn}</b>\n\n[{tags_str}]"
-
- demand_title_html = f"""
- <div class='tooltip-title' style='background:{COLOR_DEMAND_BG}; border-color:{COLOR_DEMAND_BORDER}'>需求详情</div>
- <div class='tooltip-content'>
- <p><b>名称:</b> {dn}</p>
- <p><b>描述:</b> {md["description"]}</p>
- <p><b>挂载节点:</b> {tags_str}</p>
- {"".join(f"<p>· {n['name']}({n.get('source_type','')}) — {n.get('reason','')}</p>" for n in mounted)}
- </div>
- """
- nodes_js.append({
- "id": demand_id,
- "label": demand_label,
- "title": demand_title_html,
- "group": "demand",
- "level": 0 # 核心改动1:Demand 改为 Level 0 (根节点)
- })
- for cid in md.get("source_case_ids", []):
- case_id = node_id
- node_id += 1
- # 类型转换:case_info 的 key 可能是字符串
- cid_str = str(cid)
- case_info = case_map.get(cid_str) or case_map.get(cid, {"title": f"Case {cid}", "images": [], "link": ""})
- title_short = case_info['title'][:20] + "..." if len(case_info['title']) > 20 else case_info['title']
- case_label = f"<b>Case {cid}</b>\n{title_short}"
- img_html = ""
- if case_info['images']:
- img_url = case_info['images'][0]
- img_html = f'<img src="{img_url}" class="tooltip-img"/>'
- case_title_html = f"""
- <div class='tooltip-title' style='background:{COLOR_CASE_BG}; border-color:{COLOR_CASE_BORDER}'>帖子详情 (点击跳转)</div>
- <div class='tooltip-content'>
- <p><b>ID:</b> {cid}</p>
- <p><b>标题:</b> {case_info["title"]}</p>
- {img_html}
- </div>
- """
- nodes_js.append({
- "id": case_id,
- "label": case_label,
- "title": case_title_html,
- "url": case_info['link'],
- "group": "case",
- "level": 1 # 核心改动2:Case 改为 Level 1 (叶子节点)
- })
- # 核心改动3:在数据上把连线方向反转为 Demand -> Case
- edges_js.append({"from": demand_id, "to": case_id})
- html = f"""<!DOCTYPE html>
- <html><head>
- <meta charset="utf-8">
- <meta name="referrer" content="no-referrer">
- <title>Case → Demand 语义映射图</title>
- <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
- <style>
- body {{
- margin: 0;
- font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
- background-color: #f4f7f6;
- overflow: hidden;
- }}
- #graph {{ width: 100vw; height: 100vh; background-color: white; }}
- #header {{
- position: absolute; top: 10px; left: 20px; padding: 10px 20px;
- display: flex; gap: 30px; align-items: center;
- background: rgba(255, 255, 255, 0.9); box-shadow: 0 4px 10px rgba(0,0,0,0.1);
- border-radius: 8px; z-index: 10;
- }}
- .brand {{ font-size: 16px; font-weight: bold; color: #333; }}
- #legend {{ display: flex; gap: 15px; align-items: center; font-size: 13px; }}
- .legend-item {{ display: flex; align-items: center; gap: 6px; }}
- .dot {{ width: 14px; height: 14px; border-radius: 3px; border: 1px solid; }}
- div.vis-network div.vis-tooltip {{
- padding: 0; border: none; border-radius: 8px; background-color: white;
- box-shadow: 0 4px 15px rgba(0,0,0,0.25); font-size: 14px; max-width: 400px; overflow: hidden;
- }}
- .tooltip-title {{ padding: 10px 15px; font-weight: bold; border-bottom: 1px solid; color: #333; }}
- .tooltip-content {{ padding: 15px; color: #555; }}
- .tooltip-content p {{ margin: 0 0 10px 0; line-height: 1.5; }}
- .tooltip-img {{ max-width: 100%; max-height: 250px; display: block; margin-top: 10px; border-radius: 4px; border: 1px solid #eee; }}
- </style>
- </head><body>
- <div id="header">
- <div class="brand">语义映射网络 (鼠标拖拽平移,滚轮缩放)</div>
- <div id="legend">
- <span class="legend-item"><span class="dot" style="background:{COLOR_CASE_BG}; border-color:{COLOR_CASE_BORDER}"></span> 帖子 (Case)</span>
- <span class="legend-item"><span class="dot" style="background:{COLOR_DEMAND_BG}; border-color:{COLOR_DEMAND_BORDER}"></span> 需求 (Demand)</span>
- </div>
- </div>
- <div id="graph"></div>
- <script>
- var nodesRaw = {json.dumps(nodes_js, ensure_ascii=False)};
- var edgesRaw = {json.dumps(edges_js, ensure_ascii=False)};
- nodesRaw.forEach(function(node) {{
- if (node.title && typeof node.title === 'string') {{
- var container = document.createElement('div');
- container.innerHTML = node.title;
- node.title = container;
- }}
- }});
- var nodes = new vis.DataSet(nodesRaw);
- var edges = new vis.DataSet(edgesRaw);
- var container = document.getElementById("graph");
- var options = {{
- layout: {{
- hierarchical: {{
- direction: "DU", /* 核心改动4:改为从下往上 (Down-Up) 生长。Level 0(Demand)在底,Level 1(Case)在顶 */
- sortMethod: "directed",
- levelSeparation: 300,
- nodeSpacing: 180,
- treeSpacing: 350,
- parentCentralization: true /* 由于 Demand 在逻辑上成了父节点,这个属性会完美将它居中 */
- }}
- }},
- groups: {{
- useDefaultGroups: false,
- case: {{
- shape: "box",
- color: {{ background: "{COLOR_CASE_BG}", border: "{COLOR_CASE_BORDER}" }},
- font: {{ size: 14, multi: 'html', color: "#0d47a1" }},
- widthConstraint: {{ minimum: 150, maximum: 220 }},
- shapeProperties: {{ borderDashes: false, borderRadius: 6 }},
- margin: 12
- }},
- demand: {{
- shape: "box",
- color: {{ background: "{COLOR_DEMAND_BG}", border: "{COLOR_DEMAND_BORDER}" }},
- font: {{ size: 15, multi: 'html', color: "#e65100" }},
- widthConstraint: {{ minimum: 200, maximum: 300 }},
- shapeProperties: {{ borderDashes: false, borderRadius: 6 }},
- margin: 15
- }}
- }},
- edges: {{
- arrows: "from", /* 核心改动5:虽然连线是 Demand->Case,但把箭头强制画在 Demand 一侧,看起来就是 Case->Demand */
- color: {{ color: "#b0bec5", highlight: "#78909c" }},
- smooth: {{ type: "cubicBezier", forceDirection: "vertical", roundness: 0.5 }},
- width: 1.5
- }},
- physics: {{ enabled: false }},
- interaction: {{
- hover: true, tooltipDelay: 100, dragView: true, zoomView: true
- }}
- }};
- var network = new vis.Network(container, {{ nodes: nodes, edges: edges }}, options);
- network.once("afterDrawing", function() {{
- if (nodesRaw.length > 0) {{
- network.focus(nodesRaw[0].id, {{
- scale: 1.0,
- animation: false,
- offset: {{x: 0, y: -100}}
- }});
- }}
- }});
- network.on("click", function(params) {{
- if (params.nodes.length > 0) {{
- var nodeData = nodes.get(params.nodes[0]);
- if (nodeData.url && nodeData.url.startsWith('http')) window.open(nodeData.url, '_blank');
- }}
- }});
- network.on("hoverNode", function(params) {{
- if (nodes.get(params.node).url) network.canvas.body.container.style.cursor = 'pointer';
- }});
- network.on("blurNode", function() {{
- network.canvas.body.container.style.cursor = 'default';
- }});
- </script>
- </body></html>"""
- return html
- def main():
- if len(sys.argv) < 2:
- print("用法: python visualize_results.py <match_nodes_result.json路径>")
- sys.exit(1)
- result_path = Path(sys.argv[1])
- if not result_path.exists():
- print(f"错误: 文件不存在: {result_path}")
- sys.exit(1)
- with open(result_path, "r", encoding="utf-8") as f:
- result_data = json.load(f)
- cases_path = result_path.parent / "02_cases.json"
- if not cases_path.exists():
- cases_data = {"cases": []}
- else:
- with open(cases_path, "r", encoding="utf-8") as f:
- cases_data = json.load(f)
- html = generate_html_visualization(result_data, cases_data)
- output_path = result_path.parent / "match_nodes_graph.html"
- with open(output_path, "w", encoding="utf-8") as f:
- f.write(html)
- print(f"[OK] Visualization generated: {output_path}")
- if __name__ == "__main__":
- main()
|