ts-knowledge-graph 0.1.2 → 0.1.6

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 (335) hide show
  1. package/README.md +99 -41
  2. package/contribs/webview/README.md +83 -0
  3. package/contribs/webview/web/css/style.css +310 -0
  4. package/contribs/webview/web/index.html +109 -0
  5. package/contribs/webview/web/js/app.js +1249 -0
  6. package/contribs/webview/web/js_autogenerated/.gitignore +3 -0
  7. package/contribs/webview/web/js_autogenerated/kind_descriptions.js +39 -0
  8. package/contribs/webview/web/types/app_globals.d.ts +154 -0
  9. package/dist/benchmark/benchmark_stats.d.ts +41 -0
  10. package/dist/benchmark/benchmark_stats.d.ts.map +1 -0
  11. package/dist/benchmark/benchmark_stats.js +61 -0
  12. package/dist/benchmark/benchmark_stats.js.map +1 -0
  13. package/dist/benchmark/node_benchmark.d.ts +78 -0
  14. package/dist/benchmark/node_benchmark.d.ts.map +1 -0
  15. package/dist/benchmark/node_benchmark.js +112 -0
  16. package/dist/benchmark/node_benchmark.js.map +1 -0
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +16 -4
  19. package/dist/cli.js.map +1 -1
  20. package/dist/cluster/cluster_weights.d.ts +20 -0
  21. package/dist/cluster/cluster_weights.d.ts.map +1 -0
  22. package/dist/cluster/cluster_weights.js +32 -0
  23. package/dist/cluster/cluster_weights.js.map +1 -0
  24. package/dist/cluster/community_detector.d.ts +61 -0
  25. package/dist/cluster/community_detector.d.ts.map +1 -0
  26. package/dist/cluster/community_detector.js +120 -0
  27. package/dist/cluster/community_detector.js.map +1 -0
  28. package/dist/cluster/community_labeler.d.ts +84 -0
  29. package/dist/cluster/community_labeler.d.ts.map +1 -0
  30. package/dist/cluster/community_labeler.js +194 -0
  31. package/dist/cluster/community_labeler.js.map +1 -0
  32. package/dist/cluster/graph_clusterer.d.ts +47 -0
  33. package/dist/cluster/graph_clusterer.d.ts.map +1 -0
  34. package/dist/cluster/graph_clusterer.js +126 -0
  35. package/dist/cluster/graph_clusterer.js.map +1 -0
  36. package/dist/commands/benchmark_command.d.ts +11 -0
  37. package/dist/commands/benchmark_command.d.ts.map +1 -0
  38. package/dist/commands/benchmark_command.js +94 -0
  39. package/dist/commands/benchmark_command.js.map +1 -0
  40. package/dist/commands/blast_radius_command.d.ts.map +1 -1
  41. package/dist/commands/blast_radius_command.js +7 -6
  42. package/dist/commands/blast_radius_command.js.map +1 -1
  43. package/dist/commands/cluster_command.d.ts +7 -0
  44. package/dist/commands/cluster_command.d.ts.map +1 -0
  45. package/dist/commands/cluster_command.js +55 -0
  46. package/dist/commands/cluster_command.js.map +1 -0
  47. package/dist/commands/command_helpers.d.ts +9 -4
  48. package/dist/commands/command_helpers.d.ts.map +1 -1
  49. package/dist/commands/command_helpers.js +13 -8
  50. package/dist/commands/command_helpers.js.map +1 -1
  51. package/dist/commands/cost_command.d.ts +13 -0
  52. package/dist/commands/cost_command.d.ts.map +1 -0
  53. package/dist/commands/cost_command.js +139 -0
  54. package/dist/commands/cost_command.js.map +1 -0
  55. package/dist/commands/{load.d.ts → enrich_command.d.ts} +3 -2
  56. package/dist/commands/enrich_command.d.ts.map +1 -0
  57. package/dist/commands/enrich_command.js +64 -0
  58. package/dist/commands/enrich_command.js.map +1 -0
  59. package/dist/commands/extract_command.d.ts.map +1 -1
  60. package/dist/commands/extract_command.js +12 -6
  61. package/dist/commands/extract_command.js.map +1 -1
  62. package/dist/commands/hotspots_command.d.ts +7 -0
  63. package/dist/commands/hotspots_command.d.ts.map +1 -0
  64. package/dist/commands/hotspots_command.js +68 -0
  65. package/dist/commands/hotspots_command.js.map +1 -0
  66. package/dist/commands/install_command.d.ts +15 -6
  67. package/dist/commands/install_command.d.ts.map +1 -1
  68. package/dist/commands/install_command.js +62 -25
  69. package/dist/commands/install_command.js.map +1 -1
  70. package/dist/commands/load_command.d.ts.map +1 -1
  71. package/dist/commands/load_command.js +20 -13
  72. package/dist/commands/load_command.js.map +1 -1
  73. package/dist/commands/neighbors_command.d.ts.map +1 -1
  74. package/dist/commands/neighbors_command.js +6 -5
  75. package/dist/commands/neighbors_command.js.map +1 -1
  76. package/dist/commands/references_command.d.ts.map +1 -1
  77. package/dist/commands/references_command.js +6 -5
  78. package/dist/commands/references_command.js.map +1 -1
  79. package/dist/commands/report_command.d.ts +16 -0
  80. package/dist/commands/report_command.d.ts.map +1 -0
  81. package/dist/commands/report_command.js +115 -0
  82. package/dist/commands/report_command.js.map +1 -0
  83. package/dist/commands/verify_command.d.ts +8 -0
  84. package/dist/commands/verify_command.d.ts.map +1 -0
  85. package/dist/commands/verify_command.js +57 -0
  86. package/dist/commands/verify_command.js.map +1 -0
  87. package/dist/commands/web_command.d.ts +27 -0
  88. package/dist/commands/web_command.d.ts.map +1 -1
  89. package/dist/commands/web_command.js +109 -3
  90. package/dist/commands/web_command.js.map +1 -1
  91. package/dist/commands/webview_command.d.ts +36 -0
  92. package/dist/commands/webview_command.d.ts.map +1 -0
  93. package/dist/commands/webview_command.js +186 -0
  94. package/dist/commands/webview_command.js.map +1 -0
  95. package/dist/enrich/cpu_profile.d.ts +160 -0
  96. package/dist/enrich/cpu_profile.d.ts.map +1 -0
  97. package/dist/enrich/cpu_profile.js +185 -0
  98. package/dist/enrich/cpu_profile.js.map +1 -0
  99. package/dist/enrich/runtime_enricher.d.ts +64 -0
  100. package/dist/enrich/runtime_enricher.d.ts.map +1 -0
  101. package/dist/enrich/runtime_enricher.js +98 -0
  102. package/dist/enrich/runtime_enricher.js.map +1 -0
  103. package/dist/enrich/runtime_join.d.ts +124 -0
  104. package/dist/enrich/runtime_join.d.ts.map +1 -0
  105. package/dist/enrich/runtime_join.js +270 -0
  106. package/dist/enrich/runtime_join.js.map +1 -0
  107. package/dist/extract/api_extractor.d.ts +24 -0
  108. package/dist/extract/api_extractor.d.ts.map +1 -0
  109. package/dist/extract/api_extractor.js +71 -0
  110. package/dist/extract/api_extractor.js.map +1 -0
  111. package/dist/extract/config_extractor.d.ts +22 -0
  112. package/dist/extract/config_extractor.d.ts.map +1 -0
  113. package/dist/extract/config_extractor.js +61 -0
  114. package/dist/extract/config_extractor.js.map +1 -0
  115. package/dist/extract/endpoint_extractor.d.ts +36 -0
  116. package/dist/extract/endpoint_extractor.d.ts.map +1 -0
  117. package/dist/extract/endpoint_extractor.js +117 -0
  118. package/dist/extract/endpoint_extractor.js.map +1 -0
  119. package/dist/extract/git_source.d.ts +23 -0
  120. package/dist/extract/git_source.d.ts.map +1 -0
  121. package/dist/extract/git_source.js +75 -0
  122. package/dist/extract/git_source.js.map +1 -0
  123. package/dist/extract/graph_builder.d.ts +8 -0
  124. package/dist/extract/graph_builder.d.ts.map +1 -1
  125. package/dist/extract/graph_builder.js +23 -1
  126. package/dist/extract/graph_builder.js.map +1 -1
  127. package/dist/extract/node_id.d.ts +16 -0
  128. package/dist/extract/node_id.d.ts.map +1 -1
  129. package/dist/extract/node_id.js +22 -0
  130. package/dist/extract/node_id.js.map +1 -1
  131. package/dist/extract/scope_resolver.d.ts +22 -0
  132. package/dist/extract/scope_resolver.d.ts.map +1 -0
  133. package/dist/extract/scope_resolver.js +53 -0
  134. package/dist/extract/scope_resolver.js.map +1 -0
  135. package/dist/extract/semantic_extractor.d.ts +25 -0
  136. package/dist/extract/semantic_extractor.d.ts.map +1 -1
  137. package/dist/extract/semantic_extractor.js +96 -2
  138. package/dist/extract/semantic_extractor.js.map +1 -1
  139. package/dist/extract/structural_extractor.d.ts +6 -0
  140. package/dist/extract/structural_extractor.d.ts.map +1 -1
  141. package/dist/extract/structural_extractor.js +22 -12
  142. package/dist/extract/structural_extractor.js.map +1 -1
  143. package/dist/project_root.d.ts +7 -0
  144. package/dist/project_root.d.ts.map +1 -0
  145. package/dist/project_root.js +9 -0
  146. package/dist/project_root.js.map +1 -0
  147. package/dist/query/graph_query.d.ts +269 -0
  148. package/dist/query/graph_query.d.ts.map +1 -1
  149. package/dist/query/graph_query.js +585 -11
  150. package/dist/query/graph_query.js.map +1 -1
  151. package/dist/report/graph_report.d.ts +51 -0
  152. package/dist/report/graph_report.d.ts.map +1 -0
  153. package/dist/report/graph_report.js +312 -0
  154. package/dist/report/graph_report.js.map +1 -0
  155. package/dist/report/pdf_renderer.d.ts +22 -0
  156. package/dist/report/pdf_renderer.d.ts.map +1 -0
  157. package/dist/report/pdf_renderer.js +54 -0
  158. package/dist/report/pdf_renderer.js.map +1 -0
  159. package/dist/report/report_data.d.ts +128 -0
  160. package/dist/report/report_data.d.ts.map +1 -0
  161. package/dist/report/report_data.js +191 -0
  162. package/dist/report/report_data.js.map +1 -0
  163. package/dist/schema/edge.d.ts +40 -5
  164. package/dist/schema/edge.d.ts.map +1 -1
  165. package/dist/schema/edge.js +73 -0
  166. package/dist/schema/edge.js.map +1 -1
  167. package/dist/schema/node.d.ts +20 -5
  168. package/dist/schema/node.d.ts.map +1 -1
  169. package/dist/schema/node.js +36 -0
  170. package/dist/schema/node.js.map +1 -1
  171. package/dist/schema/runtime_manifest.d.ts +36 -0
  172. package/dist/schema/runtime_manifest.d.ts.map +1 -0
  173. package/dist/schema/runtime_manifest.js +23 -0
  174. package/dist/schema/runtime_manifest.js.map +1 -0
  175. package/dist/schema/source_manifest.d.ts +30 -0
  176. package/dist/schema/source_manifest.d.ts.map +1 -0
  177. package/dist/schema/source_manifest.js +21 -0
  178. package/dist/schema/source_manifest.js.map +1 -0
  179. package/dist/store/jsonl_reader.d.ts +4 -0
  180. package/dist/store/jsonl_reader.d.ts.map +1 -1
  181. package/dist/store/jsonl_reader.js +13 -1
  182. package/dist/store/jsonl_reader.js.map +1 -1
  183. package/dist/store/jsonl_store.d.ts +2 -1
  184. package/dist/store/jsonl_store.d.ts.map +1 -1
  185. package/dist/store/jsonl_store.js +4 -1
  186. package/dist/store/jsonl_store.js.map +1 -1
  187. package/dist/store/kuzu_store.d.ts +59 -0
  188. package/dist/store/kuzu_store.d.ts.map +1 -1
  189. package/dist/store/kuzu_store.js +124 -5
  190. package/dist/store/kuzu_store.js.map +1 -1
  191. package/dist/store/output_folder.d.ts +43 -0
  192. package/dist/store/output_folder.d.ts.map +1 -0
  193. package/dist/store/output_folder.js +61 -0
  194. package/dist/store/output_folder.js.map +1 -0
  195. package/dist/verify/project_verifier.d.ts +85 -0
  196. package/dist/verify/project_verifier.d.ts.map +1 -0
  197. package/dist/verify/project_verifier.js +138 -0
  198. package/dist/verify/project_verifier.js.map +1 -0
  199. package/dotclaude_folder/commands/code-graph-interview.md +123 -0
  200. package/dotclaude_folder/commands/code-graph-optimize.md +65 -0
  201. package/{skills/ts-knowledge-graph → dotclaude_folder/skills/code-graph-query}/SKILL.md +6 -6
  202. package/package.json +99 -10
  203. package/.env-sample +0 -34
  204. package/contribs/web_visualisation/README.md +0 -55
  205. package/contribs/web_visualisation/web/css/style.css +0 -115
  206. package/contribs/web_visualisation/web/data/.gitignore +0 -2
  207. package/contribs/web_visualisation/web/index.html +0 -58
  208. package/contribs/web_visualisation/web/js/app.js +0 -364
  209. package/dist/agent/agent-tools.d.ts +0 -13
  210. package/dist/agent/agent-tools.d.ts.map +0 -1
  211. package/dist/agent/agent-tools.js +0 -153
  212. package/dist/agent/agent-tools.js.map +0 -1
  213. package/dist/agent/agent_tools.d.ts +0 -13
  214. package/dist/agent/agent_tools.d.ts.map +0 -1
  215. package/dist/agent/agent_tools.js +0 -153
  216. package/dist/agent/agent_tools.js.map +0 -1
  217. package/dist/agent/code-editor.d.ts +0 -18
  218. package/dist/agent/code-editor.d.ts.map +0 -1
  219. package/dist/agent/code-editor.js +0 -43
  220. package/dist/agent/code-editor.js.map +0 -1
  221. package/dist/agent/code_editor.d.ts +0 -18
  222. package/dist/agent/code_editor.d.ts.map +0 -1
  223. package/dist/agent/code_editor.js +0 -43
  224. package/dist/agent/code_editor.js.map +0 -1
  225. package/dist/agent/optimizer-agent.d.ts +0 -30
  226. package/dist/agent/optimizer-agent.d.ts.map +0 -1
  227. package/dist/agent/optimizer-agent.js +0 -97
  228. package/dist/agent/optimizer-agent.js.map +0 -1
  229. package/dist/agent/optimizer_agent.d.ts +0 -30
  230. package/dist/agent/optimizer_agent.d.ts.map +0 -1
  231. package/dist/agent/optimizer_agent.js +0 -97
  232. package/dist/agent/optimizer_agent.js.map +0 -1
  233. package/dist/agent/verifier.d.ts +0 -9
  234. package/dist/agent/verifier.d.ts.map +0 -1
  235. package/dist/agent/verifier.js +0 -19
  236. package/dist/agent/verifier.js.map +0 -1
  237. package/dist/commands/blast-radius.d.ts +0 -5
  238. package/dist/commands/blast-radius.d.ts.map +0 -1
  239. package/dist/commands/blast-radius.js +0 -18
  240. package/dist/commands/blast-radius.js.map +0 -1
  241. package/dist/commands/blast_radius.d.ts +0 -5
  242. package/dist/commands/blast_radius.d.ts.map +0 -1
  243. package/dist/commands/blast_radius.js +0 -18
  244. package/dist/commands/blast_radius.js.map +0 -1
  245. package/dist/commands/calls.d.ts +0 -5
  246. package/dist/commands/calls.d.ts.map +0 -1
  247. package/dist/commands/calls.js +0 -7
  248. package/dist/commands/calls.js.map +0 -1
  249. package/dist/commands/command-helpers.d.ts +0 -15
  250. package/dist/commands/command-helpers.d.ts.map +0 -1
  251. package/dist/commands/command-helpers.js +0 -61
  252. package/dist/commands/command-helpers.js.map +0 -1
  253. package/dist/commands/dead-exports.d.ts +0 -5
  254. package/dist/commands/dead-exports.d.ts.map +0 -1
  255. package/dist/commands/dead-exports.js +0 -7
  256. package/dist/commands/dead-exports.js.map +0 -1
  257. package/dist/commands/dead_exports.d.ts +0 -5
  258. package/dist/commands/dead_exports.d.ts.map +0 -1
  259. package/dist/commands/dead_exports.js +0 -7
  260. package/dist/commands/dead_exports.js.map +0 -1
  261. package/dist/commands/extract.d.ts +0 -8
  262. package/dist/commands/extract.d.ts.map +0 -1
  263. package/dist/commands/extract.js +0 -49
  264. package/dist/commands/extract.js.map +0 -1
  265. package/dist/commands/find.d.ts +0 -5
  266. package/dist/commands/find.d.ts.map +0 -1
  267. package/dist/commands/find.js +0 -7
  268. package/dist/commands/find.js.map +0 -1
  269. package/dist/commands/load.d.ts.map +0 -1
  270. package/dist/commands/load.js +0 -28
  271. package/dist/commands/load.js.map +0 -1
  272. package/dist/commands/neighbors.d.ts +0 -5
  273. package/dist/commands/neighbors.d.ts.map +0 -1
  274. package/dist/commands/neighbors.js +0 -17
  275. package/dist/commands/neighbors.js.map +0 -1
  276. package/dist/commands/optimize.d.ts +0 -6
  277. package/dist/commands/optimize.d.ts.map +0 -1
  278. package/dist/commands/optimize.js +0 -59
  279. package/dist/commands/optimize.js.map +0 -1
  280. package/dist/commands/optimize_command.d.ts +0 -6
  281. package/dist/commands/optimize_command.d.ts.map +0 -1
  282. package/dist/commands/optimize_command.js +0 -59
  283. package/dist/commands/optimize_command.js.map +0 -1
  284. package/dist/commands/references.d.ts +0 -5
  285. package/dist/commands/references.d.ts.map +0 -1
  286. package/dist/commands/references.js +0 -17
  287. package/dist/commands/references.js.map +0 -1
  288. package/dist/commands/web.d.ts +0 -19
  289. package/dist/commands/web.d.ts.map +0 -1
  290. package/dist/commands/web.js +0 -120
  291. package/dist/commands/web.js.map +0 -1
  292. package/dist/commands/who-calls.d.ts +0 -5
  293. package/dist/commands/who-calls.d.ts.map +0 -1
  294. package/dist/commands/who-calls.js +0 -7
  295. package/dist/commands/who-calls.js.map +0 -1
  296. package/dist/commands/who_calls.d.ts +0 -5
  297. package/dist/commands/who_calls.d.ts.map +0 -1
  298. package/dist/commands/who_calls.js +0 -7
  299. package/dist/commands/who_calls.js.map +0 -1
  300. package/dist/extract/graph-builder.d.ts +0 -16
  301. package/dist/extract/graph-builder.d.ts.map +0 -1
  302. package/dist/extract/graph-builder.js +0 -39
  303. package/dist/extract/graph-builder.js.map +0 -1
  304. package/dist/extract/node-id.d.ts +0 -8
  305. package/dist/extract/node-id.d.ts.map +0 -1
  306. package/dist/extract/node-id.js +0 -22
  307. package/dist/extract/node-id.js.map +0 -1
  308. package/dist/extract/project-loader.d.ts +0 -5
  309. package/dist/extract/project-loader.d.ts.map +0 -1
  310. package/dist/extract/project-loader.js +0 -19
  311. package/dist/extract/project-loader.js.map +0 -1
  312. package/dist/extract/semantic-extractor.d.ts +0 -22
  313. package/dist/extract/semantic-extractor.d.ts.map +0 -1
  314. package/dist/extract/semantic-extractor.js +0 -254
  315. package/dist/extract/semantic-extractor.js.map +0 -1
  316. package/dist/extract/structural-extractor.d.ts +0 -18
  317. package/dist/extract/structural-extractor.d.ts.map +0 -1
  318. package/dist/extract/structural-extractor.js +0 -97
  319. package/dist/extract/structural-extractor.js.map +0 -1
  320. package/dist/query/graph-query.d.ts +0 -28
  321. package/dist/query/graph-query.d.ts.map +0 -1
  322. package/dist/query/graph-query.js +0 -93
  323. package/dist/query/graph-query.js.map +0 -1
  324. package/dist/store/jsonl-reader.d.ts +0 -11
  325. package/dist/store/jsonl-reader.d.ts.map +0 -1
  326. package/dist/store/jsonl-reader.js +0 -19
  327. package/dist/store/jsonl-reader.js.map +0 -1
  328. package/dist/store/jsonl-store.d.ts +0 -7
  329. package/dist/store/jsonl-store.d.ts.map +0 -1
  330. package/dist/store/jsonl-store.js +0 -13
  331. package/dist/store/jsonl-store.js.map +0 -1
  332. package/dist/store/kuzu-store.d.ts +0 -14
  333. package/dist/store/kuzu-store.d.ts.map +0 -1
  334. package/dist/store/kuzu-store.js +0 -52
  335. package/dist/store/kuzu-store.js.map +0 -1
