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.
- package/README.md +74 -34
- package/contribs/web_visualisation/README.md +28 -0
- package/contribs/web_visualisation/web/css/style.css +104 -0
- package/contribs/web_visualisation/web/data/.gitignore +1 -0
- package/contribs/web_visualisation/web/data/kind_descriptions.js +38 -0
- package/contribs/web_visualisation/web/index.html +20 -4
- package/contribs/web_visualisation/web/js/app.js +581 -35
- package/contribs/web_visualisation/web/tsconfig.json +18 -0
- package/contribs/web_visualisation/web/types/app_globals.d.ts +146 -0
- package/dist/benchmark/benchmark_stats.d.ts +41 -0
- package/dist/benchmark/benchmark_stats.d.ts.map +1 -0
- package/dist/benchmark/benchmark_stats.js +61 -0
- package/dist/benchmark/benchmark_stats.js.map +1 -0
- package/dist/benchmark/node_benchmark.d.ts +78 -0
- package/dist/benchmark/node_benchmark.d.ts.map +1 -0
- package/dist/benchmark/node_benchmark.js +112 -0
- package/dist/benchmark/node_benchmark.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/benchmark_command.d.ts +11 -0
- package/dist/commands/benchmark_command.d.ts.map +1 -0
- package/dist/commands/benchmark_command.js +91 -0
- package/dist/commands/benchmark_command.js.map +1 -0
- package/dist/commands/blast_radius_command.js +1 -1
- package/dist/commands/blast_radius_command.js.map +1 -1
- package/dist/commands/cost_command.d.ts +13 -0
- package/dist/commands/cost_command.d.ts.map +1 -0
- package/dist/commands/cost_command.js +122 -0
- package/dist/commands/cost_command.js.map +1 -0
- package/dist/commands/{load.d.ts → enrich_command.d.ts} +3 -2
- package/dist/commands/enrich_command.d.ts.map +1 -0
- package/dist/commands/enrich_command.js +62 -0
- package/dist/commands/enrich_command.js.map +1 -0
- package/dist/commands/hotspots_command.d.ts +7 -0
- package/dist/commands/hotspots_command.d.ts.map +1 -0
- package/dist/commands/hotspots_command.js +67 -0
- package/dist/commands/hotspots_command.js.map +1 -0
- package/dist/commands/install_command.d.ts +2 -3
- package/dist/commands/install_command.d.ts.map +1 -1
- package/dist/commands/install_command.js +7 -8
- package/dist/commands/install_command.js.map +1 -1
- package/dist/commands/load_command.d.ts.map +1 -1
- package/dist/commands/load_command.js +2 -0
- package/dist/commands/load_command.js.map +1 -1
- package/dist/commands/verify_command.d.ts +8 -0
- package/dist/commands/verify_command.d.ts.map +1 -0
- package/dist/commands/verify_command.js +57 -0
- package/dist/commands/verify_command.js.map +1 -0
- package/dist/commands/web_command.d.ts +27 -0
- package/dist/commands/web_command.d.ts.map +1 -1
- package/dist/commands/web_command.js +109 -3
- package/dist/commands/web_command.js.map +1 -1
- package/dist/enrich/cpu_profile.d.ts +127 -0
- package/dist/enrich/cpu_profile.d.ts.map +1 -0
- package/dist/enrich/cpu_profile.js +97 -0
- package/dist/enrich/cpu_profile.js.map +1 -0
- package/dist/enrich/runtime_enricher.d.ts +56 -0
- package/dist/enrich/runtime_enricher.d.ts.map +1 -0
- package/dist/enrich/runtime_enricher.js +80 -0
- package/dist/enrich/runtime_enricher.js.map +1 -0
- package/dist/enrich/runtime_join.d.ts +100 -0
- package/dist/enrich/runtime_join.d.ts.map +1 -0
- package/dist/enrich/runtime_join.js +227 -0
- package/dist/enrich/runtime_join.js.map +1 -0
- package/dist/extract/api_extractor.d.ts +24 -0
- package/dist/extract/api_extractor.d.ts.map +1 -0
- package/dist/extract/api_extractor.js +71 -0
- package/dist/extract/api_extractor.js.map +1 -0
- package/dist/extract/config_extractor.d.ts +22 -0
- package/dist/extract/config_extractor.d.ts.map +1 -0
- package/dist/extract/config_extractor.js +61 -0
- package/dist/extract/config_extractor.js.map +1 -0
- package/dist/extract/endpoint_extractor.d.ts +36 -0
- package/dist/extract/endpoint_extractor.d.ts.map +1 -0
- package/dist/extract/endpoint_extractor.js +117 -0
- package/dist/extract/endpoint_extractor.js.map +1 -0
- package/dist/extract/graph_builder.d.ts +8 -0
- package/dist/extract/graph_builder.d.ts.map +1 -1
- package/dist/extract/graph_builder.js +23 -1
- package/dist/extract/graph_builder.js.map +1 -1
- package/dist/extract/node_id.d.ts +16 -0
- package/dist/extract/node_id.d.ts.map +1 -1
- package/dist/extract/node_id.js +22 -0
- package/dist/extract/node_id.js.map +1 -1
- package/dist/extract/scope_resolver.d.ts +22 -0
- package/dist/extract/scope_resolver.d.ts.map +1 -0
- package/dist/extract/scope_resolver.js +53 -0
- package/dist/extract/scope_resolver.js.map +1 -0
- package/dist/extract/semantic_extractor.d.ts +25 -0
- package/dist/extract/semantic_extractor.d.ts.map +1 -1
- package/dist/extract/semantic_extractor.js +96 -2
- package/dist/extract/semantic_extractor.js.map +1 -1
- package/dist/extract/structural_extractor.d.ts +6 -0
- package/dist/extract/structural_extractor.d.ts.map +1 -1
- package/dist/extract/structural_extractor.js +22 -12
- package/dist/extract/structural_extractor.js.map +1 -1
- package/dist/project_root.d.ts +7 -0
- package/dist/project_root.d.ts.map +1 -0
- package/dist/project_root.js +9 -0
- package/dist/project_root.js.map +1 -0
- package/dist/query/graph_query.d.ts +234 -0
- package/dist/query/graph_query.d.ts.map +1 -1
- package/dist/query/graph_query.js +522 -11
- package/dist/query/graph_query.js.map +1 -1
- package/dist/schema/edge.d.ts +40 -5
- package/dist/schema/edge.d.ts.map +1 -1
- package/dist/schema/edge.js +70 -0
- package/dist/schema/edge.js.map +1 -1
- package/dist/schema/node.d.ts +20 -5
- package/dist/schema/node.d.ts.map +1 -1
- package/dist/schema/node.js +36 -0
- package/dist/schema/node.js.map +1 -1
- package/dist/schema/runtime_manifest.d.ts +36 -0
- package/dist/schema/runtime_manifest.d.ts.map +1 -0
- package/dist/schema/runtime_manifest.js +23 -0
- package/dist/schema/runtime_manifest.js.map +1 -0
- package/dist/store/kuzu_store.d.ts +46 -0
- package/dist/store/kuzu_store.d.ts.map +1 -1
- package/dist/store/kuzu_store.js +95 -5
- package/dist/store/kuzu_store.js.map +1 -1
- package/dist/verify/project_verifier.d.ts +85 -0
- package/dist/verify/project_verifier.d.ts.map +1 -0
- package/dist/verify/project_verifier.js +138 -0
- package/dist/verify/project_verifier.js.map +1 -0
- package/{skills/ts-knowledge-graph → dotclaude_folder/skills/code-graph-query}/SKILL.md +2 -2
- package/package.json +86 -7
- package/.env-sample +0 -34
- package/dist/agent/agent-tools.d.ts +0 -13
- package/dist/agent/agent-tools.d.ts.map +0 -1
- package/dist/agent/agent-tools.js +0 -153
- package/dist/agent/agent-tools.js.map +0 -1
- package/dist/agent/agent_tools.d.ts +0 -13
- package/dist/agent/agent_tools.d.ts.map +0 -1
- package/dist/agent/agent_tools.js +0 -153
- package/dist/agent/agent_tools.js.map +0 -1
- package/dist/agent/code-editor.d.ts +0 -18
- package/dist/agent/code-editor.d.ts.map +0 -1
- package/dist/agent/code-editor.js +0 -43
- package/dist/agent/code-editor.js.map +0 -1
- package/dist/agent/code_editor.d.ts +0 -18
- package/dist/agent/code_editor.d.ts.map +0 -1
- package/dist/agent/code_editor.js +0 -43
- package/dist/agent/code_editor.js.map +0 -1
- package/dist/agent/optimizer-agent.d.ts +0 -30
- package/dist/agent/optimizer-agent.d.ts.map +0 -1
- package/dist/agent/optimizer-agent.js +0 -97
- package/dist/agent/optimizer-agent.js.map +0 -1
- package/dist/agent/optimizer_agent.d.ts +0 -30
- package/dist/agent/optimizer_agent.d.ts.map +0 -1
- package/dist/agent/optimizer_agent.js +0 -97
- package/dist/agent/optimizer_agent.js.map +0 -1
- package/dist/agent/verifier.d.ts +0 -9
- package/dist/agent/verifier.d.ts.map +0 -1
- package/dist/agent/verifier.js +0 -19
- package/dist/agent/verifier.js.map +0 -1
- package/dist/commands/blast-radius.d.ts +0 -5
- package/dist/commands/blast-radius.d.ts.map +0 -1
- package/dist/commands/blast-radius.js +0 -18
- package/dist/commands/blast-radius.js.map +0 -1
- package/dist/commands/blast_radius.d.ts +0 -5
- package/dist/commands/blast_radius.d.ts.map +0 -1
- package/dist/commands/blast_radius.js +0 -18
- package/dist/commands/blast_radius.js.map +0 -1
- package/dist/commands/calls.d.ts +0 -5
- package/dist/commands/calls.d.ts.map +0 -1
- package/dist/commands/calls.js +0 -7
- package/dist/commands/calls.js.map +0 -1
- package/dist/commands/command-helpers.d.ts +0 -15
- package/dist/commands/command-helpers.d.ts.map +0 -1
- package/dist/commands/command-helpers.js +0 -61
- package/dist/commands/command-helpers.js.map +0 -1
- package/dist/commands/dead-exports.d.ts +0 -5
- package/dist/commands/dead-exports.d.ts.map +0 -1
- package/dist/commands/dead-exports.js +0 -7
- package/dist/commands/dead-exports.js.map +0 -1
- package/dist/commands/dead_exports.d.ts +0 -5
- package/dist/commands/dead_exports.d.ts.map +0 -1
- package/dist/commands/dead_exports.js +0 -7
- package/dist/commands/dead_exports.js.map +0 -1
- package/dist/commands/extract.d.ts +0 -8
- package/dist/commands/extract.d.ts.map +0 -1
- package/dist/commands/extract.js +0 -49
- package/dist/commands/extract.js.map +0 -1
- package/dist/commands/find.d.ts +0 -5
- package/dist/commands/find.d.ts.map +0 -1
- package/dist/commands/find.js +0 -7
- package/dist/commands/find.js.map +0 -1
- package/dist/commands/load.d.ts.map +0 -1
- package/dist/commands/load.js +0 -28
- package/dist/commands/load.js.map +0 -1
- package/dist/commands/neighbors.d.ts +0 -5
- package/dist/commands/neighbors.d.ts.map +0 -1
- package/dist/commands/neighbors.js +0 -17
- package/dist/commands/neighbors.js.map +0 -1
- package/dist/commands/optimize.d.ts +0 -6
- package/dist/commands/optimize.d.ts.map +0 -1
- package/dist/commands/optimize.js +0 -59
- package/dist/commands/optimize.js.map +0 -1
- package/dist/commands/optimize_command.d.ts +0 -6
- package/dist/commands/optimize_command.d.ts.map +0 -1
- package/dist/commands/optimize_command.js +0 -59
- package/dist/commands/optimize_command.js.map +0 -1
- package/dist/commands/references.d.ts +0 -5
- package/dist/commands/references.d.ts.map +0 -1
- package/dist/commands/references.js +0 -17
- package/dist/commands/references.js.map +0 -1
- package/dist/commands/web.d.ts +0 -19
- package/dist/commands/web.d.ts.map +0 -1
- package/dist/commands/web.js +0 -120
- package/dist/commands/web.js.map +0 -1
- package/dist/commands/who-calls.d.ts +0 -5
- package/dist/commands/who-calls.d.ts.map +0 -1
- package/dist/commands/who-calls.js +0 -7
- package/dist/commands/who-calls.js.map +0 -1
- package/dist/commands/who_calls.d.ts +0 -5
- package/dist/commands/who_calls.d.ts.map +0 -1
- package/dist/commands/who_calls.js +0 -7
- package/dist/commands/who_calls.js.map +0 -1
- package/dist/extract/graph-builder.d.ts +0 -16
- package/dist/extract/graph-builder.d.ts.map +0 -1
- package/dist/extract/graph-builder.js +0 -39
- package/dist/extract/graph-builder.js.map +0 -1
- package/dist/extract/node-id.d.ts +0 -8
- package/dist/extract/node-id.d.ts.map +0 -1
- package/dist/extract/node-id.js +0 -22
- package/dist/extract/node-id.js.map +0 -1
- package/dist/extract/project-loader.d.ts +0 -5
- package/dist/extract/project-loader.d.ts.map +0 -1
- package/dist/extract/project-loader.js +0 -19
- package/dist/extract/project-loader.js.map +0 -1
- package/dist/extract/semantic-extractor.d.ts +0 -22
- package/dist/extract/semantic-extractor.d.ts.map +0 -1
- package/dist/extract/semantic-extractor.js +0 -254
- package/dist/extract/semantic-extractor.js.map +0 -1
- package/dist/extract/structural-extractor.d.ts +0 -18
- package/dist/extract/structural-extractor.d.ts.map +0 -1
- package/dist/extract/structural-extractor.js +0 -97
- package/dist/extract/structural-extractor.js.map +0 -1
- package/dist/query/graph-query.d.ts +0 -28
- package/dist/query/graph-query.d.ts.map +0 -1
- package/dist/query/graph-query.js +0 -93
- package/dist/query/graph-query.js.map +0 -1
- package/dist/store/jsonl-reader.d.ts +0 -11
- package/dist/store/jsonl-reader.d.ts.map +0 -1
- package/dist/store/jsonl-reader.js +0 -19
- package/dist/store/jsonl-reader.js.map +0 -1
- package/dist/store/jsonl-store.d.ts +0 -7
- package/dist/store/jsonl-store.d.ts.map +0 -1
- package/dist/store/jsonl-store.js +0 -13
- package/dist/store/jsonl-store.js.map +0 -1
- package/dist/store/kuzu-store.d.ts +0 -14
- package/dist/store/kuzu-store.d.ts.map +0 -1
- package/dist/store/kuzu-store.js +0 -52
- 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
|
-
|
|
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':
|
|
177
|
-
'width':
|
|
178
|
-
'height':
|
|
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':
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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>${
|
|
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
|
|
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
|
-
|
|
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>} */ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' });
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* @param {unknown} value
|
|
904
|
+
* @returns {string}
|
|
905
|
+
*/
|
|
360
906
|
function escapeHtml(value) {
|
|
361
|
-
return String(value).replace(/[&<>"']/g, (char) =>
|
|
907
|
+
return String(value).replace(/[&<>"']/g, (char) => ESCAPE_REPLACEMENTS[char]);
|
|
362
908
|
}
|
|
363
909
|
|
|
364
910
|
boot();
|