@psnext/lscg 0.1.1 → 0.1.2

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.
@@ -0,0 +1,269 @@
1
+
2
+ :root {
3
+ color-scheme: dark;
4
+ --bg: #020617;
5
+ --panel: rgba(15, 23, 42, 0.88);
6
+ --panel-border: rgba(148, 163, 184, 0.2);
7
+ --text: #e2e8f0;
8
+ --muted: #94a3b8;
9
+ }
10
+ html, body {
11
+ margin: 0;
12
+ width: 100%;
13
+ height: 100%;
14
+ overflow: hidden;
15
+ background: var(--bg);
16
+ color: var(--text);
17
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
18
+ }
19
+ #app {
20
+ display: flex;
21
+ width: 100vw;
22
+ height: 100vh;
23
+ }
24
+ .graph-panel {
25
+ flex: 1 1 auto;
26
+ min-width: 0;
27
+ position: relative;
28
+ }
29
+ .hud {
30
+ width: 320px;
31
+ flex: 0 0 320px;
32
+ box-sizing: border-box;
33
+ margin: 16px 16px 16px 0;
34
+ display: grid;
35
+ align-content: start;
36
+ gap: 12px;
37
+ padding: 14px 16px;
38
+ border: 1px solid var(--panel-border);
39
+ border-radius: 16px;
40
+ background: var(--panel);
41
+ backdrop-filter: blur(16px);
42
+ box-shadow: 0 18px 50px rgba(15, 23, 42, 0.35);
43
+ overflow: auto;
44
+ }
45
+ .section {
46
+ display: grid;
47
+ gap: 8px;
48
+ }
49
+ .section-title {
50
+ margin: 0;
51
+ color: var(--muted);
52
+ font-size: 12px;
53
+ font-weight: 600;
54
+ letter-spacing: 0.04em;
55
+ text-transform: uppercase;
56
+ }
57
+ .info-grid {
58
+ display: grid;
59
+ gap: 8px;
60
+ font-size: 13px;
61
+ }
62
+ .info-row {
63
+ display: grid;
64
+ grid-template-columns: 92px minmax(0, 1fr);
65
+ gap: 10px;
66
+ }
67
+ .info-label {
68
+ color: var(--muted);
69
+ }
70
+ .info-value {
71
+ overflow-wrap: anywhere;
72
+ }
73
+ .info-empty {
74
+ color: var(--muted);
75
+ font-size: 13px;
76
+ }
77
+ .zoom-actions {
78
+ display: flex;
79
+ flex-wrap: wrap;
80
+ gap: 8px;
81
+ }
82
+ .zoom-action {
83
+ border: 1px solid var(--panel-border);
84
+ border-radius: 10px;
85
+ background: rgba(15, 23, 42, 0.6);
86
+ color: var(--text);
87
+ padding: 8px 10px;
88
+ font: inherit;
89
+ font-size: 12px;
90
+ cursor: pointer;
91
+ }
92
+ .zoom-action:disabled {
93
+ opacity: 0.45;
94
+ cursor: not-allowed;
95
+ }
96
+ .zoom-action.is-active {
97
+ border-color: rgba(148, 163, 184, 0.55);
98
+ box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.12);
99
+ }
100
+ .hud h1 {
101
+ margin: 0;
102
+ font-size: 16px;
103
+ line-height: 1.2;
104
+ }
105
+ .hud p {
106
+ margin: 0;
107
+ color: var(--muted);
108
+ font-size: 13px;
109
+ }
110
+ .hud input {
111
+ width: 100%;
112
+ box-sizing: border-box;
113
+ padding: 10px 12px;
114
+ border-radius: 10px;
115
+ border: 1px solid var(--panel-border);
116
+ background: rgba(15, 23, 42, 0.6);
117
+ color: var(--text);
118
+ font: inherit;
119
+ }
120
+ .legend {
121
+ display: flex;
122
+ flex-wrap: wrap;
123
+ gap: 10px 14px;
124
+ font-size: 12px;
125
+ color: var(--muted);
126
+ }
127
+ .legend-toggle {
128
+ display: inline-flex;
129
+ align-items: center;
130
+ gap: 8px;
131
+ padding: 0;
132
+ border: 0;
133
+ background: transparent;
134
+ color: inherit;
135
+ font: inherit;
136
+ cursor: pointer;
137
+ }
138
+ .legend-toggle.is-hidden {
139
+ opacity: 0.45;
140
+ }
141
+ .legend-swatch {
142
+ width: 10px;
143
+ height: 10px;
144
+ border-radius: 999px;
145
+ display: inline-block;
146
+ }
147
+ .legend-icon {
148
+ width: 14px;
149
+ height: 14px;
150
+ display: inline-flex;
151
+ align-items: center;
152
+ justify-content: center;
153
+ flex: 0 0 auto;
154
+ }
155
+ .legend-icon svg {
156
+ width: 14px;
157
+ height: 14px;
158
+ display: block;
159
+ }
160
+ .graph-panel svg {
161
+ display: block;
162
+ width: 100%;
163
+ height: 100%;
164
+ }
165
+ .links {
166
+ z-index: 1;
167
+ }
168
+ .nodes {
169
+ z-index: 2;
170
+ }
171
+ .edge {
172
+ stroke: #64748b;
173
+ stroke-opacity: 0.5;
174
+ stroke-width: 1.3;
175
+ }
176
+ .edge--imports { stroke: #f59e0b; }
177
+ .edge--exports { stroke: #a855f7; }
178
+ .edge--calls { stroke: #fb7185; }
179
+ .edge--defines { stroke: #22c55e; }
180
+ .edge--contains { stroke: #38bdf8; }
181
+ .node { cursor: grab; }
182
+ .node:active { cursor: grabbing; }
183
+ .node-shape {
184
+ stroke: rgba(226, 232, 240, 0.18);
185
+ stroke-width: 1.5;
186
+ shape-rendering: geometricPrecision;
187
+ }
188
+ .node-label {
189
+ fill: var(--text);
190
+ font-size: 11px;
191
+ font-weight: 600;
192
+ paint-order: stroke;
193
+ stroke: #020617;
194
+ stroke-width: 3px;
195
+ stroke-linejoin: round;
196
+ user-select: none;
197
+ pointer-events: none;
198
+ }
199
+ .node.is-highlighted .node-shape,
200
+ .node.is-selected .node-shape {
201
+ stroke: white;
202
+ stroke-width: 2.5;
203
+ }
204
+ .node.is-hidden,
205
+ .edge.is-hidden {
206
+ display: none;
207
+ }
208
+ .node.is-dimmed {
209
+ opacity: 0.18;
210
+ }
211
+ .edge.is-dimmed {
212
+ opacity: 0.12;
213
+ }
214
+ .edge.is-highlighted {
215
+ stroke-opacity: 0.25;
216
+ stroke-width: 2.1;
217
+ }
218
+ .status {
219
+ color: var(--muted);
220
+ font-size: 12px;
221
+ }
222
+ .tooltip {
223
+ position: fixed;
224
+ pointer-events: none;
225
+ z-index: 3;
226
+ display: none;
227
+ padding: 8px 10px;
228
+ border-radius: 10px;
229
+ border: 1px solid var(--panel-border);
230
+ background: rgba(15, 23, 42, 0.96);
231
+ color: var(--text);
232
+ font-size: 12px;
233
+ box-shadow: 0 16px 30px rgba(15, 23, 42, 0.4);
234
+ }
235
+ #app.sidebar-collapsed .hud {
236
+ width: 48px;
237
+ flex-basis: 48px;
238
+ padding: 12px 8px;
239
+ }
240
+ #app.sidebar-collapsed .hud > :not(.section--toggle) {
241
+ display: none;
242
+ }
243
+ #app.sidebar-collapsed .sidebar-toggle {
244
+ justify-content: center;
245
+ width: 100%;
246
+ }
247
+ .sidebar-toggle {
248
+ display: inline-flex;
249
+ align-items: center;
250
+ justify-content: space-between;
251
+ gap: 8px;
252
+ width: 100%;
253
+ border: 1px solid var(--panel-border);
254
+ border-radius: 10px;
255
+ background: rgba(15, 23, 42, 0.6);
256
+ color: var(--text);
257
+ padding: 10px 12px;
258
+ font: inherit;
259
+ cursor: pointer;
260
+ }
261
+ .sidebar-toggle span {
262
+ color: var(--muted);
263
+ font-size: 12px;
264
+ }
265
+ .sidebar-toggle .icon {
266
+ font-size: 14px;
267
+ line-height: 1;
268
+ }
269
+
@@ -0,0 +1,572 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{{GRAPH_TITLE}}</title>
7
+ <style>{{CSS}}</style>
8
+ </head>
9
+ <body>
10
+ <div id="app">
11
+ <div class="graph-panel">
12
+ <svg id="graph" aria-label="{{GRAPH_TITLE}}"></svg>
13
+ </div>
14
+ <aside class="hud" aria-label="Graph controls">
15
+ <div class="section section--toggle">
16
+ <button id="sidebar-toggle" class="sidebar-toggle" type="button" aria-expanded="true" aria-controls="sidebar-content">
17
+ <span>Controls</span>
18
+ <span class="icon" aria-hidden="true">⟨</span>
19
+ </button>
20
+ </div>
21
+ <div id="sidebar-content" class="section">
22
+ <h1>{{GRAPH_TITLE}}</h1>
23
+ <p id="subtitle">{{SUBTITLE}}</p>
24
+ </div>
25
+ <div class="section">
26
+ <label>
27
+ <span class="status">Filter nodes</span>
28
+ <input id="search" type="search" placeholder="Type to filter by label" autocomplete="off" value="{{SEARCH_TERM}}" />
29
+ </label>
30
+ </div>
31
+ <div class="section">
32
+ <label>
33
+ <span class="status">View box zoom</span>
34
+ <input id="zoom" type="range" min="0.25" max="2.5" step="0.05" value="1" />
35
+ </label>
36
+ <div id="zoom-value" class="info-empty">1×</div>
37
+ <div class="zoom-actions">
38
+ <button id="zoom-all" class="zoom-action" type="button">Zoom all</button>
39
+ <button id="zoom-selected" class="zoom-action" type="button">Zoom selected</button>
40
+ <button id="focus-toggle" class="zoom-action" type="button" aria-pressed="false">Focus</button>
41
+ </div>
42
+ </div>
43
+ <div class="section">
44
+ <h2 class="section-title">Selected node</h2>
45
+ <div id="node-info" class="info-grid">
46
+ <div class="info-empty">Click a node to inspect it.</div>
47
+ </div>
48
+ </div>
49
+ <div class="section">
50
+ <h2 class="section-title">Legend</h2>
51
+ <div id="legend" class="legend">{{LEGEND_ITEMS}}</div>
52
+ </div>
53
+ </aside>
54
+ <div id="tooltip" class="tooltip"></div>
55
+ </div>
56
+ <script type="module">
57
+ import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7/+esm';
58
+
59
+ const data = {{DATA}};
60
+ const app = document.getElementById('app');
61
+ const svg = d3.select('#graph');
62
+ const graphPanel = document.querySelector('.graph-panel');
63
+ const tooltip = document.getElementById('tooltip');
64
+ const searchInput = document.getElementById('search');
65
+ const zoomInput = document.getElementById('zoom');
66
+ const zoomValue = document.getElementById('zoom-value');
67
+ const zoomAllButton = document.getElementById('zoom-all');
68
+ const zoomSelectedButton = document.getElementById('zoom-selected');
69
+ const focusButton = document.getElementById('focus-toggle');
70
+ const subtitle = document.getElementById('subtitle');
71
+ const nodeInfo = document.getElementById('node-info');
72
+ const sidebarToggle = document.getElementById('sidebar-toggle');
73
+ const legend = document.getElementById('legend');
74
+ const fitPadding = 72;
75
+ const focusScale = 1.2;
76
+ let zoomFactor = Number(zoomInput instanceof HTMLInputElement ? zoomInput.value : 1) || 1;
77
+ function canvasSize() {
78
+ return {
79
+ width: graphPanel?.clientWidth ?? window.innerWidth,
80
+ height: graphPanel?.clientHeight ?? window.innerHeight
81
+ };
82
+ }
83
+ const { width, height } = canvasSize();
84
+ const nodesData = data.nodes.map((node) => ({ ...node }));
85
+ const linksData = data.edges.map((edge) => ({ ...edge }));
86
+ const nodeById = new Map(nodesData.map((node) => [node.id, node]));
87
+ const allNodeIds = new Set(nodesData.map((node) => node.id));
88
+ const hiddenKinds = new Set();
89
+ let anchorId = data.anchorNodeId ?? null;
90
+ let filterText = data.search ?? '';
91
+ let sidebarCollapsed = false;
92
+ let focusMode = anchorId ? 'selected' : 'all';
93
+ let focusVisibleOnly = false;
94
+
95
+ svg.attr('viewBox', [0, 0, width, height]).attr('preserveAspectRatio', 'xMidYMid meet');
96
+
97
+ const defs = svg.append('defs');
98
+ defs.append('marker')
99
+ .attr('id', 'arrow')
100
+ .attr('viewBox', '0 0 10 10')
101
+ .attr('refX', 7.5)
102
+ .attr('refY', 5)
103
+ .attr('markerWidth', 6)
104
+ .attr('markerHeight', 6)
105
+ .attr('orient', 'auto-start-reverse')
106
+ .append('path')
107
+ .attr('d', 'M 0 0 L 10 5 L 0 10 z')
108
+ .attr('fill', '#94a3b8');
109
+
110
+ const root = svg.append('g');
111
+ const zoomLayer = root.append('g');
112
+ const linkLayer = zoomLayer.append('g').attr('class', 'links');
113
+ const nodeLayer = zoomLayer.append('g').attr('class', 'nodes');
114
+
115
+ function appendNodeShape(group, node) {
116
+ const fill = node.color ?? '#94a3b8';
117
+ switch (node.shape) {
118
+ case 'rounded-rect':
119
+ group.append('rect')
120
+ .attr('class', 'node-shape node-shape--rounded-rect')
121
+ .attr('x', -node.width / 2)
122
+ .attr('y', -node.height / 2)
123
+ .attr('width', node.width)
124
+ .attr('height', node.height)
125
+ .attr('rx', 10)
126
+ .attr('ry', 10)
127
+ .attr('fill', fill);
128
+ break;
129
+ case 'box':
130
+ group.append('rect')
131
+ .attr('class', 'node-shape node-shape--box')
132
+ .attr('x', -node.width / 2)
133
+ .attr('y', -node.height / 2)
134
+ .attr('width', node.width)
135
+ .attr('height', node.height)
136
+ .attr('rx', 4)
137
+ .attr('ry', 4)
138
+ .attr('fill', fill);
139
+ break;
140
+ case 'ellipse':
141
+ group.append('ellipse')
142
+ .attr('class', 'node-shape node-shape--ellipse')
143
+ .attr('cx', 0)
144
+ .attr('cy', 0)
145
+ .attr('rx', node.width / 2)
146
+ .attr('ry', node.height / 2)
147
+ .attr('fill', fill);
148
+ break;
149
+ case 'diamond':
150
+ case 'hexagon':
151
+ group.append('polygon')
152
+ .attr('class', 'node-shape node-shape--' + node.shape)
153
+ .attr('points', node.shape === 'diamond'
154
+ ? '0,' + (-node.height / 2) + ' ' + (node.width / 2) + ',0 ' + '0,' + (node.height / 2) + ' ' + (-node.width / 2) + ',0'
155
+ : (-node.width / 2 + node.height / 4) + ',' + (-node.height / 2) + ' ' + (node.width / 2 - node.height / 4) + ',' + (-node.height / 2) + ' ' + (node.width / 2) + ',0 ' + (node.width / 2 - node.height / 4) + ',' + (node.height / 2) + ' ' + (-node.width / 2 + node.height / 4) + ',' + (node.height / 2) + ' ' + (-node.width / 2) + ',0')
156
+ .attr('fill', fill);
157
+ break;
158
+ case 'user':
159
+ const userIcon = group.append('svg')
160
+ .attr('class', 'node-shape node-shape--user')
161
+ .attr('x', -12)
162
+ .attr('y', -12)
163
+ .attr('width', 24)
164
+ .attr('height', 24)
165
+ .attr('viewBox', '0 0 24 24')
166
+ .attr('fill', 'none')
167
+ .attr('stroke', fill)
168
+ .attr('stroke-width', 1.5)
169
+ .attr('stroke-linecap', 'round')
170
+ .attr('stroke-linejoin', 'round');
171
+ userIcon.append('circle').attr('cx', 12).attr('cy', 9).attr('r', 3);
172
+ userIcon.append('circle').attr('cx', 12).attr('cy', 12).attr('r', 10);
173
+ userIcon.append('path').attr('d', 'M17.9691 20C17.81 17.1085 16.9247 15 11.9999 15C7.07521 15 6.18991 17.1085 6.03076 20');
174
+ break;
175
+ default:
176
+ group.append('rect')
177
+ .attr('class', 'node-shape')
178
+ .attr('x', -node.width / 2)
179
+ .attr('y', -node.height / 2)
180
+ .attr('width', node.width)
181
+ .attr('height', node.height)
182
+ .attr('rx', 6)
183
+ .attr('ry', 6)
184
+ .attr('fill', fill);
185
+ }
186
+ }
187
+
188
+ const simulation = d3.forceSimulation(nodesData)
189
+ .force('link', d3.forceLink(linksData).id((d) => d.id).distance((edge) => edge.kind === 'contains' ? 42 : 120).strength((edge) => edge.kind === 'contains' ? 0.6 : 0.18))
190
+ .force('charge', d3.forceManyBody().strength((node) => node.kind === 'file' ? -420 : -220))
191
+ .force('center', d3.forceCenter(width / 2, height / 2))
192
+ .force('collide', d3.forceCollide().radius((node) => (node.presentationRadius ?? 24)))
193
+ .force('x', d3.forceX(width / 2).strength(0.05))
194
+ .force('y', d3.forceY(height / 2).strength(0.05));
195
+
196
+ const links = linkLayer.selectAll('line')
197
+ .data(linksData)
198
+ .join('line')
199
+ .attr('class', (d) => 'edge edge--' + d.kind)
200
+ .attr('marker-end', 'url(#arrow)');
201
+
202
+ const nodes = nodeLayer.selectAll('g')
203
+ .data(nodesData)
204
+ .join('g')
205
+ .attr('class', (d) => 'node node--' + d.kind)
206
+ .call(drag(simulation));
207
+
208
+ nodeLayer.raise();
209
+
210
+ nodes.each(function(d) {
211
+ const group = d3.select(this);
212
+ appendNodeShape(group, d);
213
+ group.append('text')
214
+ .attr('class', 'node-label')
215
+ .attr('text-anchor', 'middle')
216
+ .attr('dominant-baseline', d.kind === 'file' ? 'middle' : 'hanging')
217
+ .attr('y', d.kind === 'file' ? 0 : (d.height / 2) + 14)
218
+ .text(d.label);
219
+ group.append('title').text(d.label);
220
+ });
221
+
222
+ const zoom = d3.zoom().scaleExtent([0.2, 4]).on('zoom', (event) => {
223
+ zoomLayer.attr('transform', event.transform);
224
+ });
225
+
226
+ svg.call(zoom);
227
+
228
+ function toggleKind(kind) {
229
+ if (hiddenKinds.has(kind)) {
230
+ hiddenKinds.delete(kind);
231
+ } else {
232
+ hiddenKinds.add(kind);
233
+ }
234
+ applyState();
235
+ }
236
+
237
+ function updateZoomState() {
238
+ if (zoomValue) {
239
+ zoomValue.textContent = formatZoom(zoomFactor) + '×';
240
+ }
241
+ if (zoomAllButton instanceof HTMLButtonElement) {
242
+ zoomAllButton.classList.toggle('is-active', focusMode === 'all');
243
+ }
244
+ if (zoomSelectedButton instanceof HTMLButtonElement) {
245
+ zoomSelectedButton.classList.toggle('is-active', focusMode === 'selected');
246
+ zoomSelectedButton.disabled = !anchorId;
247
+ }
248
+ if (focusButton instanceof HTMLButtonElement) {
249
+ focusButton.classList.toggle('is-active', focusVisibleOnly && Boolean(anchorId));
250
+ focusButton.setAttribute('aria-pressed', String(focusVisibleOnly && Boolean(anchorId)));
251
+ focusButton.disabled = !anchorId;
252
+ }
253
+ }
254
+
255
+ function updateLegendState() {
256
+ if (!legend) return;
257
+ for (const button of legend.querySelectorAll('[data-kind-toggle]')) {
258
+ const kind = button.getAttribute('data-kind-toggle');
259
+ const hidden = kind ? hiddenKinds.has(kind) : false;
260
+ button.classList.toggle('is-hidden', hidden);
261
+ button.setAttribute('aria-pressed', String(!hidden));
262
+ }
263
+ }
264
+
265
+ function formatZoom(value) {
266
+ return Number(value.toFixed(2)).toString().replace(/\.00$/, '');
267
+ }
268
+
269
+ function getVisibleSets() {
270
+ const query = filterText.trim().toLowerCase();
271
+ const matchingNodeIds = new Set(
272
+ nodesData
273
+ .filter((node) => !query || node.label.toLowerCase().includes(query))
274
+ .filter((node) => !hiddenKinds.has(node.kind))
275
+ .map((node) => node.id)
276
+ );
277
+
278
+ if (focusVisibleOnly && anchorId) {
279
+ const neighborhood = data.fullNeighborhoods && data.fullNeighborhoods[anchorId] ? data.fullNeighborhoods[anchorId] : { nodeIds: [anchorId], edgeIds: [] };
280
+ const visibleNodeIds = new Set(neighborhood.nodeIds.filter((nodeId) => matchingNodeIds.has(nodeId)));
281
+ const visibleEdgeIds = new Set(
282
+ linksData
283
+ .filter((edge) => visibleNodeIds.has(edgeEndpointId(edge.source)) && visibleNodeIds.has(edgeEndpointId(edge.target)))
284
+ .map((edge) => edge.id)
285
+ );
286
+ return { visibleNodeIds, visibleEdgeIds };
287
+ }
288
+
289
+ const visibleNodeIds = new Set([...allNodeIds].filter((nodeId) => matchingNodeIds.has(nodeId)));
290
+ const visibleEdgeIds = new Set(
291
+ linksData
292
+ .filter((edge) => visibleNodeIds.has(edgeEndpointId(edge.source)) && visibleNodeIds.has(edgeEndpointId(edge.target)))
293
+ .map((edge) => edge.id)
294
+ );
295
+ return { visibleNodeIds, visibleEdgeIds };
296
+ }
297
+
298
+ function edgeEndpointId(endpoint) {
299
+ return typeof endpoint === 'string' ? endpoint : endpoint?.id;
300
+ }
301
+
302
+ function updateStatus() {
303
+ if (anchorId && focusVisibleOnly) {
304
+ subtitle.textContent = 'Focus on ' + (nodeById.get(anchorId)?.label ?? anchorId);
305
+ return;
306
+ }
307
+
308
+ subtitle.textContent = anchorId
309
+ ? 'Selected ' + (nodeById.get(anchorId)?.label ?? anchorId)
310
+ : 'Showing entire graph';
311
+ }
312
+
313
+ function updateInfo() {
314
+ if (!nodeInfo) return;
315
+ const node = anchorId ? nodeById.get(anchorId) ?? null : null;
316
+ nodeInfo.replaceChildren();
317
+
318
+ if (!node) {
319
+ const empty = document.createElement('div');
320
+ empty.className = 'info-empty';
321
+ empty.textContent = 'Click a node to inspect it.';
322
+ nodeInfo.append(empty);
323
+ return;
324
+ }
325
+
326
+ const rows = [
327
+ ['Label', node.label],
328
+ ['Kind', node.kind],
329
+ ['Type', node.type],
330
+ ['Degree', String(node.degree)],
331
+ ['ID', node.id]
332
+ ];
333
+
334
+ for (const [label, value] of rows) {
335
+ const row = document.createElement('div');
336
+ row.className = 'info-row';
337
+
338
+ const key = document.createElement('div');
339
+ key.className = 'info-label';
340
+ key.textContent = label;
341
+
342
+ const val = document.createElement('div');
343
+ val.className = 'info-value';
344
+ val.textContent = value;
345
+
346
+ row.append(key, val);
347
+ nodeInfo.append(row);
348
+ }
349
+ }
350
+
351
+ function applyState() {
352
+ const visibleSets = getVisibleSets();
353
+ const visibleNodeIds = visibleSets.visibleNodeIds;
354
+ const visibleEdgeIds = visibleSets.visibleEdgeIds;
355
+ const activeAnchorVisible = Boolean(anchorId && visibleNodeIds.has(anchorId));
356
+
357
+ nodes
358
+ .classed('is-hidden', (node) => !visibleNodeIds.has(node.id))
359
+ .classed('is-dimmed', (node) => !focusVisibleOnly && anchorId ? !visibleNodeIds.has(node.id) : false)
360
+ .classed('is-selected', (node) => anchorId === node.id)
361
+ .classed('is-highlighted', (node) => activeAnchorVisible && visibleNodeIds.has(node.id));
362
+
363
+ links
364
+ .classed('is-hidden', (edge) => !visibleEdgeIds.has(edge.id))
365
+ .classed('is-dimmed', (edge) => !focusVisibleOnly && anchorId ? !visibleEdgeIds.has(edge.id) : false)
366
+ .classed('is-highlighted', (edge) => visibleEdgeIds.has(edge.id));
367
+
368
+ updateStatus();
369
+ updateInfo();
370
+ updateZoomState();
371
+ updateLegendState();
372
+ reframe();
373
+ }
374
+
375
+ function setAnchor(nodeId) {
376
+ anchorId = anchorId === nodeId ? null : nodeId;
377
+ if (!anchorId) {
378
+ focusVisibleOnly = false;
379
+ }
380
+ focusMode = anchorId ? 'selected' : 'all';
381
+ applyState();
382
+ }
383
+
384
+ function setSidebarCollapsed(nextCollapsed) {
385
+ sidebarCollapsed = nextCollapsed;
386
+ app.classList.toggle('sidebar-collapsed', sidebarCollapsed);
387
+ if (sidebarToggle) {
388
+ sidebarToggle.setAttribute('aria-expanded', String(!sidebarCollapsed));
389
+ const icon = sidebarToggle.querySelector('.icon');
390
+ if (icon) icon.textContent = sidebarCollapsed ? '⟩' : '⟨';
391
+ sidebarToggle.title = sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar';
392
+ }
393
+ const nextSize = canvasSize();
394
+ svg.attr('viewBox', [0, 0, nextSize.width, nextSize.height]);
395
+ reframe();
396
+ }
397
+
398
+ function zoomAll() {
399
+ focusMode = 'all';
400
+ applyState();
401
+ }
402
+
403
+ function zoomSelected() {
404
+ if (!anchorId) return;
405
+ focusMode = 'selected';
406
+ applyState();
407
+ }
408
+
409
+ function toggleFocus() {
410
+ if (!anchorId) return;
411
+ focusVisibleOnly = !focusVisibleOnly;
412
+ applyState();
413
+ }
414
+
415
+ function reframe() {
416
+ const selectedNode = anchorId ? nodesData.find((candidate) => candidate.id === anchorId) ?? null : null;
417
+ const { width: canvasWidth, height: canvasHeight } = canvasSize();
418
+
419
+ if (focusMode === 'selected' && selectedNode) {
420
+ const transform = d3.zoomIdentity
421
+ .translate(canvasWidth / 2, canvasHeight / 2)
422
+ .scale(focusScale / zoomFactor)
423
+ .translate(-selectedNode.x, -selectedNode.y);
424
+ svg.call(zoom.transform, transform);
425
+ return;
426
+ }
427
+
428
+ if (nodesData.length === 0) {
429
+ svg.call(zoom.transform, d3.zoomIdentity);
430
+ return;
431
+ }
432
+
433
+ const bounds = nodesData.reduce((acc, current) => ({
434
+ minX: Math.min(acc.minX, current.x),
435
+ maxX: Math.max(acc.maxX, current.x),
436
+ minY: Math.min(acc.minY, current.y),
437
+ maxY: Math.max(acc.maxY, current.y)
438
+ }), { minX: Number.POSITIVE_INFINITY, maxX: Number.NEGATIVE_INFINITY, minY: Number.POSITIVE_INFINITY, maxY: Number.NEGATIVE_INFINITY });
439
+
440
+ const boundedWidth = Math.max(bounds.maxX - bounds.minX, 1);
441
+ const boundedHeight = Math.max(bounds.maxY - bounds.minY, 1);
442
+ const baseScale = Math.min(
443
+ 4,
444
+ Math.max(
445
+ 0.2,
446
+ Math.min((canvasWidth - fitPadding * 2) / boundedWidth, (canvasHeight - fitPadding * 2) / boundedHeight)
447
+ )
448
+ );
449
+ const scale = baseScale / zoomFactor;
450
+ const centerX = (bounds.minX + bounds.maxX) / 2;
451
+ const centerY = (bounds.minY + bounds.maxY) / 2;
452
+ const transform = d3.zoomIdentity
453
+ .translate(canvasWidth / 2, canvasHeight / 2)
454
+ .scale(scale)
455
+ .translate(-centerX, -centerY);
456
+
457
+ svg.call(zoom.transform, transform);
458
+ }
459
+
460
+ function drag(sim) {
461
+ function dragstarted(event) {
462
+ if (!event.active) sim.alphaTarget(0.3).restart();
463
+ event.subject.fx = event.subject.x;
464
+ event.subject.fy = event.subject.y;
465
+ }
466
+
467
+ function dragged(event) {
468
+ event.subject.fx = event.x;
469
+ event.subject.fy = event.y;
470
+ }
471
+
472
+ function dragended(event) {
473
+ if (!event.active) sim.alphaTarget(0);
474
+ event.subject.fx = null;
475
+ event.subject.fy = null;
476
+ }
477
+
478
+ return d3.drag()
479
+ .on('start', dragstarted)
480
+ .on('drag', dragged)
481
+ .on('end', dragended);
482
+ }
483
+
484
+ function ticked() {
485
+ links
486
+ .attr('x1', (d) => d.source.x)
487
+ .attr('y1', (d) => d.source.y)
488
+ .attr('x2', (d) => d.target.x)
489
+ .attr('y2', (d) => d.target.y);
490
+
491
+ nodes.attr('transform', (d) => 'translate(' + d.x + ',' + d.y + ')');
492
+ }
493
+
494
+ nodes
495
+ .on('click', (event, node) => {
496
+ event.stopPropagation();
497
+ setAnchor(node.id);
498
+ })
499
+ .on('mouseenter', function(event, node) {
500
+ if (!tooltip) return;
501
+ tooltip.style.display = 'block';
502
+ tooltip.textContent = node.label + ' · ' + node.kind;
503
+ })
504
+ .on('mousemove', function(event) {
505
+ if (!tooltip) return;
506
+ tooltip.style.left = event.clientX + 14 + 'px';
507
+ tooltip.style.top = event.clientY + 14 + 'px';
508
+ })
509
+ .on('mouseleave', function() {
510
+ if (!tooltip) return;
511
+ tooltip.style.display = 'none';
512
+ });
513
+
514
+ svg.on('click', (event) => {
515
+ if (event.target === svg.node()) {
516
+ anchorId = null;
517
+ focusMode = 'all';
518
+ applyState();
519
+ }
520
+ });
521
+
522
+ searchInput.value = filterText;
523
+ searchInput.addEventListener('input', (event) => {
524
+ filterText = event.target.value;
525
+ applyState();
526
+ });
527
+
528
+ zoomInput?.addEventListener('input', (event) => {
529
+ const target = event.target;
530
+ if (!(target instanceof HTMLInputElement)) return;
531
+ zoomFactor = Number(target.value) || 1;
532
+ applyState();
533
+ });
534
+
535
+ zoomAllButton?.addEventListener('click', () => {
536
+ zoomAll();
537
+ });
538
+
539
+ zoomSelectedButton?.addEventListener('click', () => {
540
+ zoomSelected();
541
+ });
542
+
543
+ focusButton?.addEventListener('click', () => {
544
+ toggleFocus();
545
+ });
546
+
547
+ legend?.addEventListener('click', (event) => {
548
+ const target = event.target instanceof Element ? event.target.closest('[data-kind-toggle]') : null;
549
+ if (!target) return;
550
+ const kind = target.getAttribute('data-kind-toggle');
551
+ if (!kind) return;
552
+ toggleKind(kind);
553
+ });
554
+
555
+ sidebarToggle?.addEventListener('click', () => {
556
+ setSidebarCollapsed(!sidebarCollapsed);
557
+ });
558
+
559
+ window.addEventListener('resize', () => {
560
+ if (!graphPanel) return;
561
+ const nextSize = canvasSize();
562
+ svg.attr('viewBox', [0, 0, nextSize.width, nextSize.height]);
563
+ reframe();
564
+ });
565
+
566
+ simulation.on('tick', ticked);
567
+ setSidebarCollapsed(false);
568
+ applyState();
569
+ simulation.alpha(1).restart();
570
+ </script>
571
+ </body>
572
+ </html>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@psnext/lscg",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Local source context graphs for repositories, exposed as a CLI and MCP server.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
20
+ "postbuild": "mkdir -p ./dist/src/view/templates && cp -r ./src/view/templates/* ./dist/src/view/templates",
20
21
  "typecheck": "tsc -p tsconfig.json --noEmit",
21
22
  "prepare": "npm run build",
22
23
  "prestart": "npm run build",