ts-knowledge-graph 0.1.2 → 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 (255) hide show
  1. package/README.md +74 -34
  2. package/contribs/web_visualisation/README.md +28 -0
  3. package/contribs/web_visualisation/web/css/style.css +104 -0
  4. package/contribs/web_visualisation/web/data/.gitignore +1 -0
  5. package/contribs/web_visualisation/web/data/kind_descriptions.js +38 -0
  6. package/contribs/web_visualisation/web/index.html +20 -4
  7. package/contribs/web_visualisation/web/js/app.js +581 -35
  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.map +1 -1
  19. package/dist/cli.js +10 -2
  20. package/dist/cli.js.map +1 -1
  21. package/dist/commands/benchmark_command.d.ts +11 -0
  22. package/dist/commands/benchmark_command.d.ts.map +1 -0
  23. package/dist/commands/benchmark_command.js +91 -0
  24. package/dist/commands/benchmark_command.js.map +1 -0
  25. package/dist/commands/blast_radius_command.js +1 -1
  26. package/dist/commands/blast_radius_command.js.map +1 -1
  27. package/dist/commands/cost_command.d.ts +13 -0
  28. package/dist/commands/cost_command.d.ts.map +1 -0
  29. package/dist/commands/cost_command.js +122 -0
  30. package/dist/commands/cost_command.js.map +1 -0
  31. package/dist/commands/{load.d.ts → enrich_command.d.ts} +3 -2
  32. package/dist/commands/enrich_command.d.ts.map +1 -0
  33. package/dist/commands/enrich_command.js +62 -0
  34. package/dist/commands/enrich_command.js.map +1 -0
  35. package/dist/commands/hotspots_command.d.ts +7 -0
  36. package/dist/commands/hotspots_command.d.ts.map +1 -0
  37. package/dist/commands/hotspots_command.js +67 -0
  38. package/dist/commands/hotspots_command.js.map +1 -0
  39. package/dist/commands/install_command.d.ts +2 -3
  40. package/dist/commands/install_command.d.ts.map +1 -1
  41. package/dist/commands/install_command.js +7 -8
  42. package/dist/commands/install_command.js.map +1 -1
  43. package/dist/commands/load_command.d.ts.map +1 -1
  44. package/dist/commands/load_command.js +2 -0
  45. package/dist/commands/load_command.js.map +1 -1
  46. package/dist/commands/verify_command.d.ts +8 -0
  47. package/dist/commands/verify_command.d.ts.map +1 -0
  48. package/dist/commands/verify_command.js +57 -0
  49. package/dist/commands/verify_command.js.map +1 -0
  50. package/dist/commands/web_command.d.ts +27 -0
  51. package/dist/commands/web_command.d.ts.map +1 -1
  52. package/dist/commands/web_command.js +109 -3
  53. package/dist/commands/web_command.js.map +1 -1
  54. package/dist/enrich/cpu_profile.d.ts +127 -0
  55. package/dist/enrich/cpu_profile.d.ts.map +1 -0
  56. package/dist/enrich/cpu_profile.js +97 -0
  57. package/dist/enrich/cpu_profile.js.map +1 -0
  58. package/dist/enrich/runtime_enricher.d.ts +56 -0
  59. package/dist/enrich/runtime_enricher.d.ts.map +1 -0
  60. package/dist/enrich/runtime_enricher.js +80 -0
  61. package/dist/enrich/runtime_enricher.js.map +1 -0
  62. package/dist/enrich/runtime_join.d.ts +100 -0
  63. package/dist/enrich/runtime_join.d.ts.map +1 -0
  64. package/dist/enrich/runtime_join.js +227 -0
  65. package/dist/enrich/runtime_join.js.map +1 -0
  66. package/dist/extract/api_extractor.d.ts +24 -0
  67. package/dist/extract/api_extractor.d.ts.map +1 -0
  68. package/dist/extract/api_extractor.js +71 -0
  69. package/dist/extract/api_extractor.js.map +1 -0
  70. package/dist/extract/config_extractor.d.ts +22 -0
  71. package/dist/extract/config_extractor.d.ts.map +1 -0
  72. package/dist/extract/config_extractor.js +61 -0
  73. package/dist/extract/config_extractor.js.map +1 -0
  74. package/dist/extract/endpoint_extractor.d.ts +36 -0
  75. package/dist/extract/endpoint_extractor.d.ts.map +1 -0
  76. package/dist/extract/endpoint_extractor.js +117 -0
  77. package/dist/extract/endpoint_extractor.js.map +1 -0
  78. package/dist/extract/graph_builder.d.ts +8 -0
  79. package/dist/extract/graph_builder.d.ts.map +1 -1
  80. package/dist/extract/graph_builder.js +23 -1
  81. package/dist/extract/graph_builder.js.map +1 -1
  82. package/dist/extract/node_id.d.ts +16 -0
  83. package/dist/extract/node_id.d.ts.map +1 -1
  84. package/dist/extract/node_id.js +22 -0
  85. package/dist/extract/node_id.js.map +1 -1
  86. package/dist/extract/scope_resolver.d.ts +22 -0
  87. package/dist/extract/scope_resolver.d.ts.map +1 -0
  88. package/dist/extract/scope_resolver.js +53 -0
  89. package/dist/extract/scope_resolver.js.map +1 -0
  90. package/dist/extract/semantic_extractor.d.ts +25 -0
  91. package/dist/extract/semantic_extractor.d.ts.map +1 -1
  92. package/dist/extract/semantic_extractor.js +96 -2
  93. package/dist/extract/semantic_extractor.js.map +1 -1
  94. package/dist/extract/structural_extractor.d.ts +6 -0
  95. package/dist/extract/structural_extractor.d.ts.map +1 -1
  96. package/dist/extract/structural_extractor.js +22 -12
  97. package/dist/extract/structural_extractor.js.map +1 -1
  98. package/dist/project_root.d.ts +7 -0
  99. package/dist/project_root.d.ts.map +1 -0
  100. package/dist/project_root.js +9 -0
  101. package/dist/project_root.js.map +1 -0
  102. package/dist/query/graph_query.d.ts +234 -0
  103. package/dist/query/graph_query.d.ts.map +1 -1
  104. package/dist/query/graph_query.js +522 -11
  105. package/dist/query/graph_query.js.map +1 -1
  106. package/dist/schema/edge.d.ts +40 -5
  107. package/dist/schema/edge.d.ts.map +1 -1
  108. package/dist/schema/edge.js +70 -0
  109. package/dist/schema/edge.js.map +1 -1
  110. package/dist/schema/node.d.ts +20 -5
  111. package/dist/schema/node.d.ts.map +1 -1
  112. package/dist/schema/node.js +36 -0
  113. package/dist/schema/node.js.map +1 -1
  114. package/dist/schema/runtime_manifest.d.ts +36 -0
  115. package/dist/schema/runtime_manifest.d.ts.map +1 -0
  116. package/dist/schema/runtime_manifest.js +23 -0
  117. package/dist/schema/runtime_manifest.js.map +1 -0
  118. package/dist/store/kuzu_store.d.ts +46 -0
  119. package/dist/store/kuzu_store.d.ts.map +1 -1
  120. package/dist/store/kuzu_store.js +95 -5
  121. package/dist/store/kuzu_store.js.map +1 -1
  122. package/dist/verify/project_verifier.d.ts +85 -0
  123. package/dist/verify/project_verifier.d.ts.map +1 -0
  124. package/dist/verify/project_verifier.js +138 -0
  125. package/dist/verify/project_verifier.js.map +1 -0
  126. package/{skills/ts-knowledge-graph → dotclaude_folder/skills/code-graph-query}/SKILL.md +2 -2
  127. package/package.json +86 -7
  128. package/.env-sample +0 -34
  129. package/dist/agent/agent-tools.d.ts +0 -13
  130. package/dist/agent/agent-tools.d.ts.map +0 -1
  131. package/dist/agent/agent-tools.js +0 -153
  132. package/dist/agent/agent-tools.js.map +0 -1
  133. package/dist/agent/agent_tools.d.ts +0 -13
  134. package/dist/agent/agent_tools.d.ts.map +0 -1
  135. package/dist/agent/agent_tools.js +0 -153
  136. package/dist/agent/agent_tools.js.map +0 -1
  137. package/dist/agent/code-editor.d.ts +0 -18
  138. package/dist/agent/code-editor.d.ts.map +0 -1
  139. package/dist/agent/code-editor.js +0 -43
  140. package/dist/agent/code-editor.js.map +0 -1
  141. package/dist/agent/code_editor.d.ts +0 -18
  142. package/dist/agent/code_editor.d.ts.map +0 -1
  143. package/dist/agent/code_editor.js +0 -43
  144. package/dist/agent/code_editor.js.map +0 -1
  145. package/dist/agent/optimizer-agent.d.ts +0 -30
  146. package/dist/agent/optimizer-agent.d.ts.map +0 -1
  147. package/dist/agent/optimizer-agent.js +0 -97
  148. package/dist/agent/optimizer-agent.js.map +0 -1
  149. package/dist/agent/optimizer_agent.d.ts +0 -30
  150. package/dist/agent/optimizer_agent.d.ts.map +0 -1
  151. package/dist/agent/optimizer_agent.js +0 -97
  152. package/dist/agent/optimizer_agent.js.map +0 -1
  153. package/dist/agent/verifier.d.ts +0 -9
  154. package/dist/agent/verifier.d.ts.map +0 -1
  155. package/dist/agent/verifier.js +0 -19
  156. package/dist/agent/verifier.js.map +0 -1
  157. package/dist/commands/blast-radius.d.ts +0 -5
  158. package/dist/commands/blast-radius.d.ts.map +0 -1
  159. package/dist/commands/blast-radius.js +0 -18
  160. package/dist/commands/blast-radius.js.map +0 -1
  161. package/dist/commands/blast_radius.d.ts +0 -5
  162. package/dist/commands/blast_radius.d.ts.map +0 -1
  163. package/dist/commands/blast_radius.js +0 -18
  164. package/dist/commands/blast_radius.js.map +0 -1
  165. package/dist/commands/calls.d.ts +0 -5
  166. package/dist/commands/calls.d.ts.map +0 -1
  167. package/dist/commands/calls.js +0 -7
  168. package/dist/commands/calls.js.map +0 -1
  169. package/dist/commands/command-helpers.d.ts +0 -15
  170. package/dist/commands/command-helpers.d.ts.map +0 -1
  171. package/dist/commands/command-helpers.js +0 -61
  172. package/dist/commands/command-helpers.js.map +0 -1
  173. package/dist/commands/dead-exports.d.ts +0 -5
  174. package/dist/commands/dead-exports.d.ts.map +0 -1
  175. package/dist/commands/dead-exports.js +0 -7
  176. package/dist/commands/dead-exports.js.map +0 -1
  177. package/dist/commands/dead_exports.d.ts +0 -5
  178. package/dist/commands/dead_exports.d.ts.map +0 -1
  179. package/dist/commands/dead_exports.js +0 -7
  180. package/dist/commands/dead_exports.js.map +0 -1
  181. package/dist/commands/extract.d.ts +0 -8
  182. package/dist/commands/extract.d.ts.map +0 -1
  183. package/dist/commands/extract.js +0 -49
  184. package/dist/commands/extract.js.map +0 -1
  185. package/dist/commands/find.d.ts +0 -5
  186. package/dist/commands/find.d.ts.map +0 -1
  187. package/dist/commands/find.js +0 -7
  188. package/dist/commands/find.js.map +0 -1
  189. package/dist/commands/load.d.ts.map +0 -1
  190. package/dist/commands/load.js +0 -28
  191. package/dist/commands/load.js.map +0 -1
  192. package/dist/commands/neighbors.d.ts +0 -5
  193. package/dist/commands/neighbors.d.ts.map +0 -1
  194. package/dist/commands/neighbors.js +0 -17
  195. package/dist/commands/neighbors.js.map +0 -1
  196. package/dist/commands/optimize.d.ts +0 -6
  197. package/dist/commands/optimize.d.ts.map +0 -1
  198. package/dist/commands/optimize.js +0 -59
  199. package/dist/commands/optimize.js.map +0 -1
  200. package/dist/commands/optimize_command.d.ts +0 -6
  201. package/dist/commands/optimize_command.d.ts.map +0 -1
  202. package/dist/commands/optimize_command.js +0 -59
  203. package/dist/commands/optimize_command.js.map +0 -1
  204. package/dist/commands/references.d.ts +0 -5
  205. package/dist/commands/references.d.ts.map +0 -1
  206. package/dist/commands/references.js +0 -17
  207. package/dist/commands/references.js.map +0 -1
  208. package/dist/commands/web.d.ts +0 -19
  209. package/dist/commands/web.d.ts.map +0 -1
  210. package/dist/commands/web.js +0 -120
  211. package/dist/commands/web.js.map +0 -1
  212. package/dist/commands/who-calls.d.ts +0 -5
  213. package/dist/commands/who-calls.d.ts.map +0 -1
  214. package/dist/commands/who-calls.js +0 -7
  215. package/dist/commands/who-calls.js.map +0 -1
  216. package/dist/commands/who_calls.d.ts +0 -5
  217. package/dist/commands/who_calls.d.ts.map +0 -1
  218. package/dist/commands/who_calls.js +0 -7
  219. package/dist/commands/who_calls.js.map +0 -1
  220. package/dist/extract/graph-builder.d.ts +0 -16
  221. package/dist/extract/graph-builder.d.ts.map +0 -1
  222. package/dist/extract/graph-builder.js +0 -39
  223. package/dist/extract/graph-builder.js.map +0 -1
  224. package/dist/extract/node-id.d.ts +0 -8
  225. package/dist/extract/node-id.d.ts.map +0 -1
  226. package/dist/extract/node-id.js +0 -22
  227. package/dist/extract/node-id.js.map +0 -1
  228. package/dist/extract/project-loader.d.ts +0 -5
  229. package/dist/extract/project-loader.d.ts.map +0 -1
  230. package/dist/extract/project-loader.js +0 -19
  231. package/dist/extract/project-loader.js.map +0 -1
  232. package/dist/extract/semantic-extractor.d.ts +0 -22
  233. package/dist/extract/semantic-extractor.d.ts.map +0 -1
  234. package/dist/extract/semantic-extractor.js +0 -254
  235. package/dist/extract/semantic-extractor.js.map +0 -1
  236. package/dist/extract/structural-extractor.d.ts +0 -18
  237. package/dist/extract/structural-extractor.d.ts.map +0 -1
  238. package/dist/extract/structural-extractor.js +0 -97
  239. package/dist/extract/structural-extractor.js.map +0 -1
  240. package/dist/query/graph-query.d.ts +0 -28
  241. package/dist/query/graph-query.d.ts.map +0 -1
  242. package/dist/query/graph-query.js +0 -93
  243. package/dist/query/graph-query.js.map +0 -1
  244. package/dist/store/jsonl-reader.d.ts +0 -11
  245. package/dist/store/jsonl-reader.d.ts.map +0 -1
  246. package/dist/store/jsonl-reader.js +0 -19
  247. package/dist/store/jsonl-reader.js.map +0 -1
  248. package/dist/store/jsonl-store.d.ts +0 -7
  249. package/dist/store/jsonl-store.d.ts.map +0 -1
  250. package/dist/store/jsonl-store.js +0 -13
  251. package/dist/store/jsonl-store.js.map +0 -1
  252. package/dist/store/kuzu-store.d.ts +0 -14
  253. package/dist/store/kuzu-store.d.ts.map +0 -1
  254. package/dist/store/kuzu-store.js +0 -52
  255. package/dist/store/kuzu-store.js.map +0 -1
