File size: 2,972 Bytes
71d239c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Turn the project graph into a Mermaid diagram — built from real edges.

The architecture map is **not** drawn by the model. It's generated directly from
`ProjectModel` (the resolved import edges, grouped by plain-English role), so it
is always faithful to the actual code. We render it client-side with mermaid.js
inside a Gradio HTML component.
"""
from __future__ import annotations

import html as _html
import posixpath

import config
from schema import ProjectModel

# Order roles top-to-bottom so the map reads like a journey: front door at top.
_ROLE_ORDER = ("entry", "frontend", "backend", "data", "config", "util", "test", "other")


def _node_id(idx: int) -> str:
    return f"n{idx}"


def build_mermaid(model: ProjectModel) -> str:
    """Return Mermaid `flowchart` source for the project."""
    if not model.files:
        return "flowchart TD\n  empty[No code files found]"

    ids = {f.path: _node_id(i) for i, f in enumerate(model.files)}
    lines: list[str] = ["flowchart TD"]

    # Group nodes into subgraphs by role.
    by_role: dict[str, list] = {}
    for f in model.files:
        by_role.setdefault(f.role, []).append(f)

    for role in _ROLE_ORDER:
        group = by_role.get(role)
        if not group:
            continue
        label = config.ROLE_LABEL.get(role, role)
        lines.append(f'  subgraph {role}["{label}"]')
        for f in group:
            base = posixpath.basename(f.path)
            lines.append(f'    {ids[f.path]}["{_esc(base)}"]')
        lines.append("  end")

    # Edges: A --> B means "A uses B".
    for f in model.files:
        for dep in f.depends_on:
            if dep in ids:
                lines.append(f"  {ids[f.path]} --> {ids[dep]}")

    # Colour each node by role (safe-to-edit is its own panel; here we show roles).
    for role in _ROLE_ORDER:
        if role in by_role:
            color = config.ROLE_COLOR.get(role, "#9aa0a6")
            lines.append(f"  classDef {role} fill:{color},stroke:#1d2430,color:#fff,rx:8,ry:8;")
    for f in model.files:
        lines.append(f"  class {ids[f.path]} {f.role};")

    return "\n".join(lines)


def _esc(text: str) -> str:
    # Mermaid node labels: keep it simple, strip quotes/brackets that break parsing.
    return text.replace('"', "").replace("[", "(").replace("]", ")")


# Loaded once in the page <head> (see ui.theme.MERMAID_HEAD). A MutationObserver
# renders any `.mermaid` block Gradio swaps in after an update — the reliable way
# to get diagrams to (re)draw on dynamic content in a Gradio Space.
def render_html(model: ProjectModel) -> str:
    """The mermaid block for a gr.HTML component (loader lives in the page head)."""
    safe = _html.escape(build_mermaid(model))
    # `key` forces a fresh, unprocessed node each render so the observer redraws it.
    import time
    key = int(time.time() * 1000)
    return (f'<div class="sc-diagram">'
            f'<pre class="mermaid" data-key="{key}">{safe}</pre></div>')