@harmonyos-arkts/d2h 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.claude-plugin/marketplace.json +15 -13
  2. package/.claude-plugin/plugin.json +13 -7
  3. package/AGENTS.md +5 -5
  4. package/README.md +212 -212
  5. package/agents/android-to-hmos-00-orchestrator.md +318 -318
  6. package/agents/d2h.md +157 -157
  7. package/dist/bin/install-opencode.js +30 -30
  8. package/docs/environment-check.md +86 -86
  9. package/install-opencode.sh +160 -160
  10. package/opencode/agents.json +16 -16
  11. package/opencode/vscode-d2h-package.json +7 -7
  12. package/opencode/vscode-d2h-plugin.mjs +29 -29
  13. package/package.json +54 -54
  14. package/schemas/android-source-manifest.schema.json +25 -25
  15. package/schemas/checkpoint-provenance.schema.json +67 -67
  16. package/schemas/final-acceptance.schema.json +29 -29
  17. package/schemas/managed-evidence-index.schema.json +15 -15
  18. package/schemas/managed-evidence.schema.json +114 -114
  19. package/schemas/migration-config.schema.json +51 -51
  20. package/schemas/migration-report-index.schema.json +39 -39
  21. package/schemas/migration-report-item.schema.json +51 -51
  22. package/schemas/migration-report-summary.schema.json +25 -25
  23. package/schemas/migration-status.schema.json +202 -202
  24. package/schemas/preflight.schema.json +60 -60
  25. package/schemas/source-order-audit.schema.json +16 -16
  26. package/schemas/source-provenance-event.schema.json +19 -19
  27. package/schemas/spec-app-shard.schema.json +45 -45
  28. package/schemas/spec-fact-corrections-shard.schema.json +13 -13
  29. package/schemas/spec-features-shard.schema.json +43 -43
  30. package/schemas/spec-index.schema.json +26 -26
  31. package/schemas/spec-interactions-shard.schema.json +40 -40
  32. package/schemas/spec-page.schema.json +98 -98
  33. package/schemas/spec-pages-index.schema.json +1 -1
  34. package/schemas/spec-unresolved-shard.schema.json +1 -1
  35. package/schemas/task-envelope.schema.json +109 -109
  36. package/schemas/task-plan.schema.json +341 -341
  37. package/scripts/check-environment.mjs +320 -320
  38. package/skills/android2hmos_resources_convert/SKILL.md +180 -180
  39. package/skills/android2hmos_resources_convert/references/image-conversion-rules.md +230 -230
  40. package/skills/android2hmos_resources_convert/references/svg-fix-patterns.md +175 -175
  41. package/skills/android2hmos_resources_convert/references/xml-drawable-to-svg-rules.md +513 -513
  42. package/skills/arkts-app-identity/SKILL.md +238 -238
  43. package/skills/arkts-i18n/SKILL.md +496 -496
  44. package/skills/arkts-i18n/evals/evals.json +84 -84
  45. package/skills/arkts-i18n/references/code-examples.md +302 -302
  46. package/skills/arkts-i18n/references/common-pitfalls.md +391 -391
  47. package/skills/arkts-i18n/references/dynamic-language-switch.md +604 -604
  48. package/skills/arkts-i18n/references/hardcoded-string-scanner.md +348 -348
  49. package/skills/arkts-i18n/references/language-codes.md +104 -104
  50. package/skills/arkts-i18n/references/resource-file-structure.md +775 -775
  51. package/skills/arkts-i18n/references/static-vs-dynamic.md +242 -242
  52. package/skills/arkts-i18n/references/v1-compat.md +244 -244
  53. package/skills/arkts-i18n/scripts/audit_i18n_completeness.sh +174 -174
  54. package/skills/arkts-icon-sizing/SKILL.md +211 -211
  55. package/skills/arkts-icon-sizing/scripts/icon_audit.py +131 -131
  56. package/skills/arkts-icon-sizing/scripts/icon_autofix.py +88 -88
  57. package/skills/arkts-icon-sizing/scripts/icon_dims.py +179 -179
  58. package/skills/arkts-icon-sizing/scripts/icon_fix.py +119 -119
  59. package/skills/arkts-mvvm-architecture/SKILL.md +613 -613
  60. package/skills/d2h-hypium-forge/SKILL.md +309 -309
  61. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -140
  62. package/skills/d2h-hypium-forge/references/official-api.md +135 -135
  63. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -98
  64. package/skills/d2h-hypium-forge/references/testability-template.md +71 -71
  65. package/skills/d2h-hypium-forge/scripts/_common_template.py +659 -659
  66. package/skills/d2h-hypium-forge/scripts/analyze_results.py +165 -165
  67. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -107
  68. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -169
  69. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -325
  70. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -114
  71. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -80
  72. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -60
  73. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -249
  74. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -140
  75. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -232
  76. package/skills/d2h-hypium-forge/scripts/probe.py +197 -197
  77. package/skills/d2h-hypium-forge/scripts/run_direct.py +823 -823
  78. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -121
  79. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -255
  80. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -66
  81. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -84
  82. package/skills/d2h-page-develop/SKILL.md +112 -112
  83. package/skills/d2h-page-develop/agents/openai.yaml +7 -7
  84. package/skills/d2h-page-spec-generate/SKILL.md +206 -206
  85. package/skills/d2h-page-spec-generate/scripts/build_batch_report.py +227 -227
  86. package/skills/d2h-utg-ai-generate/SKILL.md +128 -128
  87. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -76
  88. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -34
  89. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -96
  90. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -61
  91. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -89
  92. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -314
  93. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -123
  94. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -280
  95. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -524
  96. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -489
  97. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -69
  98. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -13
  99. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -2
  100. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -369
  101. package/skills/d2h-utg-static-generate/SKILL.md +73 -73
  102. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -38
  103. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -499
  104. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -335
  105. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -60
  106. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -131
  107. package/skills/harmonyos-migration-playbook/SKILL.md +83 -83
  108. package/skills/harmonyos-migration-playbook/agents/openai.yaml +7 -7
  109. package/skills/harmonyos-migration-playbook/references/arkts-compile.md +24 -24
  110. package/skills/harmonyos-migration-playbook/references/harmony-runtime.md +53 -53
  111. package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +45 -45
  112. package/skills/harmonyos-migration-playbook/references/protocol-e2e.md +23 -23
  113. package/skills/harmonyos-migration-playbook/references/ui-automation.md +53 -53
  114. package/skills/harmonyos-migration-playbook/references/windows-environment.md +38 -38
  115. package/skills/maintaining-migration-report/SKILL.md +144 -144
  116. package/skills/maintaining-migration-report/references/final-acceptance.md +48 -48
  117. package/skills/maintaining-migration-report/references/source-provenance.md +66 -66
  118. package/skills/managing-d2h-toolchain/SKILL.md +24 -24
  119. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -16
  120. package/skills/managing-d2h-toolchain/scripts/manage-toolchain.mjs +98 -98
  121. package/skills/managing-d2h-toolchain/toolchain.json +16 -16
  122. package/skills/native-library-substitution/SKILL.md +384 -384
  123. package/skills/native-library-substitution/references/native-library-substitution.json +56905 -56905
  124. package/skills/native-library-substitution/references/native-library-substitution.md +163 -163
  125. package/skills/preparing-migration-workspace/SKILL.md +131 -131
  126. package/skills/reviewing-migration-process/SKILL.md +62 -62