@@ -1,5 +1,7 @@
1
+ // @ts-check
1
2
  'use strict';
2
3
 
4
+ /** @type {Record<string, string>} */
3
5
  const NODE_COLORS = {
4
6
  Module: '#4f8cff',
5
7
  Class: '#f59e0b',
@@ -12,8 +14,12 @@ const NODE_COLORS = {
12
14
  Parameter: '#64748b',
13
15
  Variable: '#2dd4bf',
14
16
  ExternalModule: '#6b7280',
17
+ ConfigFlag: '#84cc16',
18
+ ExternalAPI: '#fb7185',
19
+ Endpoint: '#38bdf8',
15
20
  };
16
21
 
22
+ /** @type {Record<string, string>} */
17
23
  const EDGE_COLORS = {
18
24
  CONTAINS: '#475569',
19
25
  IMPORTS: '#64748b',
@@ -28,8 +34,28 @@ const EDGE_COLORS = {
28
34
  READS: '#eab308',
29
35
  WRITES: '#eab308',
30
36
  OVERRIDES: '#94a3b8',
37
+ READS_CONFIG: '#65a30d',
38
+ CALLS_EXTERNAL: '#e11d48',
39
+ HANDLES: '#0ea5e9',
31
40
  };
32
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} */
33
59
  const state = {
34
60
  nodes: [],
35
61
  edges: [],
@@ -37,24 +63,89 @@ const state = {
37
63
  hiddenNodeKinds: new Set(),
38
64
  hiddenEdgeKinds: new Set(),
39
65
  hideIsolated: false,
66
+ onlyMeasured: false,
40
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;
41
110
  };
42
111
 
43
- const el = (id) => document.getElementById(id);
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
+ };
44
124
 
45
125
  /* ---------- data loading ---------- */
46
126
 
47
127
  function boot() {
48
128
  setupDropzone();
129
+ setupFolds();
49
130
  el('hide-isolated').addEventListener('change', (event) => {
50
- state.hideIsolated = event.target.checked;
131
+ state.hideIsolated = asInput(event.target).checked;
51
132
  applyFilters();
52
133
  });
53
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
+ });
54
145
  el('search').addEventListener('input', () => renderSearchResults());
