ts-knowledge-graph 0.1.1 → 0.1.4

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 (212) hide show
  1. package/README.md +104 -43
  2. package/contribs/web_visualisation/README.md +83 -0
  3. package/contribs/web_visualisation/web/css/style.css +219 -0
  4. package/contribs/web_visualisation/web/data/.gitignore +3 -0
  5. package/contribs/web_visualisation/web/data/kind_descriptions.js +38 -0
  6. package/contribs/web_visualisation/web/index.html +74 -0
  7. package/contribs/web_visualisation/web/js/app.js +910 -0
  8. package/contribs/web_visualisation/web/tsconfig.json +18 -0
  9. package/contribs/web_visualisation/web/types/app_globals.d.ts +146 -0
  10. package/dist/benchmark/benchmark_stats.d.ts +41 -0
  11. package/dist/benchmark/benchmark_stats.d.ts.map +1 -0
  12. package/dist/benchmark/benchmark_stats.js +61 -0
  13. package/dist/benchmark/benchmark_stats.js.map +1 -0
  14. package/dist/benchmark/node_benchmark.d.ts +78 -0
  15. package/dist/benchmark/node_benchmark.d.ts.map +1 -0
  16. package/dist/benchmark/node_benchmark.js +112 -0
  17. package/dist/benchmark/node_benchmark.js.map +1 -0
  18. package/dist/cli.d.ts +0 -9
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +32 -208
  21. package/dist/cli.js.map +1 -1
  22. package/dist/commands/benchmark_command.d.ts +11 -0
  23. package/dist/commands/benchmark_command.d.ts.map +1 -0
  24. package/dist/commands/benchmark_command.js +91 -0
  25. package/dist/commands/benchmark_command.js.map +1 -0
  26. package/dist/commands/blast_radius_command.d.ts +5 -0
  27. package/dist/commands/blast_radius_command.d.ts.map +1 -0
  28. package/dist/commands/blast_radius_command.js +18 -0
  29. package/dist/commands/blast_radius_command.js.map +1 -0
  30. package/dist/commands/calls_command.d.ts +5 -0
  31. package/dist/commands/calls_command.d.ts.map +1 -0
  32. package/dist/commands/calls_command.js +7 -0
  33. package/dist/commands/calls_command.js.map +1 -0
  34. package/dist/commands/command_helpers.d.ts +15 -0
  35. package/dist/commands/command_helpers.d.ts.map +1 -0
  36. package/dist/commands/command_helpers.js +61 -0
  37. package/dist/commands/command_helpers.js.map +1 -0
  38. package/dist/commands/cost_command.d.ts +13 -0
  39. package/dist/commands/cost_command.d.ts.map +1 -0
  40. package/dist/commands/cost_command.js +122 -0
  41. package/dist/commands/cost_command.js.map +1 -0
  42. package/dist/commands/dead_exports_command.d.ts +5 -0
  43. package/dist/commands/dead_exports_command.d.ts.map +1 -0
  44. package/dist/commands/dead_exports_command.js +7 -0
  45. package/dist/commands/dead_exports_command.js.map +1 -0
  46. package/dist/commands/enrich_command.d.ts +7 -0
  47. package/dist/commands/enrich_command.d.ts.map +1 -0
  48. package/dist/commands/enrich_command.js +62 -0
  49. package/dist/commands/enrich_command.js.map +1 -0
  50. package/dist/commands/extract_command.d.ts +8 -0
  51. package/dist/commands/extract_command.d.ts.map +1 -0
  52. package/dist/commands/extract_command.js +49 -0
  53. package/dist/commands/extract_command.js.map +1 -0
  54. package/dist/commands/find_command.d.ts +5 -0
  55. package/dist/commands/find_command.d.ts.map +1 -0
  56. package/dist/commands/find_command.js +7 -0
  57. package/dist/commands/find_command.js.map +1 -0
  58. package/dist/commands/hotspots_command.d.ts +7 -0
  59. package/dist/commands/hotspots_command.d.ts.map +1 -0
  60. package/dist/commands/hotspots_command.js +67 -0
  61. package/dist/commands/hotspots_command.js.map +1 -0
  62. package/dist/commands/install_command.d.ts +15 -0
  63. package/dist/commands/install_command.d.ts.map +1 -0
  64. package/dist/commands/install_command.js +41 -0
  65. package/dist/commands/install_command.js.map +1 -0
  66. package/dist/commands/load_command.d.ts +6 -0
  67. package/dist/commands/load_command.d.ts.map +1 -0
  68. package/dist/commands/load_command.js +30 -0
  69. package/dist/commands/load_command.js.map +1 -0
  70. package/dist/commands/neighbors_command.d.ts +5 -0
  71. package/dist/commands/neighbors_command.d.ts.map +1 -0
  72. package/dist/commands/neighbors_command.js +17 -0
  73. package/dist/commands/neighbors_command.js.map +1 -0
  74. package/dist/commands/references_command.d.ts +5 -0
  75. package/dist/commands/references_command.d.ts.map +1 -0
  76. package/dist/commands/references_command.js +17 -0
  77. package/dist/commands/references_command.js.map +1 -0
  78. package/dist/commands/verify_command.d.ts +8 -0
  79. package/dist/commands/verify_command.d.ts.map +1 -0
  80. package/dist/commands/verify_command.js +57 -0
  81. package/dist/commands/verify_command.js.map +1 -0
  82. package/dist/commands/web_command.d.ts +46 -0
  83. package/dist/commands/web_command.d.ts.map +1 -0
  84. package/dist/commands/web_command.js +226 -0
  85. package/dist/commands/web_command.js.map +1 -0
  86. package/dist/commands/who_calls_command.d.ts +5 -0
  87. package/dist/commands/who_calls_command.d.ts.map +1 -0
  88. package/dist/commands/who_calls_command.js +7 -0
  89. package/dist/commands/who_calls_command.js.map +1 -0
  90. package/dist/enrich/cpu_profile.d.ts +127 -0
  91. package/dist/enrich/cpu_profile.d.ts.map +1 -0
  92. package/dist/enrich/cpu_profile.js +97 -0
  93. package/dist/enrich/cpu_profile.js.map +1 -0
  94. package/dist/enrich/runtime_enricher.d.ts +56 -0
  95. package/dist/enrich/runtime_enricher.d.ts.map +1 -0
  96. package/dist/enrich/runtime_enricher.js +80 -0
  97. package/dist/enrich/runtime_enricher.js.map +1 -0
  98. package/dist/enrich/runtime_join.d.ts +100 -0
  99. package/dist/enrich/runtime_join.d.ts.map +1 -0
  100. package/dist/enrich/runtime_join.js +227 -0
  101. package/dist/enrich/runtime_join.js.map +1 -0
  102. package/dist/extract/api_extractor.d.ts +24 -0
  103. package/dist/extract/api_extractor.d.ts.map +1 -0
  104. package/dist/extract/api_extractor.js +71 -0
  105. package/dist/extract/api_extractor.js.map +1 -0
  106. package/dist/extract/config_extractor.d.ts +22 -0
  107. package/dist/extract/config_extractor.d.ts.map +1 -0
  108. package/dist/extract/config_extractor.js +61 -0
  109. package/dist/extract/config_extractor.js.map +1 -0
  110. package/dist/extract/endpoint_extractor.d.ts +36 -0
  111. package/dist/extract/endpoint_extractor.d.ts.map +1 -0
  112. package/dist/extract/endpoint_extractor.js +117 -0
  113. package/dist/extract/endpoint_extractor.js.map +1 -0
  114. package/dist/extract/{graph-builder.d.ts → graph_builder.d.ts} +9 -1
  115. package/dist/extract/graph_builder.d.ts.map +1 -0
  116. package/dist/extract/graph_builder.js +61 -0
  117. package/dist/extract/graph_builder.js.map +1 -0
  118. package/dist/extract/node_id.d.ts +24 -0
  119. package/dist/extract/node_id.d.ts.map +1 -0
  120. package/dist/extract/node_id.js +44 -0
  121. package/dist/extract/node_id.js.map +1 -0
  122. package/dist/extract/{project-loader.d.ts → project_loader.d.ts} +1 -1
  123. package/dist/extract/project_loader.d.ts.map +1 -0
  124. package/dist/extract/{project-loader.js → project_loader.js} +1 -1
  125. package/dist/extract/{project-loader.js.map → project_loader.js.map} +1 -1
  126. package/dist/extract/scope_resolver.d.ts +22 -0
  127. package/dist/extract/scope_resolver.d.ts.map +1 -0
  128. package/dist/extract/scope_resolver.js +53 -0
  129. package/dist/extract/scope_resolver.js.map +1 -0
  130. package/dist/extract/semantic_extractor.d.ts +47 -0
  131. package/dist/extract/semantic_extractor.d.ts.map +1 -0
  132. package/dist/extract/{semantic-extractor.js → semantic_extractor.js} +98 -4
  133. package/dist/extract/semantic_extractor.js.map +1 -0
  134. package/dist/extract/{structural-extractor.d.ts → structural_extractor.d.ts} +7 -1
  135. package/dist/extract/{structural-extractor.d.ts.map → structural_extractor.d.ts.map} +1 -1
  136. package/dist/extract/{structural-extractor.js → structural_extractor.js} +24 -14
  137. package/dist/extract/structural_extractor.js.map +1 -0
  138. package/dist/project_root.d.ts +7 -0
  139. package/dist/project_root.d.ts.map +1 -0
  140. package/dist/project_root.js +9 -0
  141. package/dist/project_root.js.map +1 -0
  142. package/dist/query/graph_query.d.ts +262 -0
  143. package/dist/query/graph_query.d.ts.map +1 -0
  144. package/dist/query/graph_query.js +604 -0
  145. package/dist/query/graph_query.js.map +1 -0
  146. package/dist/schema/edge.d.ts +40 -5
  147. package/dist/schema/edge.d.ts.map +1 -1
  148. package/dist/schema/edge.js +70 -0
  149. package/dist/schema/edge.js.map +1 -1
  150. package/dist/schema/node.d.ts +20 -5
  151. package/dist/schema/node.d.ts.map +1 -1
  152. package/dist/schema/node.js +36 -0
  153. package/dist/schema/node.js.map +1 -1
  154. package/dist/schema/runtime_manifest.d.ts +36 -0
  155. package/dist/schema/runtime_manifest.d.ts.map +1 -0
  156. package/dist/schema/runtime_manifest.js +23 -0
  157. package/dist/schema/runtime_manifest.js.map +1 -0
  158. package/dist/store/{jsonl-reader.d.ts → jsonl_reader.d.ts} +1 -1
  159. package/dist/store/{jsonl-reader.d.ts.map → jsonl_reader.d.ts.map} +1 -1
  160. package/dist/store/{jsonl-reader.js → jsonl_reader.js} +1 -1
  161. package/dist/store/{jsonl-reader.js.map → jsonl_reader.js.map} +1 -1
  162. package/dist/store/{jsonl-store.d.ts → jsonl_store.d.ts} +1 -1
  163. package/dist/store/{jsonl-store.d.ts.map → jsonl_store.d.ts.map} +1 -1
  164. package/dist/store/{jsonl-store.js → jsonl_store.js} +1 -1
  165. package/dist/store/{jsonl-store.js.map → jsonl_store.js.map} +1 -1
  166. package/dist/store/kuzu_store.d.ts +66 -0
  167. package/dist/store/kuzu_store.d.ts.map +1 -0
  168. package/dist/store/kuzu_store.js +156 -0
  169. package/dist/store/kuzu_store.js.map +1 -0
  170. package/dist/verify/project_verifier.d.ts +85 -0
  171. package/dist/verify/project_verifier.d.ts.map +1 -0
  172. package/dist/verify/project_verifier.js +138 -0
  173. package/dist/verify/project_verifier.js.map +1 -0
  174. package/dotclaude_folder/skills/code-graph-query/SKILL.md +91 -0
  175. package/package.json +88 -5
  176. package/.env-sample +0 -34
  177. package/dist/agent/agent-tools.d.ts +0 -13
  178. package/dist/agent/agent-tools.d.ts.map +0 -1
  179. package/dist/agent/agent-tools.js +0 -153
  180. package/dist/agent/agent-tools.js.map +0 -1
  181. package/dist/agent/code-editor.d.ts +0 -18
  182. package/dist/agent/code-editor.d.ts.map +0 -1
  183. package/dist/agent/code-editor.js +0 -43
  184. package/dist/agent/code-editor.js.map +0 -1
  185. package/dist/agent/optimizer-agent.d.ts +0 -30
  186. package/dist/agent/optimizer-agent.d.ts.map +0 -1
  187. package/dist/agent/optimizer-agent.js +0 -97
  188. package/dist/agent/optimizer-agent.js.map +0 -1
  189. package/dist/agent/verifier.d.ts +0 -9
  190. package/dist/agent/verifier.d.ts.map +0 -1
  191. package/dist/agent/verifier.js +0 -19
  192. package/dist/agent/verifier.js.map +0 -1
  193. package/dist/extract/graph-builder.d.ts.map +0 -1
  194. package/dist/extract/graph-builder.js +0 -39
  195. package/dist/extract/graph-builder.js.map +0 -1
  196. package/dist/extract/node-id.d.ts +0 -8
  197. package/dist/extract/node-id.d.ts.map +0 -1
  198. package/dist/extract/node-id.js +0 -22
  199. package/dist/extract/node-id.js.map +0 -1
  200. package/dist/extract/project-loader.d.ts.map +0 -1
  201. package/dist/extract/semantic-extractor.d.ts +0 -22
  202. package/dist/extract/semantic-extractor.d.ts.map +0 -1
  203. package/dist/extract/semantic-extractor.js.map +0 -1
  204. package/dist/extract/structural-extractor.js.map +0 -1
  205. package/dist/query/graph-query.d.ts +0 -28
  206. package/dist/query/graph-query.d.ts.map +0 -1
  207. package/dist/query/graph-query.js +0 -93
  208. package/dist/query/graph-query.js.map +0 -1
  209. package/dist/store/kuzu-store.d.ts +0 -14
  210. package/dist/store/kuzu-store.d.ts.map +0 -1
  211. package/dist/store/kuzu-store.js +0 -52
  212. package/dist/store/kuzu-store.js.map +0 -1
