File size: 5,139 Bytes
1432cf4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86c3e08
baa4989
1432cf4
 
 
86c3e08
1432cf4
86c3e08
 
1432cf4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86c3e08
1432cf4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
baa4989
 
 
 
 
 
 
 
 
 
 
506681c
 
 
baa4989
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1432cf4
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
from __future__ import annotations

import json
import re
from importlib import import_module
from pathlib import Path
from typing import Any


STATUS_COLORS = {
    "pending": "#9CA3AF",
    "in_progress": "#F59E0B",
    "approved": "#10B981",
    "changes_requested": "#EF4444",
    "reviewed": "#3B82F6",
}

EDGE_COLORS = {
    "explicit_import": "#2563EB",
    "implicit_dependency": "#F59E0B",
    "intra_file": "#14B8A6",
    "circular": "#DC2626",
}


def _build_network(height: str, width: str) -> Any:
    network_cls = import_module("pyvis.network").Network
    try:
        return network_cls(height=height, width=width, directed=True, notebook=False, cdn_resources="in_line")
    except TypeError:
        # Backward compatible constructor path for older pyvis builds.
        return network_cls(height=height, width=width, directed=True, notebook=False)


def render_graph_html(
    *,
    nodes: list[dict[str, object]],
    edges: list[dict[str, object]],
    output_path: str | Path,
    title: str = "GraphReview - Annotated Dependency Graph",
) -> Path:
    net = _build_network(height="900px", width="100%")

    for node in nodes:
        status = str(node.get("status", "pending"))
        net.add_node(
            n_id=str(node["id"]),
            label=str(node.get("label", node["id"])),
            title=str(node.get("title", "")),
            color=STATUS_COLORS.get(status, STATUS_COLORS["pending"]),
            value=float(node.get("size", 1.0)),
            shape="dot",
        )

    for edge in edges:
        edge_type = str(edge.get("edge_type", "explicit_import"))
        edge_title = str(edge.get("title", edge_type))
        formatted_title = f"type: {edge_type}\n{edge_title}"
        net.add_edge(
            source=str(edge["source"]),
            to=str(edge["target"]),
            title=formatted_title,
            color=EDGE_COLORS.get(edge_type, EDGE_COLORS["explicit_import"]),
            value=1.0,
            width=max(1.0, min(float(edge.get("weight", 1.0)) * 1.3, 2.2)),
            arrows="to",
        )

    net.set_options(
        json.dumps(
            {
                "interaction": {
                    "hover": True,
                    "navigationButtons": True,
                    "keyboard": True,
                },
                "physics": {
                    "enabled": True,
                    "stabilization": {
                        "enabled": True,
                        "iterations": 1000,
                        "fit": True,
                    },
                },
                "nodes": {
                    "font": {"size": 14, "face": "monospace"},
                    "borderWidth": 1,
                },
                "edges": {
                    "smooth": {"enabled": False},
                    "arrows": {"to": {"enabled": True, "scaleFactor": 0.35}},
                },
            }
        )
    )

    output = Path(output_path)
    output.parent.mkdir(parents=True, exist_ok=True)
    net.write_html(str(output), open_browser=False, notebook=False)

    html = output.read_text(encoding="utf-8")
    html = re.sub(
        r'<link[^>]*cdn\.jsdelivr\.net[^>]*>\s*',
        "",
        html,
        flags=re.IGNORECASE,
    )
    html = re.sub(
        r'<script[^>]*cdn\.jsdelivr\.net[^>]*>\s*</script>\s*',
        "",
        html,
        flags=re.IGNORECASE,
    )
    if "<title>" in html:
        html = html.replace("<title></title>", f"<title>{title}</title>")
    tooltip_style = (
        "<style>"
        ".vis-tooltip {"
        " background: #fffdf8 !important;"
        " border: 1px solid #d6d3d1 !important;"
        " border-radius: 8px !important;"
        " box-shadow: 0 8px 24px rgba(15,23,42,0.15) !important;"
        " color: #1f2937 !important;"
        " font-family: 'IBM Plex Sans','Segoe UI',sans-serif !important;"
        " font-size: 12px !important;"
        " line-height: 1.35 !important;"
        " white-space: pre-wrap !important;"
        " overflow-wrap: anywhere !important;"
        " word-break: break-word !important;"
        " max-width: 420px !important;"
        " padding: 10px 12px !important;"
        " }"
        "</style>"
    )
    html = html.replace("</head>", f"{tooltip_style}</head>")
    bridge_script = (
        "<script>\n"
        "(function () {\n"
        "  function bindNodeClick() {\n"
        "    if (typeof network === 'undefined') {\n"
        "      setTimeout(bindNodeClick, 250);\n"
        "      return;\n"
        "    }\n"
        "    network.on('click', function (params) {\n"
        "      if (!params.nodes || params.nodes.length === 0) { return; }\n"
        "      var moduleId = params.nodes[0];\n"
        "      if (window.parent && window.parent !== window) {\n"
        "        window.parent.postMessage({ type: 'graphreview-node-select', moduleId: moduleId }, '*');\n"
        "      }\n"
        "    });\n"
        "  }\n"
        "  bindNodeClick();\n"
        "})();\n"
        "</script>"
    )
    html = html.replace("</body>", f"{bridge_script}</body>")
    output.write_text(html, encoding="utf-8")
    return output