55
146
  el('search').addEventListener('keydown', (event) => {
56
147
  if (event.key === 'Enter') {
57
- const first = document.querySelector('#search-results .hit');
148
+ const first = /** @type {HTMLElement | null} */ (document.querySelector('#search-results .hit'));
58
149
  if (first !== null) {
59
150
  first.click();
60
151
  }
@@ -84,6 +175,12 @@ async function tryFetch() {
84
175
  }
85
176
  }
86
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
+ */
87
184
  function parseJsonl(text) {
88
185
  return text.split('\n').filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
89
186
  }
@@ -102,6 +199,9 @@ function setupDropzone() {
102
199
  window.addEventListener('drop', async (event) => {
103
200
  event.preventDefault();
104
201
  zone.classList.remove('active');
202
+ if (event.dataTransfer === null) {
203
+ return;
204
+ }
105
205
  for (const file of event.dataTransfer.files) {
106
206
  const records = parseJsonl(await file.text());
107
207
  if (records.length === 0) {
@@ -121,29 +221,101 @@ function setupDropzone() {
121
221
  });
122
222
  }
123
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
+
124
275
  /* ---------- graph construction ---------- */
125
276
 
277
+ /**
278
+ * @param {RawNode[]} nodes
279
+ * @param {RawEdge[]} edges
280
+ * @param {string} sourceLabel
281
+ */
126
282
  function setData(nodes, edges, sourceLabel) {
127
283
  state.nodes = nodes;
128
284
  state.edges = edges;
129
285
 
130
286
  const nodeIds = new Set(nodes.map((node) => node.id));
287
+ /** @type {Map<string, number>} */
131
288
  const degree = new Map();
132
289
  for (const edge of edges) {
133
290
  degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1);
134
291
  degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1);
135
292
  }
136
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
+
137
309
  const elements = [
138
310
  ...nodes.map((node) => ({
139
311
  group: 'nodes',
140
- 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 },
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) },
141
313
  })),
142
314
  ...edges
143
315
  .filter((edge) => nodeIds.has(edge.from) === true && nodeIds.has(edge.to) === true)
144
316
  .map((edge) => ({
145
317
  group: 'edges',
146
- data: { id: edge.id, source: edge.from, target: edge.to, kind: edge.kind },
318
+ data: { id: edge.id, source: edge.from, target: edge.to, kind: edge.kind, count: edgeCount(edge) },
147
319
  })),
148
320
  ];
149
321
 
@@ -164,18 +336,46 @@ function setData(nodes, edges, sourceLabel) {
164
336
  });