@@ -0,0 +1,910 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /** @type {Record<string, string>} */
5
+ const NODE_COLORS = {
6
+ Module: '#4f8cff',
7
+ Class: '#f59e0b',
8
+ Interface: '#a78bfa',
9
+ TypeAlias: '#34d399',
10
+ Enum: '#f472b6',
11
+ Function: '#fb923c',
12
+ Method: '#facc15',
13
+ Property: '#94a3b8',
14
+ Parameter: '#64748b',
15
+ Variable: '#2dd4bf',
16
+ ExternalModule: '#6b7280',
17
+ ConfigFlag: '#84cc16',
18
+ ExternalAPI: '#fb7185',
19
+ Endpoint: '#38bdf8',
20
+ };
21
+
22
+ /** @type {Record<string, string>} */
23
+ const EDGE_COLORS = {
24
+ CONTAINS: '#475569',
25
+ IMPORTS: '#64748b',
26
+ EXPORTS: '#64748b',
27
+ CALLS: '#ef4444',
28
+ INSTANTIATES: '#f97316',
29
+ EXTENDS: '#8b5cf6',
30
+ IMPLEMENTS: '#a78bfa',
31
+ USES_TYPE: '#10b981',
32
+ RETURNS: '#14b8a6',
33
+ PARAM_TYPE: '#06b6d4',
34
+ READS: '#eab308',
35
+ WRITES: '#eab308',
36
+ OVERRIDES: '#94a3b8',
37
+ READS_CONFIG: '#65a30d',
38
+ CALLS_EXTERNAL: '#e11d48',
39
+ HANDLES: '#0ea5e9',
40
+ };
41
+
42
+ /* One-line descriptions per node/edge kind, generated from src/schema into
43
+ data/kind_descriptions.js. Absent (empty) when that file has not been built. */
44
+ const KIND_DESCRIPTIONS = window.KIND_DESCRIPTIONS ?? { nodes: {}, edges: {} };
45
+
46
+ /* Heat ramp for runtime self-time: cool slate → yellow → red ("red = hot"). */
47
+ const HEAT_STOPS = [
48
+ { at: 0, color: [100, 116, 139] },
49
+ { at: 0.5, color: [253, 224, 71] },
50
+ { at: 1, color: [220, 38, 38] },
51
+ ];
52
+
53
+ /* Un-measured nodes render at a neutral baseline, distinct from a cheap-but-measured node. */
54
+ const RUNTIME_UNMEASURED_COLOR = '#243044';
55
+ const RUNTIME_UNMEASURED_BORDER = '#475569';
56
+ const HOTSPOTS_LIMIT = 12;
57
+
58
+ /** @type {AppState} */
59
+ const state = {
60
+ nodes: [],
61
+ edges: [],
62
+ cy: undefined,
63
+ hiddenNodeKinds: new Set(),
64
+ hiddenEdgeKinds: new Set(),
65
+ hideIsolated: false,
66
+ onlyMeasured: false,
67
+ droppedFiles: { nodes: undefined, edges: undefined },
68
+ encoding: 'structural',
69
+ runtime: { maxSelfMs: 0, measuredCount: 0, totalSelfMs: 0 },
70
+ };
71
+
72
+ /**
73
+ * Looks up a required element by id, throwing when it is absent so a missing
74
+ * template node fails loudly here instead of as a later `null` dereference.
75
+ * @param {string} id
76
+ * @returns {HTMLElement}
77
+ */
78
+ const el = (id) => {
79
+ const element = document.getElementById(id);
80
+ if (element === null) {
81
+ throw new Error(`missing element #${id}`);
82
+ }
83
+ return element;
84
+ };
85
+
86
+ /**
87
+ * Looks up a required `<input>` by id, narrowing it so `.checked` / `.value` are typed.
88
+ * @param {string} id
89
+ * @returns {HTMLInputElement}
90
+ */
91
+ const inputEl = (id) => {
92
+ const element = el(id);
93
+ if ((element instanceof HTMLInputElement) === false) {
94
+ throw new Error(`element #${id} is not an input`);
95
+ }
96
+ return element;
97
+ };
98
+
99
+ /**
100
+ * Looks up a required `<select>` by id, narrowing it so `.value` is typed.
101
+ * @param {string} id
102
+ * @returns {HTMLSelectElement}
103
+ */
104
+ const selectEl = (id) => {
105
+ const element = el(id);
106
+ if ((element instanceof HTMLSelectElement) === false) {
107
+ throw new Error(`element #${id} is not a select`);
108
+ }
109
+ return element;
110
+ };
111
+
112
+ /**
113
+ * Narrows a change/input event target to an `<input>` so `.checked` / `.value`
114
+ * can be read inside handlers bound to known controls.
115
+ * @param {EventTarget | null} target
116
+ * @returns {HTMLInputElement}
117
+ */
118
+ const asInput = (target) => {
119
+ if ((target instanceof HTMLInputElement) === false) {
120
+ throw new Error('event target is not an input');
121
+ }
122
+ return target;
123
+ };
124
+
125
+ /* ---------- data loading ---------- */
126
+
127
+ function boot() {
128
+ setupDropzone();
129
+ setupFolds();
130
+ el('hide-isolated').addEventListener('change', (event) => {
131
+ state.hideIsolated = asInput(event.target).checked;
132
+ applyFilters();
133
+ });
134
+ el('relayout').addEventListener('click', () => runLayout());
135
+ el('runtime-heat').addEventListener('change', (event) => {
136
+ state.encoding = asInput(event.target).checked === true ? 'runtime' : 'structural';
137
+ if (state.cy !== undefined) {
138
+ state.cy.style(cyStyle());
139
+ }
140
+ });
141
+ el('only-measured').addEventListener('change', (event) => {
142
+ state.onlyMeasured = asInput(event.target).checked;
143
+ applyFilters();
144
+ });
145
+ el('search').addEventListener('input', () => renderSearchResults());
146
+ el('search').addEventListener('keydown', (event) => {
147
+ if (event.key === 'Enter') {
148
+ const first = /** @type {HTMLElement | null} */ (document.querySelector('#search-results .hit'));
149
+ if (first !== null) {
150
+ first.click();
151
+ }
152
+ }
153
+ });
154
+
155
+ if (window.GRAPH_DATA !== undefined) {
156
+ setData(window.GRAPH_DATA.nodes, window.GRAPH_DATA.edges, 'embedded graph_data.js');
157
+ return;
158
+ }
159
+ if (location.protocol.startsWith('http') === true) {
160
+ tryFetch();
161
+ return;
162
+ }
163
+ el('status').textContent = 'no data — run `npm run build`, or drop the JSONL files here';
164
+ }
165
+
166
+ async function tryFetch() {
167
+ try {
168
+ const [nodesText, edgesText] = await Promise.all([
169
+ fetch('../../../outputs/graph/nodes.jsonl').then((r) => r.ok === true ? r.text() : Promise.reject(new Error(String(r.status)))),
170
+ fetch('../../../outputs/graph/edges.jsonl').then((r) => r.ok === true ? r.text() : Promise.reject(new Error(String(r.status)))),
171
+ ]);
172
+ setData(parseJsonl(nodesText), parseJsonl(edgesText), 'fetched ../../../outputs/graph/*.jsonl');
173
+ } catch {
174
+ el('status').textContent = 'no data — generate data/graph_data.js or drop the JSONL files here';
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Parses newline-delimited JSON into records. The records cross an untyped
180
+ * deserialisation boundary, so callers narrow them via the `setData` signature.
181
+ * @param {string} text
182
+ * @returns {any[]}
183
+ */
184
+ function parseJsonl(text) {
185
+ return text.split('\n').filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
186
+ }
187
+
188
+ function setupDropzone() {
189
+ const zone = el('dropzone');
190
+ window.addEventListener('dragover', (event) => {
191
+ event.preventDefault();
192
+ zone.classList.add('active');
193
+ });
194
+ window.addEventListener('dragleave', (event) => {
195
+ if (event.relatedTarget === null) {
196
+ zone.classList.remove('active');
197
+ }
198
+ });
199
+ window.addEventListener('drop', async (event) => {
200
+ event.preventDefault();
201
+ zone.classList.remove('active');
202
+ if (event.dataTransfer === null) {
203
+ return;
204
+ }
205
+ for (const file of event.dataTransfer.files) {
206
+ const records = parseJsonl(await file.text());
207
+ if (records.length === 0) {
208
+ continue;
209
+ }
210
+ if (records[0].from !== undefined && records[0].to !== undefined) {
211
+ state.droppedFiles.edges = records;
212
+ } else {
213
+ state.droppedFiles.nodes = records;
214
+ }
215
+ }
216
+ if (state.droppedFiles.nodes !== undefined && state.droppedFiles.edges !== undefined) {
217
+ setData(state.droppedFiles.nodes, state.droppedFiles.edges, 'dropped files');
218
+ } else {
219
+ el('status').textContent = 'got one file — drop the other one too';
220
+ }
221
+ });
222
+ }
223
+
224
+ /* ---------- foldable sections ---------- */
225
+
226
+ const FOLD_STORAGE_KEY = 'ktg.sidebar.folds';
227
+
228
+ /**
229
+ * Reads the persisted collapsed-by-key map, tolerating absent or malformed storage.
230
+ * @returns {Record<string, boolean>}
231
+ */
232
+ function loadFolds() {
233
+ try {
234
+ const raw = localStorage.getItem(FOLD_STORAGE_KEY);
235
+ const parsed = raw === null ? {} : JSON.parse(raw);
236
+ return parsed !== null && typeof parsed === 'object' ? parsed : {};
237
+ } catch {
238
+ return {};
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Persists the collapsed-by-key map; a no-op when storage is unavailable (private mode, file://).
244
+ * @param {Record<string, boolean>} folds
245
+ */
246
+ function saveFolds(folds) {
247
+ try {
248
+ localStorage.setItem(FOLD_STORAGE_KEY, JSON.stringify(folds));
249
+ } catch {
250
+ return;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Wires every `.foldable` sidebar header to collapse the elements that follow it
256
+ * (handled in CSS via `.collapsed ~ *`), restoring and persisting the per-section
257
+ * state in localStorage so folds survive reloads.
258
+ */
259
+ function setupFolds() {
260
+ const folds = loadFolds();
261
+ for (const rawHeader of document.querySelectorAll('#sidebar .foldable')) {
262
+ const header = /** @type {HTMLElement} */ (rawHeader);
263
+ const key = header.dataset.fold;
264
+ if (key === undefined) {
265
+ continue;
266
+ }
267
+ header.classList.toggle('collapsed', folds[key] === true);
268
+ header.addEventListener('click', () => {
269
+ folds[key] = header.classList.toggle('collapsed');
270
+ saveFolds(folds);
271
+ });
272
+ }
273
+ }
274
+
275
+ /* ---------- graph construction ---------- */
276
+
277
+ /**
278
+ * @param {RawNode[]} nodes
279
+ * @param {RawEdge[]} edges
280
+ * @param {string} sourceLabel
281
+ */
282
+ function setData(nodes, edges, sourceLabel) {
283
+ state.nodes = nodes;
284
+ state.edges = edges;
285
+
286
+ const nodeIds = new Set(nodes.map((node) => node.id));
287
+ /** @type {Map<string, number>} */
288
+ const degree = new Map();
289
+ for (const edge of edges) {
290
+ degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1);
291
+ degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1);
292
+ }
293
+
294
+ let maxSelfMs = 0;
295
+ let measuredCount = 0;
296
+ let totalSelfMs = 0;
297
+ for (const node of nodes) {
298
+ const runtime = nodeRuntime(node);
299
+ if (runtime === undefined) {
300
+ continue;
301
+ }
302
+ const selfMs = runtime.selfMs ?? 0;
303
+ measuredCount += 1;
304
+ totalSelfMs += selfMs;
305
+ maxSelfMs = Math.max(maxSelfMs, selfMs);
306
+ }
307
+ state.runtime = { maxSelfMs, measuredCount, totalSelfMs };
308
+
309
+ const elements = [
310
+ ...nodes.map((node) => ({
311
+ group: 'nodes',
312
+ data: { id: node.id, name: node.name, kind: node.kind, filePath: node.filePath, startLine: node.range === undefined ? 0 : node.range.startLine, exported: node.exported === true, degree: degree.get(node.id) ?? 0, runtime: nodeRuntime(node) },
313
+ })),
314
+ ...edges
315
+ .filter((edge) => nodeIds.has(edge.from) === true && nodeIds.has(edge.to) === true)
316
+ .map((edge) => ({
317
+ group: 'edges',
318
+ data: { id: edge.id, source: edge.from, target: edge.to, kind: edge.kind, count: edgeCount(edge) },
319
+ })),
320
+ ];
321
+
322
+ if (state.cy !== undefined) {
323
+ state.cy.destroy();
324
+ }
325
+ state.cy = cytoscape({
326
+ container: el('cy'),
327
+ elements,
328
+ style: cyStyle(),
329
+ layout: { name: 'cose', animate: false, padding: 30 },
330
+ });
331
+ state.cy.on('tap', 'node', (event) => select(event.target));
332
+ state.cy.on('tap', (event) => {
333
+ if (event.target === state.cy) {
334
+ clearSelection();
335
+ }
336
+ });
337
+
338
+ buildLegends();
339
+ renderRuntime();
340
+ applyFilters();
341
+ el('status').textContent = `${sourceLabel} — ${nodes.length} nodes, ${edges.length} edges`;
342
+ }
343
+
344
+ function cyStyle() {
345
+ /** @param {CyCollection} node */
346
+ const nodeColor = (node) => {
347
+ if (state.encoding !== 'runtime') {
348
+ return NODE_COLORS[node.data('kind')] ?? '#9ca3af';
349
+ }
350
+ const runtime = node.data('runtime');
351
+ if (runtime === undefined || runtime === null) {
352
+ return RUNTIME_UNMEASURED_COLOR;
353
+ }
354
+ return heatColor(runtimeFraction(runtime.selfMs));
355
+ };
356
+ /** @param {CyCollection} node */
357
+ const nodeSize = (node) => {
358
+ if (state.encoding !== 'runtime') {
359
+ return 8 + Math.sqrt(node.data('degree')) * 4;
360
+ }
361
+ const runtime = node.data('runtime');
362
+ if (runtime === undefined || runtime === null) {
363
+ return 10;
364
+ }
365
+ return 12 + runtimeFraction(runtime.selfMs) * 40;
366
+ };
367
+ /** @param {CyCollection} node */
368
+ const isUnmeasured = (node) => node.data('runtime') === undefined || node.data('runtime') === null;
369
+ return [
370
+ {
371
+ selector: 'node',
372
+ style: {
373
+ 'background-color': nodeColor,
374
+ 'width': nodeSize,
375
+ 'height': nodeSize,
376
+ 'border-width': (/** @type {CyCollection} */ node) => state.encoding === 'runtime' && isUnmeasured(node) === true ? 1 : 0,
377
+ 'border-color': RUNTIME_UNMEASURED_BORDER,
378
+ 'border-style': 'dashed',
379
+ 'label': 'data(name)',
380
+ 'color': '#cbd5e1',
381
+ 'font-size': 8,
382
+ 'min-zoomed-font-size': 7,
383
+ 'text-valign': 'bottom',
384
+ 'text-margin-y': 3,
385
+ },
386
+ },
387
+ {
388
+ selector: 'edge',
389
+ style: {
390
+ 'width': (/** @type {CyCollection} */ edge) => edgeWidth(edge.data('count')),
391
+ 'line-color': (/** @type {CyCollection} */ edge) => EDGE_COLORS[edge.data('kind')] ?? '#475569',
392
+ 'target-arrow-color': (/** @type {CyCollection} */ edge) => EDGE_COLORS[edge.data('kind')] ?? '#475569',
393
+ 'target-arrow-shape': 'triangle',
394
+ 'arrow-scale': 0.6,
395
+ 'curve-style': 'bezier',
396
+ 'opacity': 0.65,
397
+ },
398
+ },
399
+ { selector: '.hidden', style: { display: 'none' } },
400
+ { selector: '.faded', style: { opacity: 0.08, 'text-opacity': 0 } },
401
+ { selector: 'node.sel', style: { 'border-width': 3, 'border-color': '#ffffff', 'border-style': 'solid' } },
402
+ ];
403
+ }
404
+
405
+ function runLayout() {
406
+ const cy = state.cy;
407
+ if (cy === undefined) {
408
+ return;
409
+ }
410
+ const name = selectEl('layout-select').value;
411
+ const options = name === 'concentric'
412
+ ? { name, concentric: (/** @type {CyCollection} */ node) => node.degree(), levelWidth: () => 2, animate: false, padding: 30 }
413
+ : { name, animate: false, padding: 30 };
414
+ cy.elements(':visible').layout(options).run();
415
+ }
416
+
417
+ /* ---------- edge weighting ---------- */
418
+
419
+ /**
420
+ * Reads the call-site multiplicity off a raw edge's metadata; defaults to 1 when absent.
421
+ * @param {RawEdge} edge
422
+ * @returns {number}
423
+ */
424
+ function edgeCount(edge) {
425
+ if (edge.metadata === undefined || edge.metadata === null) {
426
+ return 1;
427
+ }
428
+ const count = edge.metadata.count;
429
+ return typeof count === 'number' && count > 0 ? count : 1;
430
+ }
431
+
432
+ /**
433
+ * Maps a call-site count to a stroke width: count 1 keeps the baseline, higher counts thicken sub-linearly.
434
+ * @param {number} count
435
+ * @returns {number}
436
+ */
437
+ function edgeWidth(count) {
438
+ const value = typeof count === 'number' && count > 0 ? count : 1;
439
+ return 1 + Math.sqrt(value - 1) * 1.8;
440
+ }
441
+
442
+ /* ---------- legends & filtering ---------- */
443
+
444
+ function buildLegends() {
445
+ const nodeCounts = countBy(state.nodes.map((node) => node.kind));
446
+ const edgeCounts = countBy(state.edges.map((edge) => edge.kind));
447
+ renderLegend(el('node-kinds'), nodeCounts, NODE_COLORS, state.hiddenNodeKinds, KIND_DESCRIPTIONS.nodes);
448
+ renderLegend(el('edge-kinds'), edgeCounts, EDGE_COLORS, state.hiddenEdgeKinds, KIND_DESCRIPTIONS.edges);
449
+ }
450
+
451
+ /**
452
+ * @param {HTMLElement} container
453
+ * @param {[string, number][]} counts
454
+ * @param {Record<string, string>} colors
455
+ * @param {Set<string>} hiddenSet
456
+ * @param {Record<string, string>} descriptions
457
+ */
458
+ function renderLegend(container, counts, colors, hiddenSet, descriptions) {
459
+ container.innerHTML = '';
460
+ const kinds = counts.map(([kind]) => kind);
461
+ /** @type {HTMLInputElement[]} */
462
+ const childCheckboxes = [];
463
+
464
+ /* Master toggle: checked when every kind is visible, indeterminate on a mixed
465
+ selection. Clicking it reveals all kinds, or hides all when none are hidden. */
466
+ const master = document.createElement('input');
467
+ master.type = 'checkbox';
468
+ const syncMaster = () => {
469
+ const hiddenCount = kinds.filter((kind) => hiddenSet.has(kind) === true).length;
470
+ master.checked = hiddenCount === 0;
471
+ master.indeterminate = hiddenCount > 0 && hiddenCount < kinds.length;
472
+ };
473
+
474
+ if (kinds.length > 0) {
475
+ master.addEventListener('change', () => {
476
+ const allVisible = kinds.every((kind) => hiddenSet.has(kind) === false);
477
+ for (const kind of kinds) {
478
+ if (allVisible === true) {
479
+ hiddenSet.add(kind);
480
+ } else {
481
+ hiddenSet.delete(kind);
482
+ }
483
+ }
484
+ for (const child of childCheckboxes) {
485
+ child.checked = hiddenSet.has(child.dataset.kind ?? '') === false;
486
+ }
487
+ syncMaster();
488
+ applyFilters();
489
+ });
490
+ const masterLabel = document.createElement('label');
491
+ masterLabel.className = 'master';
492
+ masterLabel.title = 'show or hide every kind';
493
+ const spacer = document.createElement('span');
494
+ spacer.className = 'swatch spacer';
495
+ const text = document.createElement('span');
496
+ text.textContent = 'all';
497
+ masterLabel.append(master, spacer, text);
498
+ container.appendChild(masterLabel);
499
+ }
500
+
501
+ for (const [kind, count] of counts) {
502
+ const label = document.createElement('label');
503
+ const checkbox = document.createElement('input');
504
+ checkbox.type = 'checkbox';
505
+ checkbox.dataset.kind = kind;
506
+ checkbox.checked = hiddenSet.has(kind) === false;
507
+ checkbox.addEventListener('change', () => {
508
+ if (checkbox.checked === true) {
509
+ hiddenSet.delete(kind);
510
+ } else {
511
+ hiddenSet.add(kind);
512
+ }
513
+ syncMaster();
514
+ applyFilters();
515
+ });
516
+ childCheckboxes.push(checkbox);
517
+ const swatch = document.createElement('span');
518
+ swatch.className = 'swatch';
519
+ swatch.style.background = colors[kind] ?? '#9ca3af';
520
+ const text = document.createElement('span');
521
+ text.textContent = kind;
522
+ const countSpan = document.createElement('span');
523
+ countSpan.className = 'count';
524
+ countSpan.textContent = String(count);
525
+ label.append(checkbox, swatch, text);
526
+ const description = descriptions?.[kind];
527
+ if (typeof description === 'string' && description.length > 0) {
528
+ label.append(makeHelpBadge(kind, description));
529
+ }
530
+ label.append(countSpan);
531
+ container.appendChild(label);
532
+ }
533
+
534
+ syncMaster();
535
+ }
536
+
537
+ /**
538
+ * Builds the `?` help badge shown after a legend kind. Clicks are swallowed so
539
+ * the badge never toggles the surrounding filter checkbox; hover and keyboard
540
+ * focus reveal the shared tooltip with the kind's description.
541
+ * @param {string} kind
542
+ * @param {string} description
543
+ * @returns {HTMLSpanElement}
544
+ */
545
+ function makeHelpBadge(kind, description) {
546
+ const badge = document.createElement('span');
547
+ badge.className = 'help-badge';
548
+ badge.textContent = '?';
549
+ badge.tabIndex = 0;
550
+ badge.setAttribute('role', 'img');
551
+ badge.setAttribute('aria-label', `${kind}: ${description}`);
552
+ badge.addEventListener('click', (event) => {
553
+ event.preventDefault();
554
+ event.stopPropagation();
555
+ });
556
+ badge.addEventListener('mouseenter', () => showTooltip(badge, description));
557
+ badge.addEventListener('mouseleave', hideTooltip);
558
+ badge.addEventListener('focus', () => showTooltip(badge, description));
559
+ badge.addEventListener('blur', hideTooltip);
560
+ return badge;
561
+ }
562
+
563
+ /* ---------- hover tooltips ---------- */
564
+
565
+ /** @type {HTMLElement | undefined} */
566
+ let tooltipEl;
567
+
568
+ /** Lazily creates the single shared tooltip element, appended to <body> so the sidebar's overflow cannot clip it. */
569
+ function ensureTooltip() {
570
+ if (tooltipEl === undefined) {
571
+ tooltipEl = document.createElement('div');
572
+ tooltipEl.className = 'kind-tooltip';
573
+ tooltipEl.hidden = true;
574
+ document.body.appendChild(tooltipEl);
575
+ }
576
+ return tooltipEl;
577
+ }
578
+
579
+ /**
580
+ * Shows the shared tooltip just below an anchor, flipping above / clamping horizontally to stay within the viewport.
581
+ * @param {HTMLElement} anchor
582
+ * @param {string} text
583
+ */
584
+ function showTooltip(anchor, text) {
585
+ const tip = ensureTooltip();
586
+ tip.textContent = text;
587
+ tip.hidden = false;
588
+ const rect = anchor.getBoundingClientRect();
589
+ const margin = 8;
590
+ let top = rect.bottom + 6;
591
+ if (top + tip.offsetHeight > window.innerHeight - margin) {
592
+ top = Math.max(margin, rect.top - tip.offsetHeight - 6);
593
+ }
594
+ const left = Math.max(margin, Math.min(rect.left, window.innerWidth - tip.offsetWidth - margin));
595
+ tip.style.top = `${top}px`;
596
+ tip.style.left = `${left}px`;
597
+ }
598
+
599
+ function hideTooltip() {
600
+ if (tooltipEl !== undefined) {
601
+ tooltipEl.hidden = true;
602
+ }
603
+ }
604
+
605
+ function applyFilters() {
606
+ const cy = state.cy;
607
+ if (cy === undefined) {
608
+ return;
609
+ }
610
+ cy.batch(() => {
611
+ cy.nodes().forEach((node) => {
612
+ const hiddenByKind = state.hiddenNodeKinds.has(node.data('kind')) === true;
613
+ const unmeasured = node.data('runtime') === undefined || node.data('runtime') === null;
614
+ const hiddenByMeasure = state.onlyMeasured === true && unmeasured === true;
615
+ node.toggleClass('hidden', hiddenByKind === true || hiddenByMeasure === true);
616
+ });
617
+ cy.edges().forEach((edge) => {
618
+ edge.toggleClass('hidden', state.hiddenEdgeKinds.has(edge.data('kind')) === true);
619
+ });
620
+ if (state.hideIsolated === true) {
621
+ cy.nodes().not('.hidden').forEach((node) => {
622
+ const hasVisibleEdge = node.connectedEdges().some((edge) =>
623
+ edge.hasClass('hidden') === false
624
+ && edge.source().hasClass('hidden') === false
625
+ && edge.target().hasClass('hidden') === false);
626
+ if (hasVisibleEdge === false) {
627
+ node.addClass('hidden');
628
+ }
629
+ });
630
+ }
631
+ });
632
+ }
633
+
634
+ /**
635
+ * @param {string[]} values
636
+ * @returns {[string, number][]}
637
+ */
638
+ function countBy(values) {
639
+ /** @type {Map<string, number>} */
640
+ const counts = new Map();
641
+ for (const value of values) {
642
+ counts.set(value, (counts.get(value) ?? 0) + 1);
643
+ }
644
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
645
+ }
646
+
647
+ /* ---------- runtime ---------- */
648
+
649
+ /**
650
+ * Reads the `metadata.runtime` metrics off a raw node, or `undefined` if un-measured.
651
+ * @param {RawNode} node
652
+ * @returns {NodeRuntime | undefined}
653
+ */
654
+ function nodeRuntime(node) {
655
+ if (node.metadata === undefined || node.metadata === null) {
656
+ return undefined;
657
+ }
658
+ const runtime = node.metadata.runtime;
659
+ return runtime === undefined || runtime === null ? undefined : runtime;
660
+ }
661
+
662
+ /**
663
+ * Maps a self-time to [0, 1] on a square-root scale so mid-range hotspots stay visible.
664
+ * @param {number | undefined} selfMs
665
+ * @returns {number}
666
+ */
667
+ function runtimeFraction(selfMs) {
668
+ const max = state.runtime.maxSelfMs;
669
+ if (max <= 0) {
670
+ return 0;
671
+ }
672
+ return Math.sqrt(Math.max(0, selfMs ?? 0) / max);
673
+ }
674
+
675
+ /**
676
+ * Interpolates the heat ramp at the given fraction, returning an `rgb(...)` string.
677
+ * @param {number} fraction
678
+ * @returns {string}
679
+ */
680
+ function heatColor(fraction) {
681
+ const f = Math.min(1, Math.max(0, fraction));
682
+ let lo = HEAT_STOPS[0];
683
+ let hi = HEAT_STOPS[HEAT_STOPS.length - 1];
684
+ for (let i = 0; i < HEAT_STOPS.length - 1; i += 1) {
685
+ if (f >= HEAT_STOPS[i].at && f <= HEAT_STOPS[i + 1].at) {
686
+ lo = HEAT_STOPS[i];
687
+ hi = HEAT_STOPS[i + 1];
688
+ break;
689
+ }
690
+ }
691
+ const span = hi.at - lo.at || 1;
692
+ const t = (f - lo.at) / span;
693
+ /** @param {number} index */
694
+ const channel = (index) => Math.round(lo.color[index] + (hi.color[index] - lo.color[index]) * t);
695
+ return `rgb(${channel(0)}, ${channel(1)}, ${channel(2)})`;
696
+ }
697
+
698
+ /**
699
+ * Human-readable self-time: seconds above 1 s, otherwise milliseconds.
700
+ * @param {number} ms
701
+ * @returns {string}
702
+ */
703
+ function formatMs(ms) {
704
+ if (ms >= 1000) {
705
+ return `${(ms / 1000).toFixed(1)} s`;
706
+ }
707
+ if (ms >= 1) {
708
+ return `${ms.toFixed(0)} ms`;
709
+ }
710
+ return `${ms.toFixed(2)} ms`;
711
+ }
712
+
713
+ /**
714
+ * Centers and selects a node by id — shared by the hotspots list and search results.
715
+ * @param {string} id
716
+ */
717
+ function focusNode(id) {
718
+ const cy = state.cy;
719
+ if (cy === undefined) {
720
+ return;
721
+ }
722
+ const node = cy.getElementById(id);
723
+ if (node.length === 1) {
724
+ select(node);
725
+ cy.animate({ center: { eles: node }, zoom: 2 }, { duration: 350 });
726
+ }
727
+ }
728
+
729
+ /** Renders the coverage line and the ranked hotspots list from the loaded runtime metrics. */
730
+ function renderRuntime() {
731
+ const section = el('runtime');
732
+ const toggle = inputEl('runtime-heat');
733
+ const measured = state.nodes
734
+ .map((node) => ({ node, runtime: nodeRuntime(node) }))
735
+ .filter((entry) => entry.runtime !== undefined)
736
+ .sort((a, b) => (b.runtime?.selfMs ?? 0) - (a.runtime?.selfMs ?? 0));
737
+
738
+ if (measured.length === 0) {
739
+ section.classList.add('empty');
740
+ el('coverage').textContent = 'no runtime data — run `enrich` to measure self-time';
741
+ toggle.checked = false;
742
+ toggle.disabled = true;
743
+ state.encoding = 'structural';
744
+ state.onlyMeasured = false;
745
+ inputEl('only-measured').checked = false;
746
+ el('hotspots').innerHTML = '';
747
+ if (state.cy !== undefined) {
748
+ state.cy.style(cyStyle());
749
+ }
750
+ return;
751
+ }
752
+
753
+ section.classList.remove('empty');
754
+ toggle.disabled = false;
755
+ inputEl('only-measured').disabled = false;
756
+ el('coverage').textContent = `${state.runtime.measuredCount} / ${state.nodes.length} nodes measured · ${formatMs(state.runtime.totalSelfMs)} total self-time`;
757
+
758
+ const list = el('hotspots');
759
+ list.innerHTML = '';
760
+ for (const { node, runtime } of measured.slice(0, HOTSPOTS_LIMIT)) {
761
+ const row = document.createElement('div');
762
+ row.className = 'hotspot';
763
+ row.innerHTML = `<span class="heat-swatch" style="background:${heatColor(runtimeFraction(runtime?.selfMs))}"></span><span class="hotspot-name">${escapeHtml(node.name)}</span><span class="hotspot-ms">${escapeHtml(formatMs(runtime?.selfMs ?? 0))}</span>`;
764
+ row.addEventListener('click', () => focusNode(node.id));
765
+ list.appendChild(row);
766
+ }
767
+ }
768
+
769
+ /* ---------- search ---------- */
770
+
771
+ function renderSearchResults() {
772
+ const query = inputEl('search').value.trim().toLowerCase();
773
+ const container = el('search-results');
774
+ container.innerHTML = '';
775
+ if (query.length < 2) {
776
+ return;
777
+ }
778
+ const hits = state.nodes
779
+ .filter((node) => node.name.toLowerCase().includes(query) === true || node.filePath.toLowerCase().includes(query) === true)
780
+ .slice(0, 15);
781
+ for (const hit of hits) {
782
+ const row = document.createElement('div');
783
+ row.className = 'hit';
784
+ row.innerHTML = `${escapeHtml(hit.name)} <span class="loc">${escapeHtml(hit.kind)} · ${escapeHtml(hit.filePath)}</span>`;
785
+ row.addEventListener('click', () => focusNode(hit.id));
786
+ container.appendChild(row);
787
+ }
788
+ }
789
+
790
+ /* ---------- selection & details ---------- */
791
+
792
+ /** @param {CyCollection} node */
793
+ function select(node) {
794
+ const cy = state.cy;
795
+ if (cy === undefined) {
796
+ return;
797
+ }
798
+ cy.elements().addClass('faded').removeClass('sel');
799
+ const hood = node.closedNeighborhood();
800
+ hood.removeClass('faded');
801
+ node.addClass('sel');
802
+ renderDetails(node);
803
+ }
804
+
805
+ function clearSelection() {
806
+ if (state.cy !== undefined) {
807
+ state.cy.elements().removeClass('faded sel');
808
+ }
809
+ el('details-body').textContent = 'click a node';
810
+ }
811
+
812
+ /* Real source files we can link to GitHub; external modules, `process.env`, and API hosts carry synthetic paths. */
813
+ const SOURCE_FILE_PATTERN = /\.(?:tsx?|mts|cts|jsx?|mjs|cjs)$/;
814
+
815
+ /**
816
+ * Builds a GitHub permalink for a node's file at the analysed commit, or
817
+ * `undefined` when no source was configured (server-side `--source`) or the path
818
+ * is not a real source file. Line anchors are added only when a start line is known.
819
+ * @param {unknown} filePath
820
+ * @param {number} startLine
821
+ * @returns {string | undefined}
822
+ */
823
+ function githubFileUrl(filePath, startLine) {
824
+ const source = window.GRAPH_SOURCE;
825
+ if (source === undefined || source === null || source.github === undefined) {
826
+ return undefined;
827
+ }
828
+ if (typeof filePath !== 'string' || SOURCE_FILE_PATTERN.test(filePath) === false) {
829
+ return undefined;
830
+ }
831
+ const { baseUrl, commit, prefix } = source.github;
832
+ const encoded = `${prefix ?? ''}${filePath}`.split('/').map((segment) => encodeURIComponent(segment)).join('/');
833
+ const anchor = startLine > 0 ? `#L${startLine}` : '';
834
+ return `${baseUrl}/blob/${commit}/${encoded}${anchor}`;
835
+ }
836
+
837
+ /** @param {CyCollection} node */
838
+ function renderDetails(node) {
839
+ const id = node.id();
840
+ const color = NODE_COLORS[node.data('kind')] ?? '#9ca3af';
841
+ const outgoing = state.edges.filter((edge) => edge.from === id);
842
+ const incoming = state.edges.filter((edge) => edge.to === id);
843
+ const nodeById = new Map(state.nodes.map((entry) => /** @type {[string, RawNode]} */ ([entry.id, entry])));
844
+
845
+ /**
846
+ * @param {RawEdge[]} edges
847
+ * @param {'out' | 'in'} direction
848
+ */
849
+ const renderEdgeRows = (edges, direction) => edges.map((edge) => {
850
+ const otherId = direction === 'out' ? edge.to : edge.from;
851
+ const other = nodeById.get(otherId);
852
+ const name = other === undefined ? otherId : other.name;
853
+ const arrow = direction === 'out' ? '→' : '←';
854
+ const count = edgeCount(edge);
855
+ const countBadge = count > 1 ? ` <span class="edge-count">×${count}</span>` : '';
856
+ return `<div class="edge-row"><span class="edge-kind">${escapeHtml(edge.kind)}</span>${countBadge} ${arrow} <a data-target="${escapeHtml(otherId)}">${escapeHtml(name)}</a></div>`;
857
+ }).join('');
858
+
859
+ const runtime = node.data('runtime');
860
+ const runtimeBlock = runtime === undefined || runtime === null ? '' : `
861
+ <div class="runtime-block">
862
+ <h3>runtime</h3>
863
+ <div class="metric"><span>self-time</span><strong>${escapeHtml(formatMs(runtime.selfMs ?? 0))}</strong></div>
864
+ <div class="metric"><span>samples</span><strong>${escapeHtml(String(runtime.samples ?? 0))}</strong></div>
865
+ <div class="metric"><span>source</span><strong>${escapeHtml(String(runtime.source ?? '—'))}</strong></div>
866
+ </div>`;
867
+
868
+ const filePath = node.data('filePath');
869
+ const startLine = node.data('startLine');
870
+ const locationText = `${filePath}${startLine > 0 ? ':' + startLine : ''}`;
871
+ const fileUrl = githubFileUrl(filePath, startLine);
872
+ const locationHtml = fileUrl === undefined
873
+ ? escapeHtml(locationText)
874
+ : `<a class="file-link" href="${escapeHtml(fileUrl)}" target="_blank" rel="noopener noreferrer" title="open on GitHub">${escapeHtml(locationText)}</a>`;
875
+
876
+ el('details-body').innerHTML = `
877
+ <div><span class="kind-tag" style="background:${color}">${escapeHtml(node.data('kind'))}</span> <strong>${escapeHtml(node.data('name'))}</strong></div>
878
+ <div>${locationHtml}</div>
879
+ <div class="id">${escapeHtml(id)}</div>
880
+ ${runtimeBlock}
881
+ <h3>outgoing (${outgoing.length})</h3>${renderEdgeRows(outgoing, 'out')}
882
+ <h3>incoming (${incoming.length})</h3>${renderEdgeRows(incoming, 'in')}
883
+ `;
884
+ el('details-body').querySelectorAll('a[data-target]').forEach((link) => {
885
+ link.addEventListener('click', () => {
886
+ const cy = state.cy;
887
+ if (cy === undefined) {
888
+ return;
889
+ }
890
+ const anchor = /** @type {HTMLElement} */ (link);
891
+ const target = cy.getElementById(anchor.dataset.target ?? '');
892
+ if (target.length === 1) {
893
+ select(target);
894
+ cy.animate({ center: { eles: target } }, { duration: 300 });
895
+ }
896
+ });
897
+ });
898
+ }
899
+
900
+ const ESCAPE_REPLACEMENTS = /** @type {Record<string, string>} */ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' });
901
+
902
+ /**
903
+ * @param {unknown} value
904
+ * @returns {string}
905
+ */
906
+ function escapeHtml(value) {
907
+ return String(value).replace(/[&<>"']/g, (char) => ESCAPE_REPLACEMENTS[char]);
908
+ }
909
+
910
+ boot();