@@ -0,0 +1,1249 @@
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
+ CALLS_RUNTIME: '#be123c',
41
+ };
42
+
43
+ /* One-line descriptions per node/edge kind, generated from src/schema into
44
+ js_autogenerated/kind_descriptions.js. Absent (empty) when that file has not been built. */
45
+ const KIND_DESCRIPTIONS = window.KIND_DESCRIPTIONS ?? { nodes: {}, edges: {} };
46
+
47
+ /* Heat ramp for runtime self-time: cool slate → yellow → red ("red = hot"). */
48
+ const HEAT_STOPS = [
49
+ { at: 0, color: [100, 116, 139] },
50
+ { at: 0.5, color: [253, 224, 71] },
51
+ { at: 1, color: [220, 38, 38] },
52
+ ];
53
+
54
+ const HOTSPOTS_LIMIT = 12;
55
+
56
+ /* Persisted theme override ('light' | 'dark'); absent means follow the OS. */
57
+ const THEME_STORAGE_KEY = 'ktg.theme';
58
+
59
+ /** @type {AppState} */
60
+ const state = {
61
+ nodes: [],
62
+ edges: [],
63
+ cy: undefined,
64
+ hiddenNodeKinds: new Set(),
65
+ hiddenEdgeKinds: new Set(),
66
+ hiddenCommunities: new Set(),
67
+ hideIsolated: false,
68
+ onlyMeasured: false,
69
+ droppedFiles: { nodes: undefined, edges: undefined },
70
+ encoding: 'structural',
71
+ runtime: { maxSelfMs: 0, measuredCount: 0, totalSelfMs: 0 },
72
+ communities: [],
73
+ communityLabels: new Map(),
74
+ };
75
+
76
+ /* Register the fcose layout extension (loaded as a CDN global, see index.html) so the
77
+ label-aware force layout is selectable. Guarded so a missing script never breaks the viewer. */
78
+ if (window.cytoscapeFcose !== undefined) {
79
+ cytoscape.use(window.cytoscapeFcose);
80
+ }
81
+
82
+ /**
83
+ * Looks up a required element by id, throwing when it is absent so a missing
84
+ * template node fails loudly here instead of as a later `null` dereference.
85
+ * @param {string} id
86
+ * @returns {HTMLElement}
87
+ */
88
+ const el = (id) => {
89
+ const element = document.getElementById(id);
90
+ if (element === null) {
91
+ throw new Error(`missing element #${id}`);
92
+ }
93
+ return element;
94
+ };
95
+
96
+ /**
97
+ * Looks up a required `<input>` by id, narrowing it so `.checked` / `.value` are typed.
98
+ * @param {string} id
99
+ * @returns {HTMLInputElement}
100
+ */
101
+ const inputEl = (id) => {
102
+ const element = el(id);
103
+ if ((element instanceof HTMLInputElement) === false) {
104
+ throw new Error(`element #${id} is not an input`);
105
+ }
106
+ return element;
107
+ };
108
+
109
+ /**
110
+ * Looks up a required `<select>` by id, narrowing it so `.value` is typed.
111
+ * @param {string} id
112
+ * @returns {HTMLSelectElement}
113
+ */
114
+ const selectEl = (id) => {
115
+ const element = el(id);
116
+ if ((element instanceof HTMLSelectElement) === false) {
117
+ throw new Error(`element #${id} is not a select`);
118
+ }
119
+ return element;
120
+ };
121
+
122
+ /**
123
+ * Narrows a change/input event target to an `<input>` so `.checked` / `.value`
124
+ * can be read inside handlers bound to known controls.
125
+ * @param {EventTarget | null} target
126
+ * @returns {HTMLInputElement}
127
+ */
128
+ const asInput = (target) => {
129
+ if ((target instanceof HTMLInputElement) === false) {
130
+ throw new Error('event target is not an input');
131
+ }
132
+ return target;
133
+ };
134
+
135
+ /**
136
+ * Narrows a change-event target to a `<select>` so `.value` can be read inside
137
+ * the encoding-selector handler.
138
+ * @param {EventTarget | null} target
139
+ * @returns {HTMLSelectElement}
140
+ */
141
+ const asSelect = (target) => {
142
+ if ((target instanceof HTMLSelectElement) === false) {
143
+ throw new Error('event target is not a select');
144
+ }
145
+ return target;
146
+ };
147
+
148
+ /* ---------- data loading ---------- */
149
+
150
+ function boot() {
151
+ setupDropzone();
152
+ setupFolds();
153
+ setupTheme();
154
+ el('hide-isolated').addEventListener('change', (event) => {
155
+ state.hideIsolated = asInput(event.target).checked;
156
+ applyFilters();
157
+ });
158
+ el('relayout').addEventListener('click', () => runLayout());
159
+ el('encoding-select').addEventListener('change', (event) => {
160
+ state.encoding = encodingFromValue(asSelect(event.target).value);
161
+ if (state.cy !== undefined) {
162
+ state.cy.style(cyStyle());
163
+ }
164
+ });
165
+ el('only-measured').addEventListener('change', (event) => {
166
+ state.onlyMeasured = asInput(event.target).checked;
167
+ applyFilters();
168
+ });
169
+ el('search').addEventListener('input', () => renderSearchResults());
170
+ el('search').addEventListener('keydown', (event) => {
171
+ if (event.key === 'Enter') {
172
+ const first = /** @type {HTMLElement | null} */ (document.querySelector('#search-results .hit'));
173
+ if (first !== null) {
174
+ first.click();
175
+ }
176
+ }
177
+ });
178
+
179
+ if (window.GRAPH_DATA !== undefined) {
180
+ setData(window.GRAPH_DATA.nodes, window.GRAPH_DATA.edges, 'embedded graph_data.js');
181
+ return;
182
+ }
183
+ if (location.protocol.startsWith('http') === true) {
184
+ tryFetch();
185
+ return;
186
+ }
187
+ el('status').textContent = 'no data — run `npm run build`, or drop the JSONL files here';
188
+ }
189
+
190
+ async function tryFetch() {
191
+ try {
192
+ const [nodesText, edgesText] = await Promise.all([
193
+ fetch('../../../.ts_knowledge_graph/graph/nodes.jsonl').then((r) => r.ok === true ? r.text() : Promise.reject(new Error(String(r.status)))),
194
+ fetch('../../../.ts_knowledge_graph/graph/edges.jsonl').then((r) => r.ok === true ? r.text() : Promise.reject(new Error(String(r.status)))),
195
+ ]);
196
+ setData(parseJsonl(nodesText), parseJsonl(edgesText), 'fetched ../../../.ts_knowledge_graph/graph/*.jsonl');
197
+ } catch {
198
+ el('status').textContent = 'no data — generate js_autogenerated/graph_data.js or drop the JSONL files here';
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Parses newline-delimited JSON into records. The records cross an untyped
204
+ * deserialisation boundary, so callers narrow them via the `setData` signature.
205
+ * @param {string} text
206
+ * @returns {any[]}
207
+ */
208
+ function parseJsonl(text) {
209
+ return text.split('\n').filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
210
+ }
211
+
212
+ function setupDropzone() {
213
+ const zone = el('dropzone');
214
+ window.addEventListener('dragover', (event) => {
215
+ event.preventDefault();
216
+ zone.classList.add('active');
217
+ });
218
+ window.addEventListener('dragleave', (event) => {
219
+ if (event.relatedTarget === null) {
220
+ zone.classList.remove('active');
221
+ }
222
+ });
223
+ window.addEventListener('drop', async (event) => {
224
+ event.preventDefault();
225
+ zone.classList.remove('active');
226
+ if (event.dataTransfer === null) {
227
+ return;
228
+ }
229
+ for (const file of event.dataTransfer.files) {
230
+ const records = parseJsonl(await file.text());
231
+ if (records.length === 0) {
232
+ continue;
233
+ }
234
+ if (records[0].from !== undefined && records[0].to !== undefined) {
235
+ state.droppedFiles.edges = records;
236
+ } else {
237
+ state.droppedFiles.nodes = records;
238
+ }
239
+ }
240
+ if (state.droppedFiles.nodes !== undefined && state.droppedFiles.edges !== undefined) {
241
+ setData(state.droppedFiles.nodes, state.droppedFiles.edges, 'dropped files');
242
+ } else {
243
+ el('status').textContent = 'got one file — drop the other one too';
244
+ }
245
+ });
246
+ }
247
+
248
+ /* ---------- foldable sections ---------- */
249
+
250
+ const FOLD_STORAGE_KEY = 'ktg.sidebar.folds';
251
+
252
+ /**
253
+ * Reads the persisted collapsed-by-key map, tolerating absent or malformed storage.
254
+ * @returns {Record<string, boolean>}
255
+ */
256
+ function loadFolds() {
257
+ try {
258
+ const raw = localStorage.getItem(FOLD_STORAGE_KEY);
259
+ const parsed = raw === null ? {} : JSON.parse(raw);
260
+ return parsed !== null && typeof parsed === 'object' ? parsed : {};
261
+ } catch {
262
+ return {};
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Persists the collapsed-by-key map; a no-op when storage is unavailable (private mode, file://).
268
+ * @param {Record<string, boolean>} folds
269
+ */
270
+ function saveFolds(folds) {
271
+ try {
272
+ localStorage.setItem(FOLD_STORAGE_KEY, JSON.stringify(folds));
273
+ } catch {
274
+ return;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Wires every `.foldable` sidebar header to collapse the elements that follow it
280
+ * (handled in CSS via `.collapsed ~ *`), restoring and persisting the per-section
281
+ * state in localStorage so folds survive reloads.
282
+ */
283
+ function setupFolds() {
284
+ const folds = loadFolds();
285
+ for (const rawHeader of document.querySelectorAll('#sidebar .foldable')) {
286
+ const header = /** @type {HTMLElement} */ (rawHeader);
287
+ const key = header.dataset.fold;
288
+ if (key === undefined) {
289
+ continue;
290
+ }
291
+ header.classList.toggle('collapsed', folds[key] === true);
292
+ header.addEventListener('click', () => {
293
+ folds[key] = header.classList.toggle('collapsed');
294
+ saveFolds(folds);
295
+ });
296
+ }
297
+ }
298
+
299
+ /* ---------- theme ---------- */
300
+
301
+ /**
302
+ * Reads a CSS custom property off the document root, trimmed. The Cytoscape
303
+ * style pulls its theme-dependent colours from the same variables the stylesheet
304
+ * uses, so switching theme is a single attribute flip plus a graph re-style.
305
+ * @param {string} name
306
+ * @returns {string}
307
+ */
308
+ function cssVar(name) {
309
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
310
+ }
311
+
312
+ /**
313
+ * Reads the persisted theme override, or `null` when none is set or storage is unavailable.
314
+ * @returns {'light' | 'dark' | null}
315
+ */
316
+ function storedTheme() {
317
+ try {
318
+ const value = localStorage.getItem(THEME_STORAGE_KEY);
319
+ return value === 'light' || value === 'dark' ? value : null;
320
+ } catch {
321
+ return null;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Resolves the active theme: an explicit stored choice wins, otherwise the OS
327
+ * `prefers-color-scheme`, otherwise dark.
328
+ * @returns {'light' | 'dark'}
329
+ */
330
+ function resolveTheme() {
331
+ const stored = storedTheme();
332
+ if (stored !== null) {
333
+ return stored;
334
+ }
335
+ return window.matchMedia('(prefers-color-scheme: light)').matches === true ? 'light' : 'dark';
336
+ }
337
+
338
+ /**
339
+ * Applies a theme: flips the `data-theme` attribute the stylesheet keys off,
340
+ * updates the toggle glyph, and re-styles the graph so its canvas-drawn colours
341
+ * (labels, selection ring, node borders) track the theme.
342
+ * @param {'light' | 'dark'} theme
343
+ */
344
+ function applyTheme(theme) {
345
+ document.documentElement.setAttribute('data-theme', theme);
346
+ const toggle = el('theme-toggle');
347
+ toggle.textContent = theme === 'dark' ? '☀' : '☾';
348
+ toggle.title = theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme';
349
+ if (state.cy !== undefined) {
350
+ state.cy.style(cyStyle());
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Wires the theme toggle: clicking persists and applies the opposite theme, and
356
+ * — while no explicit choice is stored — the viewer follows later OS changes.
357
+ */
358
+ function setupTheme() {
359
+ applyTheme(resolveTheme());
360
+ el('theme-toggle').addEventListener('click', () => {
361
+ const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
362
+ try {
363
+ localStorage.setItem(THEME_STORAGE_KEY, next);
364
+ } catch {
365
+ /* storage unavailable (private mode, file://) — apply for this session only */
366
+ }
367
+ applyTheme(next);
368
+ });
369
+ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (event) => {
370
+ if (storedTheme() === null) {
371
+ applyTheme(event.matches === true ? 'light' : 'dark');
372
+ }
373
+ });
374
+ }
375
+
376
+ /* ---------- graph construction ---------- */
377
+
378
+ /**
379
+ * @param {RawNode[]} nodes
380
+ * @param {RawEdge[]} edges
381
+ * @param {string} sourceLabel
382
+ */
383
+ function setData(nodes, edges, sourceLabel) {
384
+ state.nodes = nodes;
385
+ state.edges = edges;
386
+
387
+ const nodeIds = new Set(nodes.map((node) => node.id));
388
+ /** @type {Map<string, number>} */
389
+ const degree = new Map();
390
+ for (const edge of edges) {
391
+ degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1);
392
+ degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1);
393
+ }
394
+
395
+ let maxSelfMs = 0;
396
+ let measuredCount = 0;
397
+ let totalSelfMs = 0;
398
+ for (const node of nodes) {
399
+ const runtime = nodeRuntime(node);
400
+ if (runtime === undefined) {
401
+ continue;
402
+ }
403
+ const selfMs = runtime.selfMs ?? 0;
404
+ measuredCount += 1;
405
+ totalSelfMs += selfMs;
406
+ maxSelfMs = Math.max(maxSelfMs, selfMs);
407
+ }
408
+ state.runtime = { maxSelfMs, measuredCount, totalSelfMs };
409
+ state.communities = communityCounts(nodes);
410
+ state.communityLabels = communityLabels(nodes);
411
+
412
+ const elements = [
413
+ ...nodes.map((node) => ({
414
+ group: 'nodes',
415
+ 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), community: nodeCommunity(node) },
416
+ })),
417
+ ...edges
418
+ .filter((edge) => nodeIds.has(edge.from) === true && nodeIds.has(edge.to) === true)
419
+ .map((edge) => ({
420
+ group: 'edges',
421
+ data: { id: edge.id, source: edge.from, target: edge.to, kind: edge.kind, count: edgeCount(edge) },
422
+ })),
423
+ ];
424
+
425
+ if (state.cy !== undefined) {
426
+ state.cy.destroy();
427
+ }
428
+ state.cy = cytoscape({
429
+ container: el('cy'),
430
+ elements,
431
+ style: cyStyle(),
432
+ layout: layoutOptions('fcose'),
433
+ });
434
+ state.cy.on('tap', 'node', (event) => select(event.target));
435
+ state.cy.on('tap', (event) => {
436
+ if (event.target === state.cy) {
437
+ clearSelection();
438
+ }
439
+ });
440
+
441
+ buildLegends();
442
+ renderRuntime();
443
+ renderCommunities();
444
+ syncEncodingOptions();
445
+ applyFilters();
446
+ el('status').textContent = `${sourceLabel} — ${nodes.length} nodes, ${edges.length} edges`;
447
+ }
448
+
449
+ function cyStyle() {
450
+ const unmeasuredFill = cssVar('--unmeasured-fill');
451
+ const unmeasuredBorder = cssVar('--unmeasured-border');
452
+ const nodeBorder = cssVar('--graph-node-border');
453
+ const nodeBorderWidth = parseFloat(cssVar('--graph-node-border-width')) || 0;
454
+ const labelColor = cssVar('--graph-label');
455
+ const labelBg = cssVar('--graph-label-bg');
456
+ const selBorder = cssVar('--graph-sel-border');
457
+ /** @param {CyCollection} node */
458
+ const nodeColor = (node) => {
459
+ if (state.encoding === 'runtime') {
460
+ const runtime = node.data('runtime');
461
+ return runtime === undefined || runtime === null ? unmeasuredFill : heatColor(runtimeFraction(runtime.selfMs));
462
+ }
463
+ if (state.encoding === 'community') {
464
+ const community = node.data('community');
465
+ return community === undefined || community === null ? unmeasuredFill : communityColor(community);
466
+ }
467
+ return NODE_COLORS[node.data('kind')] ?? '#9ca3af';
468
+ };
469
+ /** @param {CyCollection} node */
470
+ const nodeSize = (node) => {
471
+ if (state.encoding !== 'runtime') {
472
+ return 8 + Math.sqrt(node.data('degree')) * 4;
473
+ }
474
+ const runtime = node.data('runtime');
475
+ if (runtime === undefined || runtime === null) {
476
+ return 10;
477
+ }
478
+ return 12 + runtimeFraction(runtime.selfMs) * 40;
479
+ };
480
+ /**
481
+ * Whether the active encoding has no value for this node — un-measured in
482
+ * runtime mode, or unassigned to a community in community mode. Such nodes get
483
+ * the muted fill and a dashed border so the gap reads as "no data", not a colour.
484
+ * @param {CyCollection} node
485
+ */
486
+ const isUnencoded = (node) =>
487
+ (state.encoding === 'runtime' && (node.data('runtime') === undefined || node.data('runtime') === null))
488
+ || (state.encoding === 'community' && (node.data('community') === undefined || node.data('community') === null));
489
+ return [
490
+ {
491
+ selector: 'node',
492
+ style: {
493
+ 'background-color': nodeColor,
494
+ 'width': nodeSize,
495
+ 'height': nodeSize,
496
+ 'border-width': (/** @type {CyCollection} */ node) => isUnencoded(node) === true ? 1 : nodeBorderWidth,
497
+ 'border-color': (/** @type {CyCollection} */ node) => isUnencoded(node) === true ? unmeasuredBorder : nodeBorder,
498
+ 'border-style': (/** @type {CyCollection} */ node) => isUnencoded(node) === true ? 'dashed' : 'solid',
499
+ 'label': 'data(name)',
500
+ 'color': labelColor,
501
+ 'font-size': 8,
502
+ 'min-zoomed-font-size': 7,
503
+ 'text-valign': 'bottom',
504
+ 'text-margin-y': 3,
505
+ 'text-background-color': labelBg,
506
+ 'text-background-opacity': 0.5,
507
+ 'text-background-shape': 'roundrectangle',
508
+ 'text-background-padding': 2,
509
+ },
510
+ },
511
+ {
512
+ selector: 'edge',
513
+ style: {
514
+ 'width': (/** @type {CyCollection} */ edge) => edgeWidth(edge.data('count')),
515
+ 'line-color': (/** @type {CyCollection} */ edge) => EDGE_COLORS[edge.data('kind')] ?? '#475569',
516
+ 'target-arrow-color': (/** @type {CyCollection} */ edge) => EDGE_COLORS[edge.data('kind')] ?? '#475569',
517
+ 'target-arrow-shape': 'triangle',
518
+ 'arrow-scale': 0.6,
519
+ 'curve-style': 'bezier',
520
+ 'opacity': 0.65,
521
+ },
522
+ },
523
+ { selector: '.hidden', style: { display: 'none' } },
524
+ { selector: '.faded', style: { opacity: 0.08, 'text-opacity': 0, 'text-background-opacity': 0 } },
525
+ { selector: 'node.sel', style: { 'border-width': 3, 'border-color': selBorder, 'border-style': 'solid' } },
526
+ ];
527
+ }
528
+
529
+ /**
530
+ * Builds Cytoscape layout options for the given layout name. The force layouts
531
+ * (`fcose`, `cose`) are made label-aware via `nodeDimensionsIncludeLabels`, so each
532
+ * node's label box is factored into spacing and labels overlap their neighbours less.
533
+ * @param {string} name
534
+ * @returns {Record<string, unknown>}
535
+ */
536
+ function layoutOptions(name) {
537
+ const base = { name, animate: false, padding: 30 };
538
+ if (name === 'fcose' || name === 'cose') {
539
+ return { ...base, nodeDimensionsIncludeLabels: true };
540
+ }
541
+ if (name === 'concentric') {
542
+ return { ...base, concentric: (/** @type {CyCollection} */ node) => node.degree(), levelWidth: () => 2 };
543
+ }
544
+ return base;
545
+ }
546
+
547
+ function runLayout() {
548
+ const cy = state.cy;
549
+ if (cy === undefined) {
550
+ return;
551
+ }
552
+ const name = selectEl('layout-select').value;
553
+ cy.elements(':visible').layout(layoutOptions(name)).run();
554
+ }
555
+
556
+ /* ---------- edge weighting ---------- */
557
+
558
+ /**
559
+ * Reads the call-site multiplicity off a raw edge's metadata; defaults to 1 when absent.
560
+ * @param {RawEdge} edge
561
+ * @returns {number}
562
+ */
563
+ function edgeCount(edge) {
564
+ if (edge.metadata === undefined || edge.metadata === null) {
565
+ return 1;
566
+ }
567
+ const count = edge.metadata.count;
568
+ return typeof count === 'number' && count > 0 ? count : 1;
569
+ }
570
+
571
+ /**
572
+ * Maps a call-site count to a stroke width: count 1 keeps the baseline, higher counts thicken sub-linearly.
573
+ * @param {number} count
574
+ * @returns {number}
575
+ */
576
+ function edgeWidth(count) {
577
+ const value = typeof count === 'number' && count > 0 ? count : 1;
578
+ return 1 + Math.sqrt(value - 1) * 1.8;
579
+ }
580
+
581
+ /* ---------- legends & filtering ---------- */
582
+
583
+ function buildLegends() {
584
+ const nodeCounts = countBy(state.nodes.map((node) => node.kind));
585
+ const edgeCounts = countBy(state.edges.map((edge) => edge.kind));
586
+ renderLegend(el('node-kinds'), nodeCounts, NODE_COLORS, state.hiddenNodeKinds, KIND_DESCRIPTIONS.nodes);
587
+ renderLegend(el('edge-kinds'), edgeCounts, EDGE_COLORS, state.hiddenEdgeKinds, KIND_DESCRIPTIONS.edges);
588
+ }
589
+
590
+ /**
591
+ * @param {HTMLElement} container
592
+ * @param {[string, number][]} counts
593
+ * @param {Record<string, string>} colors
594
+ * @param {Set<string>} hiddenSet
595
+ * @param {Record<string, string>} descriptions
596
+ */
597
+ function renderLegend(container, counts, colors, hiddenSet, descriptions) {
598
+ container.innerHTML = '';
599
+ const kinds = counts.map(([kind]) => kind);
600
+ /** @type {HTMLInputElement[]} */
601
+ const childCheckboxes = [];
602
+
603
+ /* Master toggle: checked when every kind is visible, indeterminate on a mixed
604
+ selection. Clicking it reveals all kinds, or hides all when none are hidden. */
605
+ const master = document.createElement('input');
606
+ master.type = 'checkbox';
607
+ const syncMaster = () => {
608
+ const hiddenCount = kinds.filter((kind) => hiddenSet.has(kind) === true).length;
609
+ master.checked = hiddenCount === 0;
610
+ master.indeterminate = hiddenCount > 0 && hiddenCount < kinds.length;
611
+ };
612
+
613
+ if (kinds.length > 0) {
614
+ master.addEventListener('change', () => {
615
+ const allVisible = kinds.every((kind) => hiddenSet.has(kind) === false);
616
+ for (const kind of kinds) {
617
+ if (allVisible === true) {
618
+ hiddenSet.add(kind);
619
+ } else {
620
+ hiddenSet.delete(kind);
621
+ }
622
+ }
623
+ for (const child of childCheckboxes) {
624
+ child.checked = hiddenSet.has(child.dataset.kind ?? '') === false;
625
+ }
626
+ syncMaster();
627
+ applyFilters();
628
+ });
629
+ const masterLabel = document.createElement('label');
630
+ masterLabel.className = 'master';
631
+ masterLabel.title = 'show or hide every kind';
632
+ const spacer = document.createElement('span');
633
+ spacer.className = 'swatch spacer';
634
+ const text = document.createElement('span');
635
+ text.textContent = 'all';
636
+ masterLabel.append(master, spacer, text);
637
+ container.appendChild(masterLabel);
638
+ }
639
+
640
+ for (const [kind, count] of counts) {
641
+ const label = document.createElement('label');
642
+ const checkbox = document.createElement('input');
643
+ checkbox.type = 'checkbox';
644
+ checkbox.dataset.kind = kind;
645
+ checkbox.checked = hiddenSet.has(kind) === false;
646
+ checkbox.addEventListener('change', () => {
647
+ if (checkbox.checked === true) {
648
+ hiddenSet.delete(kind);
649
+ } else {
650
+ hiddenSet.add(kind);
651
+ }
652
+ syncMaster();
653
+ applyFilters();
654
+ });
655
+ childCheckboxes.push(checkbox);
656
+ const swatch = document.createElement('span');
657
+ swatch.className = 'swatch';
658
+ swatch.style.background = colors[kind] ?? '#9ca3af';
659
+ const text = document.createElement('span');
660
+ text.textContent = kind;
661
+ const countSpan = document.createElement('span');
662
+ countSpan.className = 'count';
663
+ countSpan.textContent = String(count);
664
+ label.append(checkbox, swatch, text);
665
+ const description = descriptions?.[kind];
666
+ if (typeof description === 'string' && description.length > 0) {
667
+ label.append(makeHelpBadge(kind, description));
668
+ }
669
+ label.append(countSpan);
670
+ container.appendChild(label);
671
+ }
672
+
673
+ syncMaster();
674
+ }
675
+
676
+ /**
677
+ * Builds the `?` help badge shown after a legend kind. Clicks are swallowed so
678
+ * the badge never toggles the surrounding filter checkbox; hover and keyboard
679
+ * focus reveal the shared tooltip with the kind's description.
680
+ * @param {string} kind
681
+ * @param {string} description
682
+ * @returns {HTMLSpanElement}
683
+ */
684
+ function makeHelpBadge(kind, description) {
685
+ const badge = document.createElement('span');
686
+ badge.className = 'help-badge';
687
+ badge.textContent = '?';
688
+ badge.tabIndex = 0;
689
+ badge.setAttribute('role', 'img');
690
+ badge.setAttribute('aria-label', `${kind}: ${description}`);
691
+ badge.addEventListener('click', (event) => {
692
+ event.preventDefault();
693
+ event.stopPropagation();
694
+ });
695
+ badge.addEventListener('mouseenter', () => showTooltip(badge, description));
696
+ badge.addEventListener('mouseleave', hideTooltip);
697
+ badge.addEventListener('focus', () => showTooltip(badge, description));
698
+ badge.addEventListener('blur', hideTooltip);
699
+ return badge;
700
+ }
701
+
702
+ /* ---------- hover tooltips ---------- */
703
+
704
+ /** @type {HTMLElement | undefined} */
705
+ let tooltipEl;
706
+
707
+ /** Lazily creates the single shared tooltip element, appended to <body> so the sidebar's overflow cannot clip it. */
708
+ function ensureTooltip() {
709
+ if (tooltipEl === undefined) {
710
+ tooltipEl = document.createElement('div');
711
+ tooltipEl.className = 'kind-tooltip';
712
+ tooltipEl.hidden = true;
713
+ document.body.appendChild(tooltipEl);
714
+ }
715
+ return tooltipEl;
716
+ }
717
+
718
+ /**
719
+ * Shows the shared tooltip just below an anchor, flipping above / clamping horizontally to stay within the viewport.
720
+ * @param {HTMLElement} anchor
721
+ * @param {string} text
722
+ */
723
+ function showTooltip(anchor, text) {
724
+ const tip = ensureTooltip();
725
+ tip.textContent = text;
726
+ tip.hidden = false;
727
+ const rect = anchor.getBoundingClientRect();
728
+ const margin = 8;
729
+ let top = rect.bottom + 6;
730
+ if (top + tip.offsetHeight > window.innerHeight - margin) {
731
+ top = Math.max(margin, rect.top - tip.offsetHeight - 6);
732
+ }
733
+ const left = Math.max(margin, Math.min(rect.left, window.innerWidth - tip.offsetWidth - margin));
734
+ tip.style.top = `${top}px`;
735
+ tip.style.left = `${left}px`;
736
+ }
737
+
738
+ function hideTooltip() {
739
+ if (tooltipEl !== undefined) {
740
+ tooltipEl.hidden = true;
741
+ }
742
+ }
743
+
744
+ function applyFilters() {
745
+ const cy = state.cy;
746
+ if (cy === undefined) {
747
+ return;
748
+ }
749
+ cy.batch(() => {
750
+ cy.nodes().forEach((node) => {
751
+ const hiddenByKind = state.hiddenNodeKinds.has(node.data('kind')) === true;
752
+ const unmeasured = node.data('runtime') === undefined || node.data('runtime') === null;
753
+ const hiddenByMeasure = state.onlyMeasured === true && unmeasured === true;
754
+ const community = node.data('community');
755
+ const hiddenByCommunity = community !== undefined && community !== null && state.hiddenCommunities.has(community) === true;
756
+ node.toggleClass('hidden', hiddenByKind === true || hiddenByMeasure === true || hiddenByCommunity === true);
757
+ });
758
+ cy.edges().forEach((edge) => {
759
+ edge.toggleClass('hidden', state.hiddenEdgeKinds.has(edge.data('kind')) === true);
760
+ });
761
+ if (state.hideIsolated === true) {
762
+ cy.nodes().not('.hidden').forEach((node) => {
763
+ const hasVisibleEdge = node.connectedEdges().some((edge) =>
764
+ edge.hasClass('hidden') === false
765
+ && edge.source().hasClass('hidden') === false
766
+ && edge.target().hasClass('hidden') === false);
767
+ if (hasVisibleEdge === false) {
768
+ node.addClass('hidden');
769
+ }
770
+ });
771
+ }
772
+ });
773
+ }
774
+
775
+ /**
776
+ * @param {string[]} values
777
+ * @returns {[string, number][]}
778
+ */
779
+ function countBy(values) {
780
+ /** @type {Map<string, number>} */
781
+ const counts = new Map();
782
+ for (const value of values) {
783
+ counts.set(value, (counts.get(value) ?? 0) + 1);
784
+ }
785
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
786
+ }
787
+
788
+ /* ---------- runtime ---------- */
789
+
790
+ /**
791
+ * Reads the `metadata.runtime` metrics off a raw node, or `undefined` if un-measured.
792
+ * @param {RawNode} node
793
+ * @returns {NodeRuntime | undefined}
794
+ */
795
+ function nodeRuntime(node) {
796
+ if (node.metadata === undefined || node.metadata === null) {
797
+ return undefined;
798
+ }
799
+ const runtime = node.metadata.runtime;
800
+ return runtime === undefined || runtime === null ? undefined : runtime;
801
+ }
802
+
803
+ /**
804
+ * Maps a self-time to [0, 1] on a square-root scale so mid-range hotspots stay visible.
805
+ * @param {number | undefined} selfMs
806
+ * @returns {number}
807
+ */
808
+ function runtimeFraction(selfMs) {
809
+ const max = state.runtime.maxSelfMs;
810
+ if (max <= 0) {
811
+ return 0;
812
+ }
813
+ return Math.sqrt(Math.max(0, selfMs ?? 0) / max);
814
+ }
815
+
816
+ /**
817
+ * Interpolates the heat ramp at the given fraction, returning an `rgb(...)` string.
818
+ * @param {number} fraction
819
+ * @returns {string}
820
+ */
821
+ function heatColor(fraction) {
822
+ const f = Math.min(1, Math.max(0, fraction));
823
+ let lo = HEAT_STOPS[0];
824
+ let hi = HEAT_STOPS[HEAT_STOPS.length - 1];
825
+ for (let i = 0; i < HEAT_STOPS.length - 1; i += 1) {
826
+ if (f >= HEAT_STOPS[i].at && f <= HEAT_STOPS[i + 1].at) {
827
+ lo = HEAT_STOPS[i];
828
+ hi = HEAT_STOPS[i + 1];
829
+ break;
830
+ }
831
+ }
832
+ const span = hi.at - lo.at || 1;
833
+ const t = (f - lo.at) / span;
834
+ /** @param {number} index */
835
+ const channel = (index) => Math.round(lo.color[index] + (hi.color[index] - lo.color[index]) * t);
836
+ return `rgb(${channel(0)}, ${channel(1)}, ${channel(2)})`;
837
+ }
838
+
839
+ /**
840
+ * Human-readable self-time: seconds above 1 s, otherwise milliseconds.
841
+ * @param {number} ms
842
+ * @returns {string}
843
+ */
844
+ function formatMs(ms) {
845
+ if (ms >= 1000) {
846
+ return `${(ms / 1000).toFixed(1)} s`;
847
+ }
848
+ if (ms >= 1) {
849
+ return `${ms.toFixed(0)} ms`;
850
+ }
851
+ return `${ms.toFixed(2)} ms`;
852
+ }
853
+
854
+ /**
855
+ * Centers and selects a node by id — shared by the hotspots list and search results.
856
+ * @param {string} id
857
+ */
858
+ function focusNode(id) {
859
+ const cy = state.cy;
860
+ if (cy === undefined) {
861
+ return;
862
+ }
863
+ const node = cy.getElementById(id);
864
+ if (node.length === 1) {
865
+ select(node);
866
+ cy.animate({ center: { eles: node }, zoom: 2 }, { duration: 350 });
867
+ }
868
+ }
869
+
870
+ /** Renders the coverage line and the ranked hotspots list from the loaded runtime metrics. */
871
+ function renderRuntime() {
872
+ const section = el('runtime');
873
+ const measured = state.nodes
874
+ .map((node) => ({ node, runtime: nodeRuntime(node) }))
875
+ .filter((entry) => entry.runtime !== undefined)
876
+ .sort((a, b) => (b.runtime?.selfMs ?? 0) - (a.runtime?.selfMs ?? 0));
877
+
878
+ if (measured.length === 0) {
879
+ section.classList.add('empty');
880
+ el('coverage').textContent = 'no runtime data — run `enrich` to measure self-time';
881
+ state.onlyMeasured = false;
882
+ inputEl('only-measured').checked = false;
883
+ el('hotspots').innerHTML = '';
884
+ return;
885
+ }
886
+
887
+ section.classList.remove('empty');
888
+ inputEl('only-measured').disabled = false;
889
+ el('coverage').textContent = `${state.runtime.measuredCount} / ${state.nodes.length} nodes measured · ${formatMs(state.runtime.totalSelfMs)} total self-time`;
890
+
891
+ const list = el('hotspots');
892
+ list.innerHTML = '';
893
+ for (const { node, runtime } of measured.slice(0, HOTSPOTS_LIMIT)) {
894
+ const row = document.createElement('div');
895
+ row.className = 'hotspot';
896
+ 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>`;
897
+ row.addEventListener('click', () => focusNode(node.id));
898
+ list.appendChild(row);
899
+ }
900
+ }
901
+
902
+ /* ---------- community ---------- */
903
+
904
+ /**
905
+ * Reads the integer community index `cluster` attaches as `metadata.community`,
906
+ * or `undefined` when the graph has not been clustered.
907
+ * @param {RawNode} node
908
+ * @returns {number | undefined}
909
+ */
910
+ function nodeCommunity(node) {
911
+ if (node.metadata === undefined || node.metadata === null) {
912
+ return undefined;
913
+ }
914
+ const community = node.metadata.community;
915
+ return typeof community === 'number' ? community : undefined;
916
+ }
917
+
918
+ /**
919
+ * Reads the human-readable community label `cluster` attaches as
920
+ * `metadata.communityLabel`. Every clustered node carries one, written alongside
921
+ * its community index, so this is defined whenever {@link nodeCommunity} is.
922
+ * @param {RawNode} node
923
+ * @returns {string | undefined}
924
+ */
925
+ function nodeCommunityLabel(node) {
926
+ if (node.metadata === undefined || node.metadata === null) {
927
+ return undefined;
928
+ }
929
+ const label = node.metadata.communityLabel;
930
+ return typeof label === 'string' ? label : undefined;
931
+ }
932
+
933
+ /**
934
+ * A stable, theme-independent colour per community index, spread around the hue
935
+ * circle by the golden angle so adjacent indices stay distinct. Fixed
936
+ * saturation/lightness keep it legible on both the light and dark canvas, like
937
+ * the kind palette.
938
+ * @param {number} index
939
+ * @returns {string}
940
+ */
941
+ function communityColor(index) {
942
+ const hue = Math.round((index * 137.508) % 360);
943
+ return `hsl(${hue}, 65%, 55%)`;
944
+ }
945
+
946
+ /**
947
+ * Counts members per community across the loaded nodes, as `[index, count]`
948
+ * pairs sorted by size descending (the order `cluster` reports them in).
949
+ * @param {RawNode[]} nodes
950
+ * @returns {[number, number][]}
951
+ */
952
+ function communityCounts(nodes) {
953
+ /** @type {Map<number, number>} */
954
+ const counts = new Map();
955
+ for (const node of nodes) {
956
+ const community = nodeCommunity(node);
957
+ if (community !== undefined) {
958
+ counts.set(community, (counts.get(community) ?? 0) + 1);
959
+ }
960
+ }
961
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
962
+ }
963
+
964
+ /**
965
+ * Maps each community index to its label, read from the first member node seen —
966
+ * `cluster` writes the same label onto every member, so one read per community
967
+ * suffices.
968
+ * @param {RawNode[]} nodes
969
+ * @returns {Map<number, string>}
970
+ */
971
+ function communityLabels(nodes) {
972
+ /** @type {Map<number, string>} */
973
+ const labels = new Map();
974
+ for (const node of nodes) {
975
+ const community = nodeCommunity(node);
976
+ if (community === undefined || labels.has(community) === true) {
977
+ continue;
978
+ }
979
+ const label = nodeCommunityLabel(node);
980
+ if (label !== undefined) {
981
+ labels.set(community, label);
982
+ }
983
+ }
984
+ return labels;
985
+ }
986
+
987
+ /**
988
+ * Renders the Communities legend as visibility filters — a checkbox + swatch +
989
+ * member count per community, plus a master "all" toggle — so a community can be
990
+ * shown or hidden on the graph, mirroring the node/edge kind legends. The section
991
+ * is hidden when the graph is un-clustered.
992
+ */
993
+ function renderCommunities() {
994
+ const section = el('communities');
995
+ const container = el('community-legend');
996
+ container.innerHTML = '';
997
+ if (state.communities.length === 0) {
998
+ section.classList.add('empty');
999
+ return;
1000
+ }
1001
+ section.classList.remove('empty');
1002
+
1003
+ const indices = state.communities.map(([index]) => index);
1004
+ /** @type {HTMLInputElement[]} */
1005
+ const childCheckboxes = [];
1006
+
1007
+ const master = document.createElement('input');
1008
+ master.type = 'checkbox';
1009
+ const syncMaster = () => {
1010
+ const hiddenCount = indices.filter((index) => state.hiddenCommunities.has(index) === true).length;
1011
+ master.checked = hiddenCount === 0;
1012
+ master.indeterminate = hiddenCount > 0 && hiddenCount < indices.length;
1013
+ };
1014
+ master.addEventListener('change', () => {
1015
+ const allVisible = indices.every((index) => state.hiddenCommunities.has(index) === false);
1016
+ for (const index of indices) {
1017
+ if (allVisible === true) {
1018
+ state.hiddenCommunities.add(index);
1019
+ } else {
1020
+ state.hiddenCommunities.delete(index);
1021
+ }
1022
+ }
1023
+ for (const child of childCheckboxes) {
1024
+ child.checked = state.hiddenCommunities.has(Number(child.dataset.community)) === false;
1025
+ }
1026
+ syncMaster();
1027
+ applyFilters();
1028
+ });
1029
+ const masterLabel = document.createElement('label');
1030
+ masterLabel.className = 'master';
1031
+ masterLabel.title = 'show or hide every community';
1032
+ const spacer = document.createElement('span');
1033
+ spacer.className = 'swatch spacer';
1034
+ const masterText = document.createElement('span');
1035
+ masterText.textContent = 'all';
1036
+ masterLabel.append(master, spacer, masterText);
1037
+ container.appendChild(masterLabel);
1038
+
1039
+ for (const [index, count] of state.communities) {
1040
+ const row = document.createElement('label');
1041
+ const checkbox = document.createElement('input');
1042
+ checkbox.type = 'checkbox';
1043
+ checkbox.dataset.community = String(index);
1044
+ checkbox.checked = state.hiddenCommunities.has(index) === false;
1045
+ checkbox.addEventListener('change', () => {
1046
+ if (checkbox.checked === true) {
1047
+ state.hiddenCommunities.delete(index);
1048
+ } else {
1049
+ state.hiddenCommunities.add(index);
1050
+ }
1051
+ syncMaster();
1052
+ applyFilters();
1053
+ });
1054
+ childCheckboxes.push(checkbox);
1055
+ const swatch = document.createElement('span');
1056
+ swatch.className = 'swatch';
1057
+ swatch.style.background = communityColor(index);
1058
+ const text = document.createElement('span');
1059
+ text.textContent = /** @type {string} */ (state.communityLabels.get(index));
1060
+ const countSpan = document.createElement('span');
1061
+ countSpan.className = 'count';
1062
+ countSpan.textContent = String(count);
1063
+ row.append(checkbox, swatch, text, countSpan);
1064
+ container.appendChild(row);
1065
+ }
1066
+
1067
+ syncMaster();
1068
+ }
1069
+
1070
+ /**
1071
+ * Enables the `self-time` and `community` colour modes only when the loaded
1072
+ * graph carries that data, falls back to `structural` if the active mode lost
1073
+ * its data, mirrors the choice into the `<select>`, and re-applies the style.
1074
+ */
1075
+ function syncEncodingOptions() {
1076
+ const select = selectEl('encoding-select');
1077
+ /**
1078
+ * @param {string} value
1079
+ * @param {boolean} enabled
1080
+ */
1081
+ const setEnabled = (value, enabled) => {
1082
+ const option = select.querySelector(`option[value="${value}"]`);
1083
+ if (option instanceof HTMLOptionElement) {
1084
+ option.disabled = enabled === false;
1085
+ }
1086
+ };
1087
+ setEnabled('runtime', state.runtime.measuredCount > 0);
1088
+ setEnabled('community', state.communities.length > 0);
1089
+ if ((state.encoding === 'runtime' && state.runtime.measuredCount === 0)
1090
+ || (state.encoding === 'community' && state.communities.length === 0)) {
1091
+ state.encoding = 'structural';
1092
+ }
1093
+ select.value = state.encoding;
1094
+ if (state.cy !== undefined) {
1095
+ state.cy.style(cyStyle());
1096
+ }
1097
+ }
1098
+
1099
+ /**
1100
+ * Narrows an arbitrary `<select>` value to a known encoding mode, defaulting to `structural`.
1101
+ * @param {string} value
1102
+ * @returns {'structural' | 'runtime' | 'community'}
1103
+ */
1104
+ function encodingFromValue(value) {
1105
+ return value === 'runtime' || value === 'community' ? value : 'structural';
1106
+ }
1107
+
1108
+ /* ---------- search ---------- */
1109
+
1110
+ function renderSearchResults() {
1111
+ const query = inputEl('search').value.trim().toLowerCase();
1112
+ const container = el('search-results');
1113
+ container.innerHTML = '';
1114
+ if (query.length < 2) {
1115
+ return;
1116
+ }
1117
+ const hits = state.nodes
1118
+ .filter((node) => node.name.toLowerCase().includes(query) === true || node.filePath.toLowerCase().includes(query) === true)
1119
+ .slice(0, 15);
1120
+ for (const hit of hits) {
1121
+ const row = document.createElement('div');
1122
+ row.className = 'hit';
1123
+ row.innerHTML = `${escapeHtml(hit.name)} <span class="loc">${escapeHtml(hit.kind)} · ${escapeHtml(hit.filePath)}</span>`;
1124
+ row.addEventListener('click', () => focusNode(hit.id));
1125
+ container.appendChild(row);
1126
+ }
1127
+ }
1128
+
1129
+ /* ---------- selection & details ---------- */
1130
+
1131
+ /** @param {CyCollection} node */
1132
+ function select(node) {
1133
+ const cy = state.cy;
1134
+ if (cy === undefined) {
1135
+ return;
1136
+ }
1137
+ cy.elements().addClass('faded').removeClass('sel');
1138
+ const hood = node.closedNeighborhood();
1139
+ hood.removeClass('faded');
1140
+ node.addClass('sel');
1141
+ renderDetails(node);
1142
+ }
1143
+
1144
+ function clearSelection() {
1145
+ if (state.cy !== undefined) {
1146
+ state.cy.elements().removeClass('faded sel');
1147
+ }
1148
+ el('details-body').textContent = 'click a node';
1149
+ }
1150
+
1151
+ /* Real source files we can link to GitHub; external modules, `process.env`, and API hosts carry synthetic paths. */
1152
+ const SOURCE_FILE_PATTERN = /\.(?:tsx?|mts|cts|jsx?|mjs|cjs)$/;
1153
+
1154
+ /**
1155
+ * Builds a GitHub permalink for a node's file at the analysed commit, or
1156
+ * `undefined` when no source was configured (server-side `--source`) or the path
1157
+ * is not a real source file. Line anchors are added only when a start line is known.
1158
+ * @param {unknown} filePath
1159
+ * @param {number} startLine
1160
+ * @returns {string | undefined}
1161
+ */
1162
+ function githubFileUrl(filePath, startLine) {
1163
+ const source = window.GRAPH_SOURCE;
1164
+ if (source === undefined || source === null || source.github === undefined) {
1165
+ return undefined;
1166
+ }
1167
+ if (typeof filePath !== 'string' || SOURCE_FILE_PATTERN.test(filePath) === false) {
1168
+ return undefined;
1169
+ }
1170
+ const { baseUrl, commit, prefix } = source.github;
1171
+ const encoded = `${prefix ?? ''}${filePath}`.split('/').map((segment) => encodeURIComponent(segment)).join('/');
1172
+ const anchor = startLine > 0 ? `#L${startLine}` : '';
1173
+ return `${baseUrl}/blob/${commit}/${encoded}${anchor}`;
1174
+ }
1175
+
1176
+ /** @param {CyCollection} node */
1177
+ function renderDetails(node) {
1178
+ const id = node.id();
1179
+ const color = NODE_COLORS[node.data('kind')] ?? '#9ca3af';
1180
+ const outgoing = state.edges.filter((edge) => edge.from === id);
1181
+ const incoming = state.edges.filter((edge) => edge.to === id);
1182
+ const nodeById = new Map(state.nodes.map((entry) => /** @type {[string, RawNode]} */ ([entry.id, entry])));
1183
+
1184
+ /**
1185
+ * @param {RawEdge[]} edges
1186
+ * @param {'out' | 'in'} direction
1187
+ */
1188
+ const renderEdgeRows = (edges, direction) => edges.map((edge) => {
1189
+ const otherId = direction === 'out' ? edge.to : edge.from;
1190
+ const other = nodeById.get(otherId);
1191
+ const name = other === undefined ? otherId : other.name;
1192
+ const arrow = direction === 'out' ? '→' : '←';
1193
+ const count = edgeCount(edge);
1194
+ const countBadge = count > 1 ? ` <span class="edge-count">×${count}</span>` : '';
1195
+ return `<div class="edge-row"><span class="edge-kind">${escapeHtml(edge.kind)}</span>${countBadge} ${arrow} <a data-target="${escapeHtml(otherId)}">${escapeHtml(name)}</a></div>`;
1196
+ }).join('');
1197
+
1198
+ const runtime = node.data('runtime');
1199
+ const runtimeBlock = runtime === undefined || runtime === null ? '' : `
1200
+ <div class="runtime-block">
1201
+ <h3>runtime</h3>
1202
+ <div class="metric"><span>self-time</span><strong>${escapeHtml(formatMs(runtime.selfMs ?? 0))}</strong></div>
1203
+ <div class="metric"><span>samples</span><strong>${escapeHtml(String(runtime.samples ?? 0))}</strong></div>
1204
+ <div class="metric"><span>source</span><strong>${escapeHtml(String(runtime.source ?? '—'))}</strong></div>
1205
+ </div>`;
1206
+
1207
+ const filePath = node.data('filePath');
1208
+ const startLine = node.data('startLine');
1209
+ const locationText = `${filePath}${startLine > 0 ? ':' + startLine : ''}`;
1210
+ const fileUrl = githubFileUrl(filePath, startLine);
1211
+ const locationHtml = fileUrl === undefined
1212
+ ? escapeHtml(locationText)
1213
+ : `<a class="file-link" href="${escapeHtml(fileUrl)}" target="_blank" rel="noopener noreferrer" title="open on GitHub">${escapeHtml(locationText)}</a>`;
1214
+
1215
+ el('details-body').innerHTML = `
1216
+ <div><span class="kind-tag" style="background:${color}">${escapeHtml(node.data('kind'))}</span> <strong>${escapeHtml(node.data('name'))}</strong></div>
1217
+ <div>${locationHtml}</div>
1218
+ <div class="id">${escapeHtml(id)}</div>
1219
+ ${runtimeBlock}
1220
+ <h3>outgoing (${outgoing.length})</h3>${renderEdgeRows(outgoing, 'out')}
1221
+ <h3>incoming (${incoming.length})</h3>${renderEdgeRows(incoming, 'in')}
1222
+ `;
1223
+ el('details-body').querySelectorAll('a[data-target]').forEach((link) => {
1224
+ link.addEventListener('click', () => {
1225
+ const cy = state.cy;
1226
+ if (cy === undefined) {
1227
+ return;
1228
+ }
1229
+ const anchor = /** @type {HTMLElement} */ (link);
1230
+ const target = cy.getElementById(anchor.dataset.target ?? '');
1231
+ if (target.length === 1) {
1232
+ select(target);
1233
+ cy.animate({ center: { eles: target } }, { duration: 300 });
1234
+ }
1235
+ });
1236
+ });
1237
+ }
1238
+
1239
+ const ESCAPE_REPLACEMENTS = /** @type {Record<string, string>} */ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' });
1240
+
1241
+ /**
1242
+ * @param {unknown} value
1243
+ * @returns {string}
1244
+ */
1245
+ function escapeHtml(value) {
1246
+ return String(value).replace(/[&<>"']/g, (char) => ESCAPE_REPLACEMENTS[char]);
1247
+ }
1248
+
1249
+ boot();