@@ -1,489 +1,489 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """Interactive HTML visualization of a reconciled UTG (guided crawl output).
4
-
5
- python scripts/utg_html.py <workspace>
6
-
7
- Reads <workspace>/utg.json, writes <workspace>/utg.html. Self-contained; D3 v7
8
- embedded from the bundled MIT-licensed distribution. Extends android-utg-static's renderer with the two things the crawler
9
- adds:
10
-
11
- * NODE = a page: the sidebar shows the captured screenshot thumbnail, the
12
- runtime view signature, reached/unreached status, and a link to view.xml.
13
- * EDGE = a navigation with SEMANTICS: precondition (guard), operation
14
- (mechanism + on-screen label), status (confirmed / static-only /
15
- dynamic-only / blocked) and confidence. Edges are colored by STATUS so the
16
- reconciliation is visible at a glance.
17
-
18
- Screenshots are referenced by RELATIVE path, so the HTML must live in the same
19
- workspace directory as the p*/route_*/overlay_* page folders (it does).
20
- """
21
- import json
22
- import os
23
- import sys
24
- from pathlib import Path
25
- from collections import Counter
26
-
27
- # ── Node kind palette ─────────────────────────────────────────────────────────
28
- KIND_META = {
29
- "activity": {"color": "#e05252", "zh": "Activity"},
30
- "compose-route": {"color": "#4a90d9", "zh": "路由页面"},
31
- "compose-route-variant": {"color": "#7db8e8", "zh": "数据状态变体"},
32
- "fragment": {"color": "#e07d52", "zh": "Fragment"},
33
- "voyager-screen": {"color": "#9b59b6", "zh": "Voyager 页面"},
34
- "dialog": {"color": "#f39c12", "zh": "对话框"},
35
- "sheet": {"color": "#f1c40f", "zh": "底部弹窗"},
36
- "menu": {"color": "#2ecc71", "zh": "菜单"},
37
- "mode": {"color": "#8e44ad", "zh": "交互模式"},
38
- "page": {"color": "#4a90d9", "zh": "页面"},
39
- "dynamic": {"color": "#1abc9c", "zh": "动态新增页面"},
40
- "logical-entry": {"color": "#9b59b6", "zh": "逻辑入口"},
41
- "host-activity": {"color": "#95a5a6", "zh": "页面宿主"},
42
- "sentinel": {"color": "#c0392b", "zh": "阻断哨兵"},
43
- "unknown": {"color": "#95a5a6", "zh": "未知"},
44
- }
45
-
46
- # ── Edge STATUS palette (the reconciliation dimension) ──────────────────────────
47
- STATUS_META = {
48
- "confirmed": {"color": "#2ecc71", "dash": "", "zh": "已确认(动态复现)"},
49
- "static-only": {"color": "#8894a8", "dash": "5 4", "zh": "仅静态(未验证)"},
50
- "dynamic-only": {"color": "#4a90d9", "dash": "", "zh": "仅动态(静态漏掉)"},
51
- "blocked": {"color": "#e05252", "dash": "3 3", "zh": "阻断(敏感/外部)"},
52
- }
53
-
54
-
55
- def _guard_text(guard):
56
- if not guard:
57
- return ""
58
- if isinstance(guard, dict):
59
- kind = guard.get("kind", "")
60
- txt = guard.get("text", "")
61
- return f"{txt}({kind})" if kind else txt
62
- return str(guard)
63
-
64
-
65
- def main():
66
- ws = sys.argv[1] if len(sys.argv) > 1 else "."
67
- utg_path = os.path.join(ws, "utg.json")
68
- html_path = os.path.join(ws, "utg.html")
69
- if not os.path.exists(utg_path):
70
- raise SystemExit(f"utg.json not found in {ws}")
71
-
72
- utg = json.load(open(utg_path, encoding="utf-8"))
73
- nodes = utg.get("nodes", [])
74
- links = utg.get("links") or utg.get("edges") or []
75
-
76
- for n in nodes:
77
- n["_display_kind"] = "logical-entry" if (n.get("extra") or {}).get("logical_entry") else n.get("kind", "unknown")
78
- meta = KIND_META.get(n["_display_kind"], KIND_META["unknown"])
79
- n["_color"] = meta["color"]
80
- n["_zh"] = meta["zh"]
81
- # dim nodes that are unreachable OR never reached by the crawl
82
- n["_dim"] = (n.get("status") == "unreachable") or (n.get("reached") is False
83
- and n.get("kind") not in ("sentinel",))
84
- n["_guardtext"] = ""
85
-
86
- for e in links:
87
- st = e.get("status", "static-only")
88
- sm = STATUS_META.get(st, STATUS_META["static-only"])
89
- e["_color"] = sm["color"]
90
- e["_dash"] = sm["dash"]
91
- e["_guardtext"] = _guard_text(e.get("guard"))
92
- e["_label"] = (e.get("args") or {}).get("label", "")
93
-
94
- kind_counts = Counter(n["_display_kind"] for n in nodes)
95
- status_counts = Counter(e.get("status", "static-only") for e in links)
96
- reached = sum(1 for n in nodes if n.get("reached"))
97
- pages = sum(1 for n in nodes if n.get("kind") not in ("sentinel",))
98
-
99
- stats_html = "\n".join(
100
- f'<span class="badge" style="background:{KIND_META.get(k, KIND_META["unknown"])["color"]}">'
101
- f'{KIND_META.get(k, {}).get("zh", k)}: {v}</span>'
102
- for k, v in sorted(kind_counts.items(), key=lambda x: -x[1])
103
- )
104
- status_html = "\n".join(
105
- f'<span class="badge" style="background:{STATUS_META.get(s, {}).get("color", "#888")}">'
106
- f'{STATUS_META.get(s, {}).get("zh", s)}: {v}</span>'
107
- for s, v in sorted(status_counts.items(), key=lambda x: -x[1])
108
- )
109
-
110
- graph_json = json.dumps({"nodes": nodes, "links": links}, ensure_ascii=False).replace("<", "\\u003c")
111
- review_json = json.dumps({"audit": utg.get("graph", {}).get("discovery_audit", []), "unresolved": utg.get("graph", {}).get("unresolved", []), "boundary_transitions": utg.get("graph", {}).get("boundary_transitions", [])}, ensure_ascii=False).replace("<", "\\u003c")
112
- d3_source = (Path(__file__).parent / "vendor" / "d3-7.9.0.min.js").read_text(encoding="utf-8")
113
- kind_meta_json = json.dumps(KIND_META, ensure_ascii=False)
114
- status_meta_json = json.dumps(STATUS_META, ensure_ascii=False)
115
-
116
- html = f"""<!DOCTYPE html>
117
- <html lang="zh">
118
- <head>
119
- <meta charset="UTF-8">
120
- <title>UTG — {os.path.basename(os.path.abspath(ws))}</title>
121
- <script>{d3_source}</script>
122
- <style>
123
- * {{ box-sizing: border-box; margin: 0; padding: 0; }}
124
- body {{ font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; display: flex; height: 100vh; overflow: hidden; }}
125
- #sidebar {{ width: 360px; min-width: 260px; max-width: 520px; background: #16213e; border-right: 1px solid #0f3460; display: flex; flex-direction: column; overflow: hidden; }}
126
- #sidebar-header {{ padding: 14px 16px 10px; background: #0f3460; flex-shrink: 0; }}
127
- #sidebar-header h1 {{ font-size: 15px; font-weight: 700; }}
128
- #sidebar-header .sub {{ font-size: 11px; color: #aaa; margin-top: 3px; }}
129
- .badge {{ display: inline-block; font-size: 10px; padding: 2px 6px; border-radius: 10px; color: #fff; margin: 2px 2px 2px 0; opacity: .9; }}
130
- #stats {{ padding: 10px 16px; border-bottom: 1px solid #0f3460; flex-shrink: 0; }}
131
- #stats .section-label {{ font-size: 10px; color: #888; text-transform: uppercase; letter-spacing: .05em; margin: 6px 0 4px; }}
132
- #filter-box {{ padding: 10px 16px; border-bottom: 1px solid #0f3460; flex-shrink: 0; }}
133
- #filter-box input {{ width: 100%; padding: 6px 10px; border-radius: 6px; background: #0d0d1a; border: 1px solid #0f3460; color: #eee; font-size: 13px; }}
134
- #filter-box input:focus {{ outline: none; border-color: #4a90d9; }}
135
- #kind-filters {{ display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }}
136
- #kind-filters button {{ font-size: 10px; border: none; border-radius: 10px; padding: 2px 8px; cursor: pointer; opacity: .6; color: #fff; transition: opacity .15s; }}
137
- #kind-filters button.active {{ opacity: 1; }}
138
- #detail-panel {{ flex: 1; overflow-y: auto; padding: 12px 16px; }}
139
- #detail-panel h2 {{ font-size: 14px; margin-bottom: 8px; }}
140
- .shot {{ width: 100%; max-height: 340px; object-fit: contain; border: 1px solid #0f3460; border-radius: 6px; background:#000; margin-bottom: 10px; }}
141
- .detail-kv {{ margin-bottom: 5px; font-size: 12px; }}
142
- .detail-kv .k {{ color: #888; width: 78px; display: inline-block; vertical-align: top; }}
143
- .detail-kv .v {{ color: #eee; word-break: break-all; }}
144
- .edge-list {{ margin-top: 4px; }}
145
- .edge-item {{ font-size: 11px; padding: 5px 7px; margin: 3px 0; border-radius: 5px; background: #1a1a2e; cursor: pointer; }}
146
- .edge-item:hover {{ background: #0f3460; }}
147
- .edge-top {{ display: flex; align-items: center; gap: 6px; }}
148
- .edge-dot {{ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }}
149
- .edge-sub {{ color: #9aa4b8; font-size: 10px; margin-top: 2px; padding-left: 14px; }}
150
- .edge-sub .guard {{ color: #f39c12; }}
151
- svg {{ width: 100%; height: 100%; display: block; }}
152
- #canvas-wrap {{ flex: 1; position: relative; overflow: hidden; }}
153
- .node circle {{ stroke-width: 2px; cursor: pointer; transition: r .1s; }}
154
- .node circle.dim {{ stroke-dasharray: 4 3; opacity: .45; }}
155
- .node text {{ pointer-events: auto; cursor: pointer; font-size: 10px; fill: #ddd; text-shadow: 0 0 3px #000, 0 0 3px #000; }}
156
- .link {{ fill: none; stroke-width: 1.6px; opacity: .6; pointer-events: none; }}
157
- .link.highlighted {{ opacity: 1; stroke-width: 2.6px; }}
158
- .link-hit {{ fill: none; stroke: transparent; stroke-width: 14px; pointer-events: stroke; cursor: pointer; }}
159
- .edge-nav {{ cursor: pointer; color: #4a90d9; }}
160
- .edge-nav:hover {{ text-decoration: underline; }}
161
- .node.dimmed circle {{ opacity: .15; }}
162
- .node.dimmed text {{ opacity: .15; }}
163
- .link.dimmed {{ opacity: .06; }}
164
- #tooltip {{ position: absolute; pointer-events: none; background: rgba(15,52,96,.97); border: 1px solid #4a90d9; border-radius: 6px; padding: 6px 10px; font-size: 12px; max-width: 240px; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.5); }}
165
- #tooltip img {{ width: 120px; display:block; margin-top:5px; border-radius:4px; }}
166
- #controls {{ position: absolute; bottom: 14px; right: 14px; display: flex; gap: 8px; }}
167
- #controls button {{ background: #0f3460; border: 1px solid #4a90d9; color: #eee; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; }}
168
- #controls button:hover {{ background: #1a4a7a; }}
169
- #legend {{ position: absolute; top: 10px; right: 10px; background: rgba(15,33,62,.92); border: 1px solid #0f3460; border-radius: 6px; padding: 8px 12px; font-size: 11px; }}
170
- #legend .l-row {{ display: flex; align-items: center; gap: 6px; margin: 3px 0; }}
171
- #legend .l-line {{ width: 18px; height: 0; border-top: 3px solid; flex-shrink: 0; }}
172
- #legend .section-label {{ font-size: 9px; color:#888; text-transform:uppercase; margin: 4px 0 2px; }}
173
- </style>
174
- </head>
175
- <body>
176
- <div id="sidebar">
177
- <div id="sidebar-header">
178
- <h1>UI Transition Graph · 对账结果</h1>
179
- <div class="sub">{pages} 页面 · {reached} 已到达 · {len(links)} 边</div>
180
- </div>
181
- <div id="stats">
182
- <div class="section-label">节点分类</div>
183
- {stats_html}
184
- <div class="section-label">边状态(对账)</div>
185
- {status_html}
186
- </div>
187
- <div id="filter-box">
188
- <input id="search" type="text" placeholder="搜索节点 (名称 / ID)…">
189
- <div id="kind-filters"></div>
190
- <select id="source-filter"><option value="">全部来源</option><option value="appgraph">AppGraph</option><option value="ai-source-review">AI 修补</option><option value="ai-source-analysis">AI 源码分析</option></select>
191
- <details><summary>修补与未解析项</summary><pre id="review-data" style="white-space:pre-wrap"></pre></details>
192
- </div>
193
- <div id="detail-panel">
194
- <div id="detail-empty" style="color:#666;font-size:12px;margin-top:20px">点击图中节点查看截图与边语义</div>
195
- <div id="detail-content" style="display:none"></div>
196
- </div>
197
- </div>
198
- <div id="canvas-wrap">
199
- <svg id="graph"></svg>
200
- <div id="tooltip"></div>
201
- <div id="legend"><div id="legend-items"></div></div>
202
- <div id="controls">
203
- <button id="btn-reset">重置视图</button>
204
- <button id="btn-center">居中</button>
205
- <button id="btn-cluster">聚类模式</button>
206
- </div>
207
- </div>
208
- <script>
209
- const GRAPH = {graph_json};
210
- const REVIEW = {review_json};
211
- document.getElementById("review-data").textContent = JSON.stringify(REVIEW, null, 2);
212
- const KIND_META = {kind_meta_json};
213
- const kindOf = node => node._display_kind || node.kind;
214
- const STATUS_META = {status_meta_json};
215
-
216
- // ── Legend: node kinds + edge statuses ──
217
- const legendEl = document.getElementById('legend-items');
218
- let lg = '<div class="section-label">节点</div>';
219
- Object.entries(KIND_META).forEach(([k, m]) => {{
220
- if (!GRAPH.nodes.some(n => kindOf(n) === k)) return;
221
- lg += `<div class="l-row"><div style="width:10px;height:10px;border-radius:50%;background:${{m.color}}"></div><span>${{m.zh || k}}</span></div>`;
222
- }});
223
- lg += '<div class="section-label">边状态</div>';
224
- Object.entries(STATUS_META).forEach(([s, m]) => {{
225
- if (!GRAPH.links.some(e => (e.status||'static-only') === s)) return;
226
- lg += `<div class="l-row"><div class="l-line" style="border-top-style:${{m.dash?'dashed':'solid'}};border-color:${{m.color}}"></div><span>${{m.zh || s}}</span></div>`;
227
- }});
228
- legendEl.innerHTML = lg;
229
-
230
- // ── Kind filters ──
231
- const kinds = [...new Set(GRAPH.nodes.map(kindOf))];
232
- let activeKinds = new Set(kinds);
233
- const kindFiltersEl = document.getElementById('kind-filters');
234
- kinds.forEach(k => {{
235
- const meta = KIND_META[k] || {{color:'#888'}};
236
- const btn = document.createElement('button');
237
- btn.textContent = (KIND_META[k]?.zh || k);
238
- btn.style.background = meta.color;
239
- btn.classList.add('active');
240
- btn.addEventListener('click', () => {{
241
- if (activeKinds.has(k)) {{ activeKinds.delete(k); btn.classList.remove('active'); }}
242
- else {{ activeKinds.add(k); btn.classList.add('active'); }}
243
- applyFilters();
244
- }});
245
- kindFiltersEl.appendChild(btn);
246
- }});
247
-
248
- const svg = d3.select('#graph');
249
- const width = () => document.getElementById('canvas-wrap').clientWidth;
250
- const height = () => document.getElementById('canvas-wrap').clientHeight;
251
- const defs = svg.append('defs');
252
- // one arrow marker per status color
253
- Object.entries(STATUS_META).forEach(([s, m]) => {{
254
- defs.append('marker').attr('id','arrow-'+s).attr('viewBox','0 -5 10 10')
255
- .attr('refX',22).attr('refY',0).attr('markerWidth',6).attr('markerHeight',6)
256
- .attr('orient','auto').append('path').attr('d','M0,-5L10,0L0,5').attr('fill',m.color);
257
- }});
258
- const zoom = d3.zoom().scaleExtent([.05,4]).on('zoom', e => container.attr('transform', e.transform));
259
- svg.call(zoom);
260
- const container = svg.append('g');
261
- const linkGroup = container.append('g');
262
- const linkHitGroup = container.append('g');
263
- const nodeGroup = container.append('g');
264
- const nodeById = Object.fromEntries(GRAPH.nodes.map(n => [n.id, n]));
265
- const inDeg = {{}}, outDeg = {{}};
266
- GRAPH.nodes.forEach(n => {{ inDeg[n.id]=0; outDeg[n.id]=0; }});
267
- GRAPH.links.forEach(e => {{ outDeg[e.source]=(outDeg[e.source]||0)+1; inDeg[e.target]=(inDeg[e.target]||0)+1; }});
268
-
269
- // Separate parallel and opposing edges; a self-transition needs a visible loop.
270
- const edgeBundles = new Map();
271
- GRAPH.links.forEach(edge => {{
272
- const pair = JSON.stringify([edge.source, edge.target].sort());
273
- if (!edgeBundles.has(pair)) edgeBundles.set(pair, []);
274
- edgeBundles.get(pair).push(edge);
275
- }});
276
- edgeBundles.forEach(bundle => bundle.forEach((edge, index) => {{
277
- edge._bend = (index - (bundle.length - 1)/2) * 36 * (edge.source < edge.target ? 1 : -1);
278
- edge._loop = 32 + index * 18;
279
- }}));
280
- function edgePath(edge) {{
281
- const s=edge.source, t=edge.target;
282
- if (s.id === t.id) {{ const r=edge._loop; return `M${{s.x}},${{s.y}} C${{s.x+r}},${{s.y-r*2}} ${{s.x-r}},${{s.y-r*2}} ${{s.x}},${{s.y}}`; }}
283
- const dx=t.x-s.x, dy=t.y-s.y, length=Math.hypot(dx,dy)||1;
284
- return `M${{s.x}},${{s.y}} Q${{(s.x+t.x)/2-dy/length*edge._bend}},${{(s.y+t.y)/2+dx/length*edge._bend}} ${{t.x}},${{t.y}}`;
285
- }}
286
-
287
- const sim = d3.forceSimulation(GRAPH.nodes)
288
- .force('link', d3.forceLink(GRAPH.links).id(d => d.id).distance(110).strength(0.3))
289
- .force('charge', d3.forceManyBody().strength(-240))
290
- .force('center', d3.forceCenter(width()/2, height()/2))
291
- .force('collision', d3.forceCollide(26))
292
- .alphaDecay(0.02);
293
-
294
- const linkSel = linkGroup.selectAll('path').data(GRAPH.links).join('path').attr('fill','none')
295
- .attr('class','link')
296
- .attr('stroke', d => d._color)
297
- .attr('stroke-dasharray', d => d._dash || null)
298
- .attr('marker-end', d => `url(#arrow-${{d.status||'static-only'}})`);
299
-
300
- // Wide transparent hitzone lines so thin edges are easy to click/hover.
301
- const linkHitSel = linkHitGroup.selectAll('path').data(GRAPH.links).join('path').attr('fill','none')
302
- .attr('class','link-hit');
303
-
304
- const nodeSel = nodeGroup.selectAll('g').data(GRAPH.nodes).join('g').attr('class','node')
305
- .call(d3.drag()
306
- .on('start', (e,d) => {{ if (!e.active) sim.alphaTarget(.3).restart(); d.fx=d.x; d.fy=d.y; }})
307
- .on('drag', (e,d) => {{ d.fx=e.x; d.fy=e.y; }})
308
- .on('end', (e,d) => {{ if (!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }}));
309
-
310
- nodeSel.append('circle').attr('r', d => nodeRadius(d))
311
- .attr('fill', d => d._color).attr('stroke', d => d3.color(d._color).brighter(.8))
312
- .attr('class', d => d._dim ? 'dim' : '');
313
- nodeSel.append('text').attr('dy', d => nodeRadius(d) + 11).attr('text-anchor','middle')
314
- .text(d => truncate(d.label || d.id, 18));
315
-
316
- function nodeRadius(d) {{ const deg=(inDeg[d.id]||0)+(outDeg[d.id]||0); return Math.max(8, Math.min(20, 8+deg*1.2)); }}
317
- function truncate(s,n) {{ s=String(s); return s.length>n ? s.slice(0,n-1)+'…' : s; }}
318
-
319
- sim.on('tick', () => {{
320
- linkSel.attr('d',edgePath);
321
- linkHitSel.attr('d',edgePath);
322
- nodeSel.attr('transform', d => `translate(${{d.x}},${{d.y}})`);
323
- }});
324
-
325
- const tooltip = document.getElementById('tooltip');
326
- nodeSel.on('mouseover', (e,d) => {{
327
- tooltip.style.display='block';
328
- tooltip.innerHTML = `<b>${{d.label||d.id}}</b><br><span style="color:#aaa">${{d.kind}}</span>`
329
- + `<br><span style="color:#4a90d9">入 ${{inDeg[d.id]||0}} · 出 ${{outDeg[d.id]||0}}</span>`
330
- + (d.reached ? '<br><span style="color:#2ecc71">✓ 已到达</span>' : '<br><span style="color:#e05252">✗ 未到达</span>')
331
- + (d.screenshot ? `<img src="${{d.screenshot}}">` : '');
332
- moveTip(e);
333
- }}).on('mousemove', moveTip).on('mouseout', () => tooltip.style.display='none');
334
- function moveTip(e) {{ tooltip.style.left=(e.pageX+12)+'px'; tooltip.style.top=(e.pageY-28)+'px'; }}
335
-
336
- let selectedId = null;
337
- nodeSel.on('click', (e,d) => {{ e.stopPropagation(); selectedId=d.id; showDetail(d); highlight(d.id); }});
338
- svg.on('click', () => {{ selectedId=null; clearHi(); document.getElementById('detail-empty').style.display=''; document.getElementById('detail-content').style.display='none'; }});
339
-
340
- // Edge interaction: the wide transparent hitzone layer catches clicks/hovers so
341
- // thin edges are easy to hit. Click → edge-semantics detail; hover → tooltip.
342
- linkHitSel
343
- .on('click', (e,d) => {{ e.stopPropagation(); selectedId=null; showEdgeDetail(d); }})
344
- .on('mouseover', (e,d) => {{
345
- const s=edgeEndpoint(d,'source'), t=edgeEndpoint(d,'target');
346
- tooltip.style.display='block';
347
- tooltip.innerHTML = `<b style="color:${{d._color}}">${{d.status||'static-only'}}</b>`
348
- + `<br>${{nodeById[s]?.label||s}} → ${{nodeById[t]?.label||t}}`
349
- + `<br><span style="color:#aaa">${{d._label||d.mechanism||''}}</span>`
350
- + (d._guardtext ? `<br><span style="color:#f39c12">前置: ${{d._guardtext}}</span>` : '');
351
- moveTip(e);
352
- }}).on('mousemove', moveTip).on('mouseout', () => tooltip.style.display='none');
353
-
354
- function edgeEndpoint(l, which) {{ const x=l[which]; return typeof x==='object'?x.id:x; }}
355
-
356
- function highlight(id) {{
357
- const nb=new Set([id]);
358
- GRAPH.links.forEach(l => {{ const s=edgeEndpoint(l,'source'),t=edgeEndpoint(l,'target'); if(s===id||t===id){{nb.add(s);nb.add(t);}} }});
359
- nodeSel.classed('dimmed', nd => !nb.has(nd.id));
360
- linkSel.classed('dimmed', l => edgeEndpoint(l,'source')!==id && edgeEndpoint(l,'target')!==id)
361
- .classed('highlighted', l => edgeEndpoint(l,'source')===id || edgeEndpoint(l,'target')===id);
362
- }}
363
- function clearHi() {{ nodeSel.classed('dimmed',false); linkSel.classed('dimmed',false).classed('highlighted',false); }}
364
-
365
- function edgeHtml(edges, dir) {{
366
- if (!edges.length) return '<div style="color:#666;font-size:11px">无</div>';
367
- return edges.map(e => {{
368
- const other = dir==='out' ? edgeEndpoint(e,'target') : edgeEndpoint(e,'source');
369
- const on = nodeById[other];
370
- const col = e._color;
371
- const op = e._label ? `<b>${{e._label}}</b>` : (e.mechanism||'');
372
- const conf = (e.confidence!=null && e.confidence<1) ? ` <span style="color:#f39c12">≈${{(e.confidence*100).toFixed(0)}}%</span>` : '';
373
- const guard = e._guardtext ? `<span class="guard">前置: ${{e._guardtext}}</span> · ` : '';
374
- const trig = e.trigger ? String(e.trigger).replace(/^onClick@/,'').split(/[\\\\/]/).pop() : '';
375
- return `<div class="edge-item" onclick="goEdge('${{other}}')">`
376
- + `<div class="edge-top"><span class="edge-dot" style="background:${{col}}"></span>`
377
- + `<span style="color:${{col}};font-size:10px">${{e.status||'static-only'}}</span>`
378
- + `<span style="flex:1">${{dir==='out'?'→':'←'}} ${{on?.label||other}}</span>${{conf}}</div>`
379
- + `<div class="edge-sub">${{guard}}操作: ${{op}} <span style="color:#667">[${{e.mechanism||'?'}}]</span>${{trig?` · ${{trig}}`:''}}</div>`
380
- + `</div>`;
381
- }}).join('');
382
- }}
383
-
384
- function showDetail(d) {{
385
- document.getElementById('detail-empty').style.display='none';
386
- const panel = document.getElementById('detail-content'); panel.style.display='';
387
- const outE = GRAPH.links.filter(l => edgeEndpoint(l,'source')===d.id);
388
- const inE = GRAPH.links.filter(l => edgeEndpoint(l,'target')===d.id);
389
- const statusColor = d.reached ? '#2ecc71' : (d.status==='unreachable'?'#e05252':'#f39c12');
390
- panel.innerHTML =
391
- (d.screenshot ? `<img class="shot" src="${{d.screenshot}}" onerror="this.style.display='none'">` : '')
392
- + `<h2 style="color:${{d._color}}">${{d.label || d.id}}</h2>`
393
- + `<div class="detail-kv"><span class="k">ID</span><span class="v">${{d.id}}</span></div>`
394
- + `<div class="detail-kv"><span class="k">类型</span><span class="v" style="color:${{d._color}}">${{d._zh||d.kind}}</span></div>`
395
- + `<div class="detail-kv"><span class="k">到达</span><span class="v" style="color:${{statusColor}}">${{d.reached?'✓ 已到达':'✗ 未到达'}} (${{d.status||'static-only'}})</span></div>`
396
- + (d.reached && d.verify ? `<div class="detail-kv"><span class="k">验证</span><span class="v" style="color:${{d.verify==='structural'?'#f39c12':'#2ecc71'}}">${{d.verify==='structural'?'结构(身份待核)':d.verify}}</span></div>` : '')
397
- + (d.phase ? `<div class="detail-kv"><span class="k">相位</span><span class="v">${{d.phase}}</span></div>` : '')
398
- + (d.env_state ? `<div class="detail-kv"><span class="k">旁路原因</span><span class="v" style="color:#c78">${{d.env_state}}</span></div>` : '')
399
- + (d.view_signature ? `<div class="detail-kv"><span class="k">页面签名</span><span class="v" style="font-size:10px">${{d.view_signature}}</span></div>` : '')
400
- + (d.view_tree ? `<div class="detail-kv"><span class="k">视图树</span><span class="v"><a href="${{d.view_tree}}" style="color:#4a90d9" target="_blank">${{d.view_tree}}</a></span></div>` : '')
401
- + (d.declared_at ? `<div class="detail-kv"><span class="k">声明位置</span><span class="v" style="font-size:10px">${{d.declared_at}}</span></div>` : '')
402
- + `<div class="detail-kv" style="margin-top:10px"><span class="k">跳出边</span><span class="v">${{outE.length}} 条</span></div>`
403
- + `<div class="edge-list">${{edgeHtml(outE,'out')}}</div>`
404
- + `<div class="detail-kv" style="margin-top:8px"><span class="k">跳入边</span><span class="v">${{inE.length}} 条</span></div>`
405
- + `<div class="edge-list">${{edgeHtml(inE,'in')}}</div>`;
406
- appendEvidence({{provenance:d.provenance, declared_at:d.declared_at, expected:d.expected, widgets:d.widgets, extra:d.extra}});
407
- }}
408
-
409
- function appendEvidence(value) {{
410
- const pre = document.createElement('pre');
411
- pre.style.cssText = 'white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px';
412
- pre.textContent = JSON.stringify(value, null, 2);
413
- document.getElementById('detail-content').appendChild(pre);
414
- }}
415
-
416
- function showEdgeDetail(e) {{
417
- document.getElementById('detail-empty').style.display='none';
418
- const panel = document.getElementById('detail-content'); panel.style.display='';
419
- const sid = edgeEndpoint(e,'source'), tid = edgeEndpoint(e,'target');
420
- const s = nodeById[sid], t = nodeById[tid];
421
- const st = e.status || 'static-only';
422
- const conf = (e.confidence!=null && e.confidence<1) ? `≈${{(e.confidence*100).toFixed(0)}}%` : '100%';
423
- const trig = e.trigger ? String(e.trigger).replace(/^onClick@/,'') : '';
424
- panel.innerHTML =
425
- `<h2 style="color:${{e._color}}">${{s?.label||sid}} → ${{t?.label||tid}}</h2>`
426
- + `<div class="detail-kv"><span class="k">状态</span><span class="v" style="color:${{e._color}}">${{st}}</span></div>`
427
- + `<div class="detail-kv"><span class="k">机制</span><span class="v">${{e.mechanism||'?'}}</span></div>`
428
- + (e._label ? `<div class="detail-kv"><span class="k">操作</span><span class="v">${{e._label}}</span></div>` : '')
429
- + (e._guardtext ? `<div class="detail-kv"><span class="k">前置</span><span class="v" style="color:#f39c12">${{e._guardtext}}</span></div>` : '')
430
- + `<div class="detail-kv"><span class="k">置信度</span><span class="v">${{conf}}</span></div>`
431
- + (e.provenance ? `<div class="detail-kv"><span class="k">来源</span><span class="v">${{e.provenance}}</span></div>` : '')
432
- + (trig ? `<div class="detail-kv"><span class="k">触发</span><span class="v" style="font-size:10px">${{trig}}</span></div>` : '')
433
- + `<div class="detail-kv" style="margin-top:10px"><span class="k">起点</span><span class="v edge-nav" onclick="goEdge('${{sid}}')">${{s?.label||sid}}</span></div>`
434
- + `<div class="detail-kv"><span class="k">终点</span><span class="v edge-nav" onclick="goEdge('${{tid}}')">${{t?.label||tid}}</span></div>`;
435
- appendEvidence({{provenance:e.provenance, trigger:e.trigger, args:e.args, guard:e.guard}});
436
- }}
437
-
438
- window.goEdge = function(id) {{
439
- const d = nodeById[id]; if (!d) return;
440
- selectedId=id; showDetail(d); highlight(id);
441
- svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(width()/2-d.x, height()/2-d.y));
442
- }};
443
-
444
- document.getElementById('search').addEventListener('input', function() {{
445
- const q=this.value.toLowerCase().trim();
446
- if (!q) {{ clearHi(); return; }}
447
- const m=new Set(GRAPH.nodes.filter(n => (n.label||'').toLowerCase().includes(q) || n.id.toLowerCase().includes(q)).map(n=>n.id));
448
- nodeSel.classed('dimmed', d => !m.has(d.id));
449
- linkSel.classed('dimmed', true).classed('highlighted', false);
450
- }});
451
-
452
- document.getElementById("source-filter").addEventListener("change", applyFilters);
453
- function applyFilters() {{
454
- const source = document.getElementById("source-filter").value;
455
- nodeSel.style("display", d => !source || d.provenance === source ? null : "none");
456
- linkSel.style("display", d => !source || d.provenance === source ? null : "none");
457
- linkHitSel.style("display", d => !source || d.provenance === source ? null : "none");
458
- nodeSel.classed('dimmed', d => !activeKinds.has(kindOf(d)));
459
- linkSel.classed('dimmed', l => {{
460
- const s=nodeById[edgeEndpoint(l,'source')], t=nodeById[edgeEndpoint(l,'target')];
461
- return !(s&&activeKinds.has(kindOf(s))) || !(t&&activeKinds.has(kindOf(t)));
462
- }});
463
- }}
464
-
465
- let clusterMode=false; const kindPos={{}};
466
- const R = Math.min(width(),height())*0.38;
467
- kinds.forEach((k,i) => {{ const a=(i/kinds.length)*2*Math.PI-Math.PI/2; kindPos[k]=[width()/2+R*Math.cos(a), height()/2+R*Math.sin(a)]; }});
468
- document.getElementById('btn-cluster').addEventListener('click', () => {{
469
- clusterMode=!clusterMode;
470
- document.getElementById('btn-cluster').textContent = clusterMode?'自由布局':'聚类模式';
471
- if (clusterMode) {{
472
- sim.force('cluster', alpha => GRAPH.nodes.forEach(d => {{ const [cx,cy]=kindPos[kindOf(d)]||[width()/2,height()/2]; d.vx+=(cx-d.x)*alpha*0.08; d.vy+=(cy-d.y)*alpha*0.08; }})).alphaTarget(.3).restart();
473
- setTimeout(()=>sim.alphaTarget(0),2000);
474
- }} else {{ sim.force('cluster',null).alphaTarget(.3).restart(); setTimeout(()=>sim.alphaTarget(0),2000); }}
475
- }});
476
- document.getElementById('btn-reset').addEventListener('click', () => {{ sim.alpha(1).restart(); clearHi(); }});
477
- document.getElementById('btn-center').addEventListener('click', () => {{ svg.transition().duration(600).call(zoom.transform, d3.zoomIdentity); }});
478
- window.addEventListener('resize', () => sim.force('center', d3.forceCenter(width()/2,height()/2)).alpha(.3).restart());
479
- </script>
480
- </body>
481
- </html>
482
- """
483
- with open(html_path, "w", encoding="utf-8") as f:
484
- f.write(html)
485
- print(f"Written: {html_path} ({os.path.getsize(html_path)//1024} KB)")
486
-
487
-
488
- if __name__ == "__main__":
489
- main()
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Interactive HTML visualization of a reconciled UTG (guided crawl output).
4
+
5
+ python scripts/utg_html.py <workspace>
6
+
7
+ Reads <workspace>/utg.json, writes <workspace>/utg.html. Self-contained; D3 v7
8
+ embedded from the bundled MIT-licensed distribution. Extends android-utg-static's renderer with the two things the crawler
9
+ adds:
10
+
11
+ * NODE = a page: the sidebar shows the captured screenshot thumbnail, the
12
+ runtime view signature, reached/unreached status, and a link to view.xml.
13
+ * EDGE = a navigation with SEMANTICS: precondition (guard), operation
14
+ (mechanism + on-screen label), status (confirmed / static-only /
15
+ dynamic-only / blocked) and confidence. Edges are colored by STATUS so the
16
+ reconciliation is visible at a glance.
17
+
18
+ Screenshots are referenced by RELATIVE path, so the HTML must live in the same
19
+ workspace directory as the p*/route_*/overlay_* page folders (it does).
20
+ """
21
+ import json
22
+ import os
23
+ import sys
24
+ from pathlib import Path
25
+ from collections import Counter
26
+
27
+ # ── Node kind palette ─────────────────────────────────────────────────────────
28
+ KIND_META = {
29
+ "activity": {"color": "#e05252", "zh": "Activity"},
30
+ "compose-route": {"color": "#4a90d9", "zh": "路由页面"},
31
+ "compose-route-variant": {"color": "#7db8e8", "zh": "数据状态变体"},
32
+ "fragment": {"color": "#e07d52", "zh": "Fragment"},
33
+ "voyager-screen": {"color": "#9b59b6", "zh": "Voyager 页面"},
34
+ "dialog": {"color": "#f39c12", "zh": "对话框"},
35
+ "sheet": {"color": "#f1c40f", "zh": "底部弹窗"},
36
+ "menu": {"color": "#2ecc71", "zh": "菜单"},
37
+ "mode": {"color": "#8e44ad", "zh": "交互模式"},
38
+ "page": {"color": "#4a90d9", "zh": "页面"},
39
+ "dynamic": {"color": "#1abc9c", "zh": "动态新增页面"},
40
+ "logical-entry": {"color": "#9b59b6", "zh": "逻辑入口"},
41
+ "host-activity": {"color": "#95a5a6", "zh": "页面宿主"},
42
+ "sentinel": {"color": "#c0392b", "zh": "阻断哨兵"},
43
+ "unknown": {"color": "#95a5a6", "zh": "未知"},
44
+ }
45
+
46
+ # ── Edge STATUS palette (the reconciliation dimension) ──────────────────────────
47
+ STATUS_META = {
48
+ "confirmed": {"color": "#2ecc71", "dash": "", "zh": "已确认(动态复现)"},
49
+ "static-only": {"color": "#8894a8", "dash": "5 4", "zh": "仅静态(未验证)"},
50
+ "dynamic-only": {"color": "#4a90d9", "dash": "", "zh": "仅动态(静态漏掉)"},
51
+ "blocked": {"color": "#e05252", "dash": "3 3", "zh": "阻断(敏感/外部)"},
52
+ }
53
+
54
+
55
+ def _guard_text(guard):
56
+ if not guard:
57
+ return ""
58
+ if isinstance(guard, dict):
59
+ kind = guard.get("kind", "")
60
+ txt = guard.get("text", "")
61
+ return f"{txt}({kind})" if kind else txt
62
+ return str(guard)
63
+
64
+
65
+ def main():
66
+ ws = sys.argv[1] if len(sys.argv) > 1 else "."
67
+ utg_path = os.path.join(ws, "utg.json")
68
+ html_path = os.path.join(ws, "utg.html")
69
+ if not os.path.exists(utg_path):
70
+ raise SystemExit(f"utg.json not found in {ws}")
71
+
72
+ utg = json.load(open(utg_path, encoding="utf-8"))
73
+ nodes = utg.get("nodes", [])
74
+ links = utg.get("links") or utg.get("edges") or []
75
+
76
+ for n in nodes:
77
+ n["_display_kind"] = "logical-entry" if (n.get("extra") or {}).get("logical_entry") else n.get("kind", "unknown")
78
+ meta = KIND_META.get(n["_display_kind"], KIND_META["unknown"])
79
+ n["_color"] = meta["color"]
80
+ n["_zh"] = meta["zh"]
81
+ # dim nodes that are unreachable OR never reached by the crawl
82
+ n["_dim"] = (n.get("status") == "unreachable") or (n.get("reached") is False
83
+ and n.get("kind") not in ("sentinel",))
84
+ n["_guardtext"] = ""
85
+
86
+ for e in links:
87
+ st = e.get("status", "static-only")
88
+ sm = STATUS_META.get(st, STATUS_META["static-only"])
89
+ e["_color"] = sm["color"]
90
+ e["_dash"] = sm["dash"]
91
+ e["_guardtext"] = _guard_text(e.get("guard"))
92
+ e["_label"] = (e.get("args") or {}).get("label", "")
93
+
94
+ kind_counts = Counter(n["_display_kind"] for n in nodes)
95
+ status_counts = Counter(e.get("status", "static-only") for e in links)
96
+ reached = sum(1 for n in nodes if n.get("reached"))
97
+ pages = sum(1 for n in nodes if n.get("kind") not in ("sentinel",))
98
+
99
+ stats_html = "\n".join(
100
+ f'<span class="badge" style="background:{KIND_META.get(k, KIND_META["unknown"])["color"]}">'
101
+ f'{KIND_META.get(k, {}).get("zh", k)}: {v}</span>'
102
+ for k, v in sorted(kind_counts.items(), key=lambda x: -x[1])
103
+ )
104
+ status_html = "\n".join(
105
+ f'<span class="badge" style="background:{STATUS_META.get(s, {}).get("color", "#888")}">'
106
+ f'{STATUS_META.get(s, {}).get("zh", s)}: {v}</span>'
107
+ for s, v in sorted(status_counts.items(), key=lambda x: -x[1])
108
+ )
109
+
110
+ graph_json = json.dumps({"nodes": nodes, "links": links}, ensure_ascii=False).replace("<", "\\u003c")
111
+ review_json = json.dumps({"audit": utg.get("graph", {}).get("discovery_audit", []), "unresolved": utg.get("graph", {}).get("unresolved", []), "boundary_transitions": utg.get("graph", {}).get("boundary_transitions", [])}, ensure_ascii=False).replace("<", "\\u003c")
112
+ d3_source = (Path(__file__).parent / "vendor" / "d3-7.9.0.min.js").read_text(encoding="utf-8")
113
+ kind_meta_json = json.dumps(KIND_META, ensure_ascii=False)
114
+ status_meta_json = json.dumps(STATUS_META, ensure_ascii=False)
115
+
116
+ html = f"""<!DOCTYPE html>
117
+ <html lang="zh">
118
+ <head>
119
+ <meta charset="UTF-8">
120
+ <title>UTG — {os.path.basename(os.path.abspath(ws))}</title>
121
+ <script>{d3_source}</script>
122
+ <style>
123
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
124
+ body {{ font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; display: flex; height: 100vh; overflow: hidden; }}
125
+ #sidebar {{ width: 360px; min-width: 260px; max-width: 520px; background: #16213e; border-right: 1px solid #0f3460; display: flex; flex-direction: column; overflow: hidden; }}
126
+ #sidebar-header {{ padding: 14px 16px 10px; background: #0f3460; flex-shrink: 0; }}
127
+ #sidebar-header h1 {{ font-size: 15px; font-weight: 700; }}
128
+ #sidebar-header .sub {{ font-size: 11px; color: #aaa; margin-top: 3px; }}
129
+ .badge {{ display: inline-block; font-size: 10px; padding: 2px 6px; border-radius: 10px; color: #fff; margin: 2px 2px 2px 0; opacity: .9; }}
130
+ #stats {{ padding: 10px 16px; border-bottom: 1px solid #0f3460; flex-shrink: 0; }}
131
+ #stats .section-label {{ font-size: 10px; color: #888; text-transform: uppercase; letter-spacing: .05em; margin: 6px 0 4px; }}
132
+ #filter-box {{ padding: 10px 16px; border-bottom: 1px solid #0f3460; flex-shrink: 0; }}
133
+ #filter-box input {{ width: 100%; padding: 6px 10px; border-radius: 6px; background: #0d0d1a; border: 1px solid #0f3460; color: #eee; font-size: 13px; }}
134
+ #filter-box input:focus {{ outline: none; border-color: #4a90d9; }}
135
+ #kind-filters {{ display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }}
136
+ #kind-filters button {{ font-size: 10px; border: none; border-radius: 10px; padding: 2px 8px; cursor: pointer; opacity: .6; color: #fff; transition: opacity .15s; }}
137
+ #kind-filters button.active {{ opacity: 1; }}
138
+ #detail-panel {{ flex: 1; overflow-y: auto; padding: 12px 16px; }}
139
+ #detail-panel h2 {{ font-size: 14px; margin-bottom: 8px; }}
140
+ .shot {{ width: 100%; max-height: 340px; object-fit: contain; border: 1px solid #0f3460; border-radius: 6px; background:#000; margin-bottom: 10px; }}
141
+ .detail-kv {{ margin-bottom: 5px; font-size: 12px; }}
142
+ .detail-kv .k {{ color: #888; width: 78px; display: inline-block; vertical-align: top; }}
143
+ .detail-kv .v {{ color: #eee; word-break: break-all; }}
144
+ .edge-list {{ margin-top: 4px; }}
145
+ .edge-item {{ font-size: 11px; padding: 5px 7px; margin: 3px 0; border-radius: 5px; background: #1a1a2e; cursor: pointer; }}
146
+ .edge-item:hover {{ background: #0f3460; }}
147
+ .edge-top {{ display: flex; align-items: center; gap: 6px; }}
148
+ .edge-dot {{ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }}
149
+ .edge-sub {{ color: #9aa4b8; font-size: 10px; margin-top: 2px; padding-left: 14px; }}
150
+ .edge-sub .guard {{ color: #f39c12; }}
151
+ svg {{ width: 100%; height: 100%; display: block; }}
152
+ #canvas-wrap {{ flex: 1; position: relative; overflow: hidden; }}
153
+ .node circle {{ stroke-width: 2px; cursor: pointer; transition: r .1s; }}
154
+ .node circle.dim {{ stroke-dasharray: 4 3; opacity: .45; }}
155
+ .node text {{ pointer-events: auto; cursor: pointer; font-size: 10px; fill: #ddd; text-shadow: 0 0 3px #000, 0 0 3px #000; }}
156
+ .link {{ fill: none; stroke-width: 1.6px; opacity: .6; pointer-events: none; }}
157
+ .link.highlighted {{ opacity: 1; stroke-width: 2.6px; }}
158
+ .link-hit {{ fill: none; stroke: transparent; stroke-width: 14px; pointer-events: stroke; cursor: pointer; }}
159
+ .edge-nav {{ cursor: pointer; color: #4a90d9; }}
160
+ .edge-nav:hover {{ text-decoration: underline; }}
161
+ .node.dimmed circle {{ opacity: .15; }}
162
+ .node.dimmed text {{ opacity: .15; }}
163
+ .link.dimmed {{ opacity: .06; }}
164
+ #tooltip {{ position: absolute; pointer-events: none; background: rgba(15,52,96,.97); border: 1px solid #4a90d9; border-radius: 6px; padding: 6px 10px; font-size: 12px; max-width: 240px; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.5); }}
165
+ #tooltip img {{ width: 120px; display:block; margin-top:5px; border-radius:4px; }}
166
+ #controls {{ position: absolute; bottom: 14px; right: 14px; display: flex; gap: 8px; }}
167
+ #controls button {{ background: #0f3460; border: 1px solid #4a90d9; color: #eee; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; }}
168
+ #controls button:hover {{ background: #1a4a7a; }}
169
+ #legend {{ position: absolute; top: 10px; right: 10px; background: rgba(15,33,62,.92); border: 1px solid #0f3460; border-radius: 6px; padding: 8px 12px; font-size: 11px; }}
170
+ #legend .l-row {{ display: flex; align-items: center; gap: 6px; margin: 3px 0; }}
171
+ #legend .l-line {{ width: 18px; height: 0; border-top: 3px solid; flex-shrink: 0; }}
172
+ #legend .section-label {{ font-size: 9px; color:#888; text-transform:uppercase; margin: 4px 0 2px; }}
173
+ </style>
174
+ </head>
175
+ <body>
176
+ <div id="sidebar">
177
+ <div id="sidebar-header">
178
+ <h1>UI Transition Graph · 对账结果</h1>
179
+ <div class="sub">{pages} 页面 · {reached} 已到达 · {len(links)} 边</div>
180
+ </div>
181
+ <div id="stats">
182
+ <div class="section-label">节点分类</div>
183
+ {stats_html}
184
+ <div class="section-label">边状态(对账)</div>
185
+ {status_html}
186
+ </div>
187
+ <div id="filter-box">
188
+ <input id="search" type="text" placeholder="搜索节点 (名称 / ID)…">
189
+ <div id="kind-filters"></div>
190
+ <select id="source-filter"><option value="">全部来源</option><option value="appgraph">AppGraph</option><option value="ai-source-review">AI 修补</option><option value="ai-source-analysis">AI 源码分析</option></select>
191
+ <details><summary>修补与未解析项</summary><pre id="review-data" style="white-space:pre-wrap"></pre></details>
192
+ </div>
193
+ <div id="detail-panel">
194
+ <div id="detail-empty" style="color:#666;font-size:12px;margin-top:20px">点击图中节点查看截图与边语义</div>
195
+ <div id="detail-content" style="display:none"></div>
196
+ </div>
197
+ </div>
198
+ <div id="canvas-wrap">
199
+ <svg id="graph"></svg>
200
+ <div id="tooltip"></div>
201
+ <div id="legend"><div id="legend-items"></div></div>
202
+ <div id="controls">
203
+ <button id="btn-reset">重置视图</button>
204
+ <button id="btn-center">居中</button>
205
+ <button id="btn-cluster">聚类模式</button>
206
+ </div>
207
+ </div>
208
+ <script>
209
+ const GRAPH = {graph_json};
210
+ const REVIEW = {review_json};
211
+ document.getElementById("review-data").textContent = JSON.stringify(REVIEW, null, 2);
212
+ const KIND_META = {kind_meta_json};
213
+ const kindOf = node => node._display_kind || node.kind;
214
+ const STATUS_META = {status_meta_json};
215
+
216
+ // ── Legend: node kinds + edge statuses ──
217
+ const legendEl = document.getElementById('legend-items');
218
+ let lg = '<div class="section-label">节点</div>';
219
+ Object.entries(KIND_META).forEach(([k, m]) => {{
220
+ if (!GRAPH.nodes.some(n => kindOf(n) === k)) return;
221
+ lg += `<div class="l-row"><div style="width:10px;height:10px;border-radius:50%;background:${{m.color}}"></div><span>${{m.zh || k}}</span></div>`;
222
+ }});
223
+ lg += '<div class="section-label">边状态</div>';
224
+ Object.entries(STATUS_META).forEach(([s, m]) => {{
225
+ if (!GRAPH.links.some(e => (e.status||'static-only') === s)) return;
226
+ lg += `<div class="l-row"><div class="l-line" style="border-top-style:${{m.dash?'dashed':'solid'}};border-color:${{m.color}}"></div><span>${{m.zh || s}}</span></div>`;
227
+ }});
228
+ legendEl.innerHTML = lg;
229
+
230
+ // ── Kind filters ──
231
+ const kinds = [...new Set(GRAPH.nodes.map(kindOf))];
232
+ let activeKinds = new Set(kinds);
233
+ const kindFiltersEl = document.getElementById('kind-filters');
234
+ kinds.forEach(k => {{
235
+ const meta = KIND_META[k] || {{color:'#888'}};
236
+ const btn = document.createElement('button');
237
+ btn.textContent = (KIND_META[k]?.zh || k);
238
+ btn.style.background = meta.color;
239
+ btn.classList.add('active');
240
+ btn.addEventListener('click', () => {{
241
+ if (activeKinds.has(k)) {{ activeKinds.delete(k); btn.classList.remove('active'); }}
242
+ else {{ activeKinds.add(k); btn.classList.add('active'); }}
243
+ applyFilters();
244
+ }});
245
+ kindFiltersEl.appendChild(btn);
246
+ }});
247
+
248
+ const svg = d3.select('#graph');
249
+ const width = () => document.getElementById('canvas-wrap').clientWidth;
250
+ const height = () => document.getElementById('canvas-wrap').clientHeight;
251
+ const defs = svg.append('defs');
252
+ // one arrow marker per status color
253
+ Object.entries(STATUS_META).forEach(([s, m]) => {{
254
+ defs.append('marker').attr('id','arrow-'+s).attr('viewBox','0 -5 10 10')
255
+ .attr('refX',22).attr('refY',0).attr('markerWidth',6).attr('markerHeight',6)
256
+ .attr('orient','auto').append('path').attr('d','M0,-5L10,0L0,5').attr('fill',m.color);
257
+ }});
258
+ const zoom = d3.zoom().scaleExtent([.05,4]).on('zoom', e => container.attr('transform', e.transform));
259
+ svg.call(zoom);
260
+ const container = svg.append('g');
261
+ const linkGroup = container.append('g');
262
+ const linkHitGroup = container.append('g');
263
+ const nodeGroup = container.append('g');
264
+ const nodeById = Object.fromEntries(GRAPH.nodes.map(n => [n.id, n]));
265
+ const inDeg = {{}}, outDeg = {{}};
266
+ GRAPH.nodes.forEach(n => {{ inDeg[n.id]=0; outDeg[n.id]=0; }});
267
+ GRAPH.links.forEach(e => {{ outDeg[e.source]=(outDeg[e.source]||0)+1; inDeg[e.target]=(inDeg[e.target]||0)+1; }});
268
+
269
+ // Separate parallel and opposing edges; a self-transition needs a visible loop.
270
+ const edgeBundles = new Map();
271
+ GRAPH.links.forEach(edge => {{
272
+ const pair = JSON.stringify([edge.source, edge.target].sort());
273
+ if (!edgeBundles.has(pair)) edgeBundles.set(pair, []);
274
+ edgeBundles.get(pair).push(edge);
275
+ }});
276
+ edgeBundles.forEach(bundle => bundle.forEach((edge, index) => {{
277
+ edge._bend = (index - (bundle.length - 1)/2) * 36 * (edge.source < edge.target ? 1 : -1);
278
+ edge._loop = 32 + index * 18;
279
+ }}));
280
+ function edgePath(edge) {{
281
+ const s=edge.source, t=edge.target;
282
+ if (s.id === t.id) {{ const r=edge._loop; return `M${{s.x}},${{s.y}} C${{s.x+r}},${{s.y-r*2}} ${{s.x-r}},${{s.y-r*2}} ${{s.x}},${{s.y}}`; }}
283
+ const dx=t.x-s.x, dy=t.y-s.y, length=Math.hypot(dx,dy)||1;
284
+ return `M${{s.x}},${{s.y}} Q${{(s.x+t.x)/2-dy/length*edge._bend}},${{(s.y+t.y)/2+dx/length*edge._bend}} ${{t.x}},${{t.y}}`;
285
+ }}
286
+
287
+ const sim = d3.forceSimulation(GRAPH.nodes)
288
+ .force('link', d3.forceLink(GRAPH.links).id(d => d.id).distance(110).strength(0.3))
289
+ .force('charge', d3.forceManyBody().strength(-240))
290
+ .force('center', d3.forceCenter(width()/2, height()/2))
291
+ .force('collision', d3.forceCollide(26))
292
+ .alphaDecay(0.02);
293
+
294
+ const linkSel = linkGroup.selectAll('path').data(GRAPH.links).join('path').attr('fill','none')
295
+ .attr('class','link')
296
+ .attr('stroke', d => d._color)
297
+ .attr('stroke-dasharray', d => d._dash || null)
298
+ .attr('marker-end', d => `url(#arrow-${{d.status||'static-only'}})`);
299
+
300
+ // Wide transparent hitzone lines so thin edges are easy to click/hover.
301
+ const linkHitSel = linkHitGroup.selectAll('path').data(GRAPH.links).join('path').attr('fill','none')
302
+ .attr('class','link-hit');
303
+
304
+ const nodeSel = nodeGroup.selectAll('g').data(GRAPH.nodes).join('g').attr('class','node')
305
+ .call(d3.drag()
306
+ .on('start', (e,d) => {{ if (!e.active) sim.alphaTarget(.3).restart(); d.fx=d.x; d.fy=d.y; }})
307
+ .on('drag', (e,d) => {{ d.fx=e.x; d.fy=e.y; }})
308
+ .on('end', (e,d) => {{ if (!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }}));
309
+
310
+ nodeSel.append('circle').attr('r', d => nodeRadius(d))
311
+ .attr('fill', d => d._color).attr('stroke', d => d3.color(d._color).brighter(.8))
312
+ .attr('class', d => d._dim ? 'dim' : '');
313
+ nodeSel.append('text').attr('dy', d => nodeRadius(d) + 11).attr('text-anchor','middle')
314
+ .text(d => truncate(d.label || d.id, 18));
315
+
316
+ function nodeRadius(d) {{ const deg=(inDeg[d.id]||0)+(outDeg[d.id]||0); return Math.max(8, Math.min(20, 8+deg*1.2)); }}
317
+ function truncate(s,n) {{ s=String(s); return s.length>n ? s.slice(0,n-1)+'…' : s; }}
318
+
319
+ sim.on('tick', () => {{
320
+ linkSel.attr('d',edgePath);
321
+ linkHitSel.attr('d',edgePath);
322
+ nodeSel.attr('transform', d => `translate(${{d.x}},${{d.y}})`);
323
+ }});
324
+
325
+ const tooltip = document.getElementById('tooltip');
326
+ nodeSel.on('mouseover', (e,d) => {{
327
+ tooltip.style.display='block';
328
+ tooltip.innerHTML = `<b>${{d.label||d.id}}</b><br><span style="color:#aaa">${{d.kind}}</span>`
329
+ + `<br><span style="color:#4a90d9">入 ${{inDeg[d.id]||0}} · 出 ${{outDeg[d.id]||0}}</span>`
330
+ + (d.reached ? '<br><span style="color:#2ecc71">✓ 已到达</span>' : '<br><span style="color:#e05252">✗ 未到达</span>')
331
+ + (d.screenshot ? `<img src="${{d.screenshot}}">` : '');
332
+ moveTip(e);
333
+ }}).on('mousemove', moveTip).on('mouseout', () => tooltip.style.display='none');
334
+ function moveTip(e) {{ tooltip.style.left=(e.pageX+12)+'px'; tooltip.style.top=(e.pageY-28)+'px'; }}
335
+
336
+ let selectedId = null;
337
+ nodeSel.on('click', (e,d) => {{ e.stopPropagation(); selectedId=d.id; showDetail(d); highlight(d.id); }});
338
+ svg.on('click', () => {{ selectedId=null; clearHi(); document.getElementById('detail-empty').style.display=''; document.getElementById('detail-content').style.display='none'; }});
339
+
340
+ // Edge interaction: the wide transparent hitzone layer catches clicks/hovers so
341
+ // thin edges are easy to hit. Click → edge-semantics detail; hover → tooltip.
342
+ linkHitSel
343
+ .on('click', (e,d) => {{ e.stopPropagation(); selectedId=null; showEdgeDetail(d); }})
344
+ .on('mouseover', (e,d) => {{
345
+ const s=edgeEndpoint(d,'source'), t=edgeEndpoint(d,'target');
346
+ tooltip.style.display='block';
347
+ tooltip.innerHTML = `<b style="color:${{d._color}}">${{d.status||'static-only'}}</b>`
348
+ + `<br>${{nodeById[s]?.label||s}} → ${{nodeById[t]?.label||t}}`
349
+ + `<br><span style="color:#aaa">${{d._label||d.mechanism||''}}</span>`
350
+ + (d._guardtext ? `<br><span style="color:#f39c12">前置: ${{d._guardtext}}</span>` : '');
351
+ moveTip(e);
352
+ }}).on('mousemove', moveTip).on('mouseout', () => tooltip.style.display='none');
353
+
354
+ function edgeEndpoint(l, which) {{ const x=l[which]; return typeof x==='object'?x.id:x; }}
355
+
356
+ function highlight(id) {{
357
+ const nb=new Set([id]);
358
+ GRAPH.links.forEach(l => {{ const s=edgeEndpoint(l,'source'),t=edgeEndpoint(l,'target'); if(s===id||t===id){{nb.add(s);nb.add(t);}} }});
359
+ nodeSel.classed('dimmed', nd => !nb.has(nd.id));
360
+ linkSel.classed('dimmed', l => edgeEndpoint(l,'source')!==id && edgeEndpoint(l,'target')!==id)
361
+ .classed('highlighted', l => edgeEndpoint(l,'source')===id || edgeEndpoint(l,'target')===id);
362
+ }}
363
+ function clearHi() {{ nodeSel.classed('dimmed',false); linkSel.classed('dimmed',false).classed('highlighted',false); }}
364
+
365
+ function edgeHtml(edges, dir) {{
366
+ if (!edges.length) return '<div style="color:#666;font-size:11px">无</div>';
367
+ return edges.map(e => {{
368
+ const other = dir==='out' ? edgeEndpoint(e,'target') : edgeEndpoint(e,'source');
369
+ const on = nodeById[other];
370
+ const col = e._color;
371
+ const op = e._label ? `<b>${{e._label}}</b>` : (e.mechanism||'');
372
+ const conf = (e.confidence!=null && e.confidence<1) ? ` <span style="color:#f39c12">≈${{(e.confidence*100).toFixed(0)}}%</span>` : '';
373
+ const guard = e._guardtext ? `<span class="guard">前置: ${{e._guardtext}}</span> · ` : '';
374
+ const trig = e.trigger ? String(e.trigger).replace(/^onClick@/,'').split(/[\\\\/]/).pop() : '';
375
+ return `<div class="edge-item" onclick="goEdge('${{other}}')">`
376
+ + `<div class="edge-top"><span class="edge-dot" style="background:${{col}}"></span>`
377
+ + `<span style="color:${{col}};font-size:10px">${{e.status||'static-only'}}</span>`
378
+ + `<span style="flex:1">${{dir==='out'?'→':'←'}} ${{on?.label||other}}</span>${{conf}}</div>`
379
+ + `<div class="edge-sub">${{guard}}操作: ${{op}} <span style="color:#667">[${{e.mechanism||'?'}}]</span>${{trig?` · ${{trig}}`:''}}</div>`
380
+ + `</div>`;
381
+ }}).join('');
382
+ }}
383
+
384
+ function showDetail(d) {{
385
+ document.getElementById('detail-empty').style.display='none';
386
+ const panel = document.getElementById('detail-content'); panel.style.display='';
387
+ const outE = GRAPH.links.filter(l => edgeEndpoint(l,'source')===d.id);
388
+ const inE = GRAPH.links.filter(l => edgeEndpoint(l,'target')===d.id);
389
+ const statusColor = d.reached ? '#2ecc71' : (d.status==='unreachable'?'#e05252':'#f39c12');
390
+ panel.innerHTML =
391
+ (d.screenshot ? `<img class="shot" src="${{d.screenshot}}" onerror="this.style.display='none'">` : '')
392
+ + `<h2 style="color:${{d._color}}">${{d.label || d.id}}</h2>`
393
+ + `<div class="detail-kv"><span class="k">ID</span><span class="v">${{d.id}}</span></div>`
394
+ + `<div class="detail-kv"><span class="k">类型</span><span class="v" style="color:${{d._color}}">${{d._zh||d.kind}}</span></div>`
395
+ + `<div class="detail-kv"><span class="k">到达</span><span class="v" style="color:${{statusColor}}">${{d.reached?'✓ 已到达':'✗ 未到达'}} (${{d.status||'static-only'}})</span></div>`
396
+ + (d.reached && d.verify ? `<div class="detail-kv"><span class="k">验证</span><span class="v" style="color:${{d.verify==='structural'?'#f39c12':'#2ecc71'}}">${{d.verify==='structural'?'结构(身份待核)':d.verify}}</span></div>` : '')
397
+ + (d.phase ? `<div class="detail-kv"><span class="k">相位</span><span class="v">${{d.phase}}</span></div>` : '')
398
+ + (d.env_state ? `<div class="detail-kv"><span class="k">旁路原因</span><span class="v" style="color:#c78">${{d.env_state}}</span></div>` : '')
399
+ + (d.view_signature ? `<div class="detail-kv"><span class="k">页面签名</span><span class="v" style="font-size:10px">${{d.view_signature}}</span></div>` : '')
400
+ + (d.view_tree ? `<div class="detail-kv"><span class="k">视图树</span><span class="v"><a href="${{d.view_tree}}" style="color:#4a90d9" target="_blank">${{d.view_tree}}</a></span></div>` : '')
401
+ + (d.declared_at ? `<div class="detail-kv"><span class="k">声明位置</span><span class="v" style="font-size:10px">${{d.declared_at}}</span></div>` : '')
402
+ + `<div class="detail-kv" style="margin-top:10px"><span class="k">跳出边</span><span class="v">${{outE.length}} 条</span></div>`
403
+ + `<div class="edge-list">${{edgeHtml(outE,'out')}}</div>`
404
+ + `<div class="detail-kv" style="margin-top:8px"><span class="k">跳入边</span><span class="v">${{inE.length}} 条</span></div>`
405
+ + `<div class="edge-list">${{edgeHtml(inE,'in')}}</div>`;
406
+ appendEvidence({{provenance:d.provenance, declared_at:d.declared_at, expected:d.expected, widgets:d.widgets, extra:d.extra}});
407
+ }}
408
+
409
+ function appendEvidence(value) {{
410
+ const pre = document.createElement('pre');
411
+ pre.style.cssText = 'white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px';
412
+ pre.textContent = JSON.stringify(value, null, 2);
413
+ document.getElementById('detail-content').appendChild(pre);
414
+ }}
415
+
416
+ function showEdgeDetail(e) {{
417
+ document.getElementById('detail-empty').style.display='none';
418
+ const panel = document.getElementById('detail-content'); panel.style.display='';
419
+ const sid = edgeEndpoint(e,'source'), tid = edgeEndpoint(e,'target');
420
+ const s = nodeById[sid], t = nodeById[tid];
421
+ const st = e.status || 'static-only';
422
+ const conf = (e.confidence!=null && e.confidence<1) ? `≈${{(e.confidence*100).toFixed(0)}}%` : '100%';
423
+ const trig = e.trigger ? String(e.trigger).replace(/^onClick@/,'') : '';
424
+ panel.innerHTML =
425
+ `<h2 style="color:${{e._color}}">${{s?.label||sid}} → ${{t?.label||tid}}</h2>`
426
+ + `<div class="detail-kv"><span class="k">状态</span><span class="v" style="color:${{e._color}}">${{st}}</span></div>`
427
+ + `<div class="detail-kv"><span class="k">机制</span><span class="v">${{e.mechanism||'?'}}</span></div>`
428
+ + (e._label ? `<div class="detail-kv"><span class="k">操作</span><span class="v">${{e._label}}</span></div>` : '')
429
+ + (e._guardtext ? `<div class="detail-kv"><span class="k">前置</span><span class="v" style="color:#f39c12">${{e._guardtext}}</span></div>` : '')
430
+ + `<div class="detail-kv"><span class="k">置信度</span><span class="v">${{conf}}</span></div>`
431
+ + (e.provenance ? `<div class="detail-kv"><span class="k">来源</span><span class="v">${{e.provenance}}</span></div>` : '')
432
+ + (trig ? `<div class="detail-kv"><span class="k">触发</span><span class="v" style="font-size:10px">${{trig}}</span></div>` : '')
433
+ + `<div class="detail-kv" style="margin-top:10px"><span class="k">起点</span><span class="v edge-nav" onclick="goEdge('${{sid}}')">${{s?.label||sid}}</span></div>`
434
+ + `<div class="detail-kv"><span class="k">终点</span><span class="v edge-nav" onclick="goEdge('${{tid}}')">${{t?.label||tid}}</span></div>`;
435
+ appendEvidence({{provenance:e.provenance, trigger:e.trigger, args:e.args, guard:e.guard}});
436
+ }}
437
+
438
+ window.goEdge = function(id) {{
439
+ const d = nodeById[id]; if (!d) return;
440
+ selectedId=id; showDetail(d); highlight(id);
441
+ svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(width()/2-d.x, height()/2-d.y));
442
+ }};
443
+
444
+ document.getElementById('search').addEventListener('input', function() {{
445
+ const q=this.value.toLowerCase().trim();
446
+ if (!q) {{ clearHi(); return; }}
447
+ const m=new Set(GRAPH.nodes.filter(n => (n.label||'').toLowerCase().includes(q) || n.id.toLowerCase().includes(q)).map(n=>n.id));
448
+ nodeSel.classed('dimmed', d => !m.has(d.id));
449
+ linkSel.classed('dimmed', true).classed('highlighted', false);
450
+ }});
451
+
452
+ document.getElementById("source-filter").addEventListener("change", applyFilters);
453
+ function applyFilters() {{
454
+ const source = document.getElementById("source-filter").value;
455
+ nodeSel.style("display", d => !source || d.provenance === source ? null : "none");
456
+ linkSel.style("display", d => !source || d.provenance === source ? null : "none");
457
+ linkHitSel.style("display", d => !source || d.provenance === source ? null : "none");
458
+ nodeSel.classed('dimmed', d => !activeKinds.has(kindOf(d)));
459
+ linkSel.classed('dimmed', l => {{
460
+ const s=nodeById[edgeEndpoint(l,'source')], t=nodeById[edgeEndpoint(l,'target')];
461
+ return !(s&&activeKinds.has(kindOf(s))) || !(t&&activeKinds.has(kindOf(t)));
462
+ }});
463
+ }}
464
+
465
+ let clusterMode=false; const kindPos={{}};
466
+ const R = Math.min(width(),height())*0.38;
467
+ kinds.forEach((k,i) => {{ const a=(i/kinds.length)*2*Math.PI-Math.PI/2; kindPos[k]=[width()/2+R*Math.cos(a), height()/2+R*Math.sin(a)]; }});
468
+ document.getElementById('btn-cluster').addEventListener('click', () => {{
469
+ clusterMode=!clusterMode;
470
+ document.getElementById('btn-cluster').textContent = clusterMode?'自由布局':'聚类模式';
471
+ if (clusterMode) {{
472
+ sim.force('cluster', alpha => GRAPH.nodes.forEach(d => {{ const [cx,cy]=kindPos[kindOf(d)]||[width()/2,height()/2]; d.vx+=(cx-d.x)*alpha*0.08; d.vy+=(cy-d.y)*alpha*0.08; }})).alphaTarget(.3).restart();
473
+ setTimeout(()=>sim.alphaTarget(0),2000);
474
+ }} else {{ sim.force('cluster',null).alphaTarget(.3).restart(); setTimeout(()=>sim.alphaTarget(0),2000); }}
475
+ }});
476
+ document.getElementById('btn-reset').addEventListener('click', () => {{ sim.alpha(1).restart(); clearHi(); }});
477
+ document.getElementById('btn-center').addEventListener('click', () => {{ svg.transition().duration(600).call(zoom.transform, d3.zoomIdentity); }});
478
+ window.addEventListener('resize', () => sim.force('center', d3.forceCenter(width()/2,height()/2)).alpha(.3).restart());
479
+ </script>
480
+ </body>
481
+ </html>
482
+ """
483
+ with open(html_path, "w", encoding="utf-8") as f:
484
+ f.write(html)
485
+ print(f"Written: {html_path} ({os.path.getsize(html_path)//1024} KB)")
486
+
487
+
488
+ if __name__ == "__main__":
489
+ main()