165
337
 
166
338
  buildLegends();
339
+ renderRuntime();
167
340
  applyFilters();
168
341
  el('status').textContent = `${sourceLabel} — ${nodes.length} nodes, ${edges.length} edges`;
169
342
  }
170
343
 
171
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;
172
369
  return [
173
370
  {
174
371
  selector: 'node',
175
372
  style: {
176
- 'background-color': (node) => NODE_COLORS[node.data('kind')] ?? '#9ca3af',
177
- 'width': (node) => 8 + Math.sqrt(node.data('degree')) * 4,
178
- 'height': (node) => 8 + Math.sqrt(node.data('degree')) * 4,
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',
179
379
  'label': 'data(name)',
180
380
  'color': '#cbd5e1',
181
381
  'font-size': 8,
@@ -187,9 +387,9 @@ function cyStyle() {
187
387
  {
188
388
  selector: 'edge',
189
389
  style: {
190
- 'width': 1,
191
- 'line-color': (edge) => EDGE_COLORS[edge.data('kind')] ?? '#475569',
192
- 'target-arrow-color': (edge) => EDGE_COLORS[edge.data('kind')] ?? '#475569',
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',
193
393
  'target-arrow-shape': 'triangle',
194
394
  'arrow-scale': 0.6,
195
395
  'curve-style': 'bezier',
@@ -198,16 +398,45 @@ function cyStyle() {
198
398
  },
199
399
  { selector: '.hidden', style: { display: 'none' } },
200
400
  { selector: '.faded', style: { opacity: 0.08, 'text-opacity': 0 } },
201
- { selector: 'node.sel', style: { 'border-width': 3, 'border-color': '#ffffff' } },
401
+ { selector: 'node.sel', style: { 'border-width': 3, 'border-color': '#ffffff', 'border-style': 'solid' } },
202
402
  ];
203
403
  }
204
404
 
205
405
  function runLayout() {
206
- const name = el('layout-select').value;
406
+ const cy = state.cy;
407
+ if (cy === undefined) {
408
+ return;
409
+ }
410
+ const name = selectEl('layout-select').value;
207
411
  const options = name === 'concentric'
208
- ? { name, concentric: (node) => node.degree(), levelWidth: () => 2, animate: false, padding: 30 }
412
+ ? { name, concentric: (/** @type {CyCollection} */ node) => node.degree(), levelWidth: () => 2, animate: false, padding: 30 }
209
413
  : { name, animate: false, padding: 30 };
210
- state.cy.elements(':visible').layout(options).run();
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;
211
440
  }
212
441
 
213
442
  /* ---------- legends & filtering ---------- */
@@ -215,16 +444,65 @@ function runLayout() {
215
444
  function buildLegends() {
216
445
  const nodeCounts = countBy(state.nodes.map((node) => node.kind));
217
446
  const edgeCounts = countBy(state.edges.map((edge) => edge.kind));
218
- renderLegend(el('node-kinds'), nodeCounts, NODE_COLORS, state.hiddenNodeKinds);
219
- renderLegend(el('edge-kinds'), edgeCounts, EDGE_COLORS, state.hiddenEdgeKinds);
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);
220
449
  }
221
450
 
222
- function renderLegend(container, counts, colors, hiddenSet) {
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) {
223
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
+
224
501
  for (const [kind, count] of counts) {
225
502
  const label = document.createElement('label');
226
503
  const checkbox = document.createElement('input');
227
504
  checkbox.type = 'checkbox';
505
+ checkbox.dataset.kind = kind;
228
506
  checkbox.checked = hiddenSet.has(kind) === false;
229
507
  checkbox.addEventListener('change', () => {
230
508
  if (checkbox.checked === true) {
@@ -232,8 +510,10 @@ function renderLegend(container, counts, colors, hiddenSet) {
232
510
  } else {
233
511
  hiddenSet.add(kind);
234
512
  }
513
+ syncMaster();
235
514
  applyFilters();
236
515
  });
516
+ childCheckboxes.push(checkbox);
237
517
  const swatch = document.createElement('span');
238
518
  swatch.className = 'swatch';
239
519
  swatch.style.background = colors[kind] ?? '#9ca3af';
@@ -242,9 +522,84 @@ function renderLegend(container, counts, colors, hiddenSet) {
242
522
  const countSpan = document.createElement('span');
243
523
  countSpan.className = 'count';
244
524
  countSpan.textContent = String(count);
245
- label.append(checkbox, swatch, text, countSpan);
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);
246
531
  container.appendChild(label);
247
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
+ }
248
603
  }
249
604
 
250
605
  function applyFilters() {
@@ -254,7 +609,10 @@ function applyFilters() {
254
609
  }
255
610
  cy.batch(() => {
256
611
  cy.nodes().forEach((node) => {
257
- node.toggleClass('hidden', state.hiddenNodeKinds.has(node.data('kind')) === true);
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);
258
616
  });
259
617
  cy.edges().forEach((edge) => {
260
618
  edge.toggleClass('hidden', state.hiddenEdgeKinds.has(edge.data('kind')) === true);
@@ -273,7 +631,12 @@ function applyFilters() {
273
631
  });
274
632
  }
275
633
 
634
+ /**
635
+ * @param {string[]} values
636
+ * @returns {[string, number][]}
637
+ */
276
638
  function countBy(values) {
639
+ /** @type {Map<string, number>} */
277
640
  const counts = new Map();
278
641
  for (const value of values) {
279
642
  counts.set(value, (counts.get(value) ?? 0) + 1);
@@ -281,10 +644,132 @@ function countBy(values) {
281
644
  return [...counts.entries()].sort((a, b) => b[1] - a[1]);
282
645
  }
283
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
+
284
769
  /* ---------- search ---------- */
285
770
 
286
771
  function renderSearchResults() {
287
- const query = el('search').value.trim().toLowerCase();
772
+ const query = inputEl('search').value.trim().toLowerCase();
288
773
  const container = el('search-results');
289
774
  container.innerHTML = '';
290
775
  if (query.length < 2) {
@@ -297,21 +782,19 @@ function renderSearchResults() {
297
782
  const row = document.createElement('div');
298
783
  row.className = 'hit';
299
784
  row.innerHTML = `${escapeHtml(hit.name)} <span class="loc">${escapeHtml(hit.kind)} · ${escapeHtml(hit.filePath)}</span>`;
300
- row.addEventListener('click', () => {
301
- const node = state.cy.getElementById(hit.id);
302
- if (node.length === 1) {
303
- select(node);
304
- state.cy.animate({ center: { eles: node }, zoom: 2 }, { duration: 350 });
305
- }
306
- });
785
+ row.addEventListener('click', () => focusNode(hit.id));
307
786
  container.appendChild(row);
308
787
  }
309
788
  }
310
789
 
311
790
  /* ---------- selection & details ---------- */
312
791
 
792
+ /** @param {CyCollection} node */
313
793
  function select(node) {
314
794
  const cy = state.cy;
795
+ if (cy === undefined) {
796
+ return;
797
+ }
315
798
  cy.elements().addClass('faded').removeClass('sel');
316
799
  const hood = node.closedNeighborhood();
317
800
  hood.removeClass('faded');
@@ -320,45 +803,108 @@ function select(node) {
320
803
  }
321
804
 
322
805
  function clearSelection() {
323
- state.cy.elements().removeClass('faded sel');
806
+ if (state.cy !== undefined) {
807
+ state.cy.elements().removeClass('faded sel');
808
+ }
324
809
  el('details-body').textContent = 'click a node';
325
810
  }
326
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 */
327
838
  function renderDetails(node) {
328
839
  const id = node.id();
329
840
  const color = NODE_COLORS[node.data('kind')] ?? '#9ca3af';
330
841
  const outgoing = state.edges.filter((edge) => edge.from === id);
331
842
  const incoming = state.edges.filter((edge) => edge.to === id);
332
- const nodeById = new Map(state.nodes.map((entry) => [entry.id, entry]));
843
+ const nodeById = new Map(state.nodes.map((entry) => /** @type {[string, RawNode]} */ ([entry.id, entry])));
333
844
 
845
+ /**
846
+ * @param {RawEdge[]} edges
847
+ * @param {'out' | 'in'} direction
848
+ */
334
849
  const renderEdgeRows = (edges, direction) => edges.map((edge) => {
335
850
  const otherId = direction === 'out' ? edge.to : edge.from;
336
851
  const other = nodeById.get(otherId);
337
852
  const name = other === undefined ? otherId : other.name;
338
853
  const arrow = direction === 'out' ? '→' : '←';
339
- return `<div class="edge-row"><span class="edge-kind">${escapeHtml(edge.kind)}</span> ${arrow} <a data-target="${escapeHtml(otherId)}">${escapeHtml(name)}</a></div>`;
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>`;
340
857
  }).join('');
341
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
+
342
876
  el('details-body').innerHTML = `
343
877
  <div><span class="kind-tag" style="background:${color}">${escapeHtml(node.data('kind'))}</span> <strong>${escapeHtml(node.data('name'))}</strong></div>
344
- <div>${escapeHtml(node.data('filePath'))}${node.data('startLine') > 0 ? ':' + node.data('startLine') : ''}</div>
878
+ <div>${locationHtml}</div>
345
879
  <div class="id">${escapeHtml(id)}</div>
880
+ ${runtimeBlock}
346
881
  <h3>outgoing (${outgoing.length})</h3>${renderEdgeRows(outgoing, 'out')}
347
882
  <h3>incoming (${incoming.length})</h3>${renderEdgeRows(incoming, 'in')}
348
883
  `;
349
884
  el('details-body').querySelectorAll('a[data-target]').forEach((link) => {
350
885
  link.addEventListener('click', () => {
351
- const target = state.cy.getElementById(link.dataset.target);
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 ?? '');
352
892
  if (target.length === 1) {
353
893
  select(target);
354
- state.cy.animate({ center: { eles: target } }, { duration: 300 });
894
+ cy.animate({ center: { eles: target } }, { duration: 300 });
355
895
  }
356
896
  });
357
897
  });
358
898
  }
359
899
 
900
+ const ESCAPE_REPLACEMENTS = /** @type {Record<string, string>} */ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' });
901
+
902
+ /**
903
+ * @param {unknown} value
904
+ * @returns {string}
905
+ */
360
906
  function escapeHtml(value) {
361
- return String(value).replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
907
+ return String(value).replace(/[&<>"']/g, (char) => ESCAPE_REPLACEMENTS[char]);
362
908
  }
363
909
 
364
910
  boot();