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