Panel+pyvisで木構造を可視化する。ほしいのは以下の機能。 - 木構造が表現できること。ノードとエッジが表現でき、矢印で方向を表現できること。 - 分析がしやすいこと。ノードの色や大きさで重要な箇所がわかりやすくできること。あるノードを選択した際に、そこまでのルートが一目でわかること。
起動方法
panel serve dashboard.py --static-dirs assets=./assets
コード
「あるノードを選択した際に、そこまでのルートが一目でわかること」についてはPyvisの元の機能にはなさそうであったため、出力後のHTMLにJSコードを埋め込むことで対応した。
実際にどんなグラフが出てくるかは以下を参照。
import json, re import panel as pn from pyvis.network import Network # from bokeh.models.widgets import DataTable, TableColumn, StringFormatter import pandas as pd import os pn.extension() old_graph_html_path = None html_num = 0 result_hist = [] def build_pyvis_graph(tree_data): net = Network(height="600px", width="100%", directed=True) net.set_options(""" { "nodes": { "shape": "dot", "font": { "size": 14, "face": "Tahoma" } }, "edges": { "arrows": { "to": { "enabled": true } }, "smooth": { "enabled": true, "type": "cubicBezier" } }, "interaction": { "navigationButtons": true, "keyboard": true }, "physics": { "enabled": false } } """) # 値の最大・最小を見つける(正規化のため) all_vals = [] def collect_vals(node_data): if 'val' in node_data: all_vals.append(node_data['val']) for child in node_data.get('child', {}).values(): collect_vals(child) for root_data in tree_data.values(): collect_vals(root_data) min_val = min(all_vals) if all_vals else 0 max_val = max(all_vals) if all_vals else 1 val_range = max_val - min_val if max_val != min_val else 1 # RGBグラデーション def get_color(val): ratio = (val - min_val) / val_range # ratio: 0.0 (薄い) → 1.0 (濃い) # 濃いCyan: rgb(0, 200, 200) # 薄いCyan: rgb(240, 255, 255) r = int(240 * (1 - ratio)) # 減らす → 0 g = int(255 * (1 - 0.2 * ratio)) # 少しだけ暗く → 200 b = int(255 * (1 - 0.2 * ratio)) # 同上 return f"rgb({r},{g},{b})" def add_nodes(node_name, node_data, path=[]): val = node_data.get('val', 0) size = 15 + (val - min_val) / val_range * 20 # サイズ調整 color = get_color(val) net.add_node(node_name, label=f"{node_name}\nval={val}", size=size, color=color) for child_name, child_data in node_data.get("child", {}).items(): # child_val = child_data.get('val', 0) # width = 1 + (child_val - min_val) / val_range * 5 # エッジ太さ # まず子ノードを追加 add_nodes(child_name, child_data, path + [node_name]) # ノード追加後にエッジを追加(← ここが重要) # net.add_edge(node_name, child_name, width=width) net.add_edge(node_name, child_name, width=1) for root_name, root_data in tree_data.items(): add_nodes(root_name, root_data) global html_num tmp_html_path = f"assets/graph{str(html_num)}.html" html_num += 1 net.save_graph(tmp_html_path) # クリック時に親方向のルートを赤くするJS custom_js = """ <script type="text/javascript"> function highlightPath(network) { network.on("click", function (params) { const edges = network.body.data.edges; edges.update(edges.get().map(e => ({ ...e, color: undefined, width: e.originalWidth || e.width }))); if (params.nodes.length === 0) return; let target = params.nodes[0]; while (true) { let incoming = edges.get().filter(e => e.to === target); if (incoming.length === 0) break; let parentEdge = incoming[0]; parentEdge.originalWidth = parentEdge.width; // parentEdge.color = "red"; parentEdge.width = 5; edges.update(parentEdge); target = parentEdge.from; } }); } // ネットワークが準備できたら highlightPath を実行 if (typeof network !== 'undefined') { highlightPath(network); } else { setTimeout(() => { if (typeof network !== 'undefined') highlightPath(network); }, 500); } </script> """ # HTMLファイルにスクリプトを埋め込む with open(tmp_html_path, 'r', encoding='utf-8') as f: html = f.read() html = re.sub(r'</body>', custom_js + '</body>', html, flags=re.IGNORECASE) with open(tmp_html_path, 'w', encoding='utf-8') as f: f.write(html) return tmp_html_path # グラフとテーブルの初期要素 graph_pane = pn.pane.HTML("<b>Submitしてグラフを表示</b>", height=420, sizing_mode="stretch_width") result_table = pn.widgets.DataFrame(pd.DataFrame(columns=["Step", "Node", "Success"]), height=200, disabled=True) # 入力フォーム json_input = pn.widgets.TextAreaInput(placeholder="JSONを入力", height=150, sizing_mode="stretch_width") submit_button = pn.widgets.Button(name="Submit", button_type="primary") sample_submit_button = pn.widgets.Button(name="Sample", button_type="primary") # Submitボタンクリック時の動作 def process_input(event): # try: print("Processing input...") graph_pane.object = "<b>Processing input...</b>" pn.state.cache.clear() os.makedirs("assets", exist_ok=True) global old_graph_html_path if old_graph_html_path and os.path.exists(old_graph_html_path): os.remove(old_graph_html_path) data = json.loads(json_input.value) tree = data["tree"] result = data["result"] # ノードグラフ更新 graph_html_path = build_pyvis_graph(tree) graph_pane.object = f""" <iframe src="/assets/{os.path.basename(graph_html_path)}" width="100%" height="400px" frameborder="0"></iframe> """ # テーブル更新 result_hist.append(result) if len(result_hist) > 10: result_hist.pop(0) result_df = pd.DataFrame(result_hist, columns=["Step", "Node", "Success"]) result_table.value = result_df old_graph_html_path = graph_html_path # except Exception as e: # graph_pane.object = f"<pre>Error: {e}</pre>" def process_sample_input(event): # ランダムなJSONを生成して入力欄にセット sample_data = { "tree": { "root": { "val": 10, "child": { "child1": {"val": 5, "child": { "grandchild1": {"val": 3, "child": { "greatgrandchild1": {"val": 8, "child": { "greatgreatgrandchild2": {"val": 6, "child": {}} }}, "greatgrandchild": {"val": 4, "child": {}} }}, "grandchild2": {"val": 2, "child": { "greatgrandchild2": {"val": 1, "child": {}} }} }}, "child2": {"val": 15, "child": { "grandchild3": {"val": 8, "child": { "greatgrandchild4": {"val": 7, "child": {}}, "greatgrandchild5": {"val": 9, "child": {}} }}, "grandchild4": {"val": 6, "child": {}} }} } } }, "result": [ {"Step": 1, "Node": "root", "Success": True}, {"Step": 2, "Node": "child1", "Success": False}, {"Step": 3, "Node": "child2", "Success": True} ] } json_input.value = json.dumps(sample_data, indent=4) process_input(event) submit_button.on_click(process_input) sample_submit_button.on_click(process_sample_input) # レイアウト layout = pn.Row( pn.Column( pn.pane.Markdown("## ゲーム検証結果可視化"), json_input, pn.Row( submit_button, sample_submit_button, ) ), pn.Column( pn.pane.Markdown("### ノードグラフ"), graph_pane, pn.pane.Markdown("### 結果テーブル"), result_table, sizing_mode="stretch_width" ) ) layout.servable()
入力するデータ
{ "tree": { "Start": { "child": { "AA": { "child": { "AA12": { "child": {}, "val": 13 }, "AA22": { "child": {}, "val": 15 } }, "val": 10 }, "BB": { "child": {}, "val": 20 } }, "val": 11 } }, "result": ["Start", "AA", false] }
