@psnext/lscg 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +69 -3
  2. package/dist/src/cli.js +68 -13
  3. package/dist/src/graph/repository.d.ts +4 -1
  4. package/dist/src/graph/repository.js +106 -6
  5. package/dist/src/index.d.ts +3 -0
  6. package/dist/src/index.js +2 -0
  7. package/dist/src/mcp/server.js +2 -2
  8. package/dist/src/scanner/discover.js +0 -2
  9. package/dist/src/scanner/packagePlugin.d.ts +3 -0
  10. package/dist/src/scanner/packagePlugin.js +125 -0
  11. package/dist/src/scanner/plugins.d.ts +86 -0
  12. package/dist/src/scanner/plugins.js +503 -0
  13. package/dist/src/storage/connection.d.ts +6 -0
  14. package/dist/src/storage/connection.js +133 -0
  15. package/dist/src/storage/context-queries.d.ts +20 -0
  16. package/dist/src/storage/context-queries.js +137 -0
  17. package/dist/src/storage/database.d.ts +12 -71
  18. package/dist/src/storage/database.js +12 -562
  19. package/dist/src/storage/graph-writes.d.ts +17 -0
  20. package/dist/src/storage/graph-writes.js +126 -0
  21. package/dist/src/storage/plugin-contributions.d.ts +15 -0
  22. package/dist/src/storage/plugin-contributions.js +28 -0
  23. package/dist/src/storage/plugin-graph.d.ts +6 -0
  24. package/dist/src/storage/plugin-graph.js +81 -0
  25. package/dist/src/storage/queries.d.ts +25 -0
  26. package/dist/src/storage/queries.js +129 -0
  27. package/dist/src/storage/row-decoders.d.ts +8 -0
  28. package/dist/src/storage/row-decoders.js +37 -0
  29. package/dist/src/storage/schema.d.ts +2 -2
  30. package/dist/src/storage/schema.js +13 -2
  31. package/dist/src/storage/traversal-queries.d.ts +15 -0
  32. package/dist/src/storage/traversal-queries.js +87 -0
  33. package/dist/src/types.d.ts +10 -4
  34. package/dist/src/view/index.d.ts +1 -1
  35. package/dist/src/view/index.js +2 -2
  36. package/dist/src/view/model.d.ts +2 -0
  37. package/dist/src/view/render.d.ts +5 -2
  38. package/dist/src/view/render.js +81 -13
  39. package/dist/src/view/templates/icons/call.svg +11 -0
  40. package/dist/src/view/templates/icons/export.svg +1 -0
  41. package/dist/src/view/templates/icons/file.svg +9 -0
  42. package/dist/src/view/templates/icons/import.svg +1 -0
  43. package/dist/src/view/templates/icons/package.svg +1 -0
  44. package/dist/src/view/templates/icons/symbol.svg +7 -0
  45. package/dist/src/view/templates/icons/user.svg +15 -0
  46. package/dist/src/view/templates/interactive.css +272 -0
  47. package/dist/src/view/templates/interactive.html +642 -0
  48. package/package.json +2 -1
@@ -0,0 +1,642 @@
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">Node display</span>
34
+ <select id="display-select">
35
+ <option value="simple">Simple</option>
36
+ <option value="shape">Shape</option>
37
+ <option value="icon">Icon</option>
38
+ </select>
39
+ </label>
40
+ </div>
41
+ <div class="section">
42
+ <label>
43
+ <span class="status">View box zoom</span>
44
+ <input id="zoom" type="range" min="0.25" max="2.5" step="0.05" value="1" />
45
+ </label>
46
+ <div id="zoom-value" class="info-empty">1×</div>
47
+ <div class="zoom-actions">
48
+ <button id="zoom-all" class="zoom-action" type="button">Zoom all</button>
49
+ <button id="zoom-selected" class="zoom-action" type="button">Zoom selected</button>
50
+ <button id="focus-toggle" class="zoom-action" type="button" aria-pressed="false">Focus</button>
51
+ </div>
52
+ </div>
53
+ <div class="section">
54
+ <h2 class="section-title">Selected node</h2>
55
+ <div id="node-info" class="info-grid">
56
+ <div class="info-empty">Click a node to inspect it.</div>
57
+ </div>
58
+ </div>
59
+ <div class="section">
60
+ <h2 class="section-title">Legend</h2>
61
+ <div id="legend" class="legend">{{LEGEND_ITEMS}}</div>
62
+ </div>
63
+ </aside>
64
+ <div id="tooltip" class="tooltip"></div>
65
+ </div>
66
+ <script type="module">
67
+ import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7/+esm';
68
+
69
+ const data = {{DATA}};
70
+ let display = data.display ?? 'shape';
71
+ const app = document.getElementById('app');
72
+ const svg = d3.select('#graph');
73
+ const graphPanel = document.querySelector('.graph-panel');
74
+ const tooltip = document.getElementById('tooltip');
75
+ const searchInput = document.getElementById('search');
76
+ const displaySelect = document.getElementById('display-select');
77
+ const zoomInput = document.getElementById('zoom');
78
+ const zoomValue = document.getElementById('zoom-value');
79
+ const zoomAllButton = document.getElementById('zoom-all');
80
+ const zoomSelectedButton = document.getElementById('zoom-selected');
81
+ const focusButton = document.getElementById('focus-toggle');
82
+ const subtitle = document.getElementById('subtitle');
83
+ const nodeInfo = document.getElementById('node-info');
84
+ const sidebarToggle = document.getElementById('sidebar-toggle');
85
+ const legend = document.getElementById('legend');
86
+ const fitPadding = 72;
87
+ const focusScale = 1.2;
88
+ let zoomFactor = Number(zoomInput instanceof HTMLInputElement ? zoomInput.value : 1) || 1;
89
+ function canvasSize() {
90
+ return {
91
+ width: graphPanel?.clientWidth ?? window.innerWidth,
92
+ height: graphPanel?.clientHeight ?? window.innerHeight
93
+ };
94
+ }
95
+ const { width, height } = canvasSize();
96
+ const nodesData = data.nodes.map((node) => ({ ...node }));
97
+ const linksData = data.edges.map((edge) => ({ ...edge }));
98
+ const nodeById = new Map(nodesData.map((node) => [node.id, node]));
99
+ const allNodeIds = new Set(nodesData.map((node) => node.id));
100
+ const hiddenKinds = new Set();
101
+ let anchorId = data.anchorNodeId ?? null;
102
+ let filterText = data.search ?? '';
103
+ let sidebarCollapsed = false;
104
+ let focusMode = anchorId ? 'selected' : 'all';
105
+ let focusVisibleOnly = false;
106
+
107
+ svg.attr('viewBox', [0, 0, width, height]).attr('preserveAspectRatio', 'xMidYMid meet');
108
+
109
+ const defs = svg.append('defs');
110
+ defs.append('marker')
111
+ .attr('id', 'arrow')
112
+ .attr('viewBox', '0 0 10 10')
113
+ .attr('refX', 7.5)
114
+ .attr('refY', 5)
115
+ .attr('markerWidth', 6)
116
+ .attr('markerHeight', 6)
117
+ .attr('orient', 'auto-start-reverse')
118
+ .append('path')
119
+ .attr('d', 'M 0 0 L 10 5 L 0 10 z')
120
+ .attr('fill', '#94a3b8');
121
+
122
+ const root = svg.append('g');
123
+ const zoomLayer = root.append('g');
124
+ const linkLayer = zoomLayer.append('g').attr('class', 'links');
125
+ const nodeLayer = zoomLayer.append('g').attr('class', 'nodes');
126
+
127
+ function appendNodeShape(group, node) {
128
+ const fill = node.color ?? '#94a3b8';
129
+ if (display === 'simple') {
130
+ group.append('circle')
131
+ .attr('class', 'node-shape node-shape--simple')
132
+ .attr('cx', 0)
133
+ .attr('cy', 0)
134
+ .attr('r', Math.min(node.width, node.height) / 2)
135
+ .attr('fill', fill);
136
+ return;
137
+ }
138
+ if (display === 'icon') {
139
+ const icon = data.icons?.[node.kind];
140
+ if (!icon) throw new Error('Missing icon asset for ' + node.kind);
141
+ group.append('svg')
142
+ .attr('class', 'node-shape node-shape--icon')
143
+ .attr('x', -node.width / 2)
144
+ .attr('y', -node.height / 2)
145
+ .attr('width', node.width)
146
+ .attr('height', node.height)
147
+ .attr('viewBox', icon.viewBox)
148
+ .attr('color', fill)
149
+ .attr('aria-hidden', 'true')
150
+ .html(icon.markup);
151
+ return;
152
+ }
153
+ switch (node.shape) {
154
+ case 'rounded-rect':
155
+ group.append('rect')
156
+ .attr('class', 'node-shape node-shape--rounded-rect')
157
+ .attr('x', -node.width / 2)
158
+ .attr('y', -node.height / 2)
159
+ .attr('width', node.width)
160
+ .attr('height', node.height)
161
+ .attr('rx', 10)
162
+ .attr('ry', 10)
163
+ .attr('fill', fill);
164
+ break;
165
+ case 'box':
166
+ group.append('rect')
167
+ .attr('class', 'node-shape node-shape--box')
168
+ .attr('x', -node.width / 2)
169
+ .attr('y', -node.height / 2)
170
+ .attr('width', node.width)
171
+ .attr('height', node.height)
172
+ .attr('rx', 4)
173
+ .attr('ry', 4)
174
+ .attr('fill', fill);
175
+ break;
176
+ case 'ellipse':
177
+ group.append('ellipse')
178
+ .attr('class', 'node-shape node-shape--ellipse')
179
+ .attr('cx', 0)
180
+ .attr('cy', 0)
181
+ .attr('rx', node.width / 2)
182
+ .attr('ry', node.height / 2)
183
+ .attr('fill', fill);
184
+ break;
185
+ case 'diamond':
186
+ case 'hexagon':
187
+ group.append('polygon')
188
+ .attr('class', 'node-shape node-shape--' + node.shape)
189
+ .attr('points', node.shape === 'diamond'
190
+ ? '0,' + (-node.height / 2) + ' ' + (node.width / 2) + ',0 ' + '0,' + (node.height / 2) + ' ' + (-node.width / 2) + ',0'
191
+ : (-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')
192
+ .attr('fill', fill);
193
+ break;
194
+ case 'package':
195
+ group.append('svg')
196
+ .attr('class', 'node-shape node-shape--package')
197
+ .attr('x', -node.width / 2)
198
+ .attr('y', -node.height / 2)
199
+ .attr('width', node.width)
200
+ .attr('height', node.height)
201
+ .attr('viewBox', '0 0 512 512')
202
+ .attr('aria-hidden', 'true')
203
+ .html('<polygon fill="#7E4F1F" points="511.516,107.105 511.788,107.036 264.065,31.004 0.479,106.898 0,106.759 0,411.849 238.676,480.721 238.676,480.159 238.888,480.996 512,412.125 512,107.036"/><polygon fill="#EDA637" points="264.065,31.004 0,107.036 238.676,175.907 511.788,107.036"/><polygon fill="#C27526" points="238.888,480.996 512,412.125 512,107.036 238.888,175.907"/><path fill="#FFEC99" d="M193.016 51.462 78.638 84.393l230.233 73.866v50.416c0 3.323 1.541 6.458 4.172 8.487 2.631 2.029 6.053 2.725 9.268 1.881l99.058-25.986c4.713-1.237 7.999-5.495 7.999-10.369v-54.816L193.016 51.462Z"/><path fill="#FFDC35" d="M486.158 374.572c0 6.29-4.278 11.775-10.378 13.307l-52.647 13.55c-2.779.698-5.736.663-8.41-.367-5.363-2.066-8.677-7.119-8.677-12.576V332.26c0-6.29 4.278-11.775 10.378-13.307l52.647-13.55c2.779-.698 5.736-.663 8.41.367 5.363 2.066 8.677 7.119 8.677 12.576v56.226Z"/><path fill="#FFDC35" d="M429.367 182.689c0 4.872-3.287 9.132-7.999 10.368l-99.058 25.986c-3.214.843-6.637.148-9.268-1.881-2.631-2.029-4.172-5.164-4.172-8.487v-50.416l120.497-30.386v54.816Z"/><path fill="#DF7400" d="m186.692 292.407-40.804-11.978c-5.273-1.548-8.895-6.385-8.895-11.881v-39.76c0-8.263 7.941-14.208 15.87-11.881l40.804 11.978c5.273 1.548 8.895 6.385 8.895 11.881v39.76c0 8.263-7.941 14.208-15.87 11.881Z"/>');
204
+ break;
205
+ case 'user':
206
+ const userIcon = group.append('svg')
207
+ .attr('class', 'node-shape node-shape--user')
208
+ .attr('x', -12)
209
+ .attr('y', -12)
210
+ .attr('width', 24)
211
+ .attr('height', 24)
212
+ .attr('viewBox', '0 0 24 24')
213
+ .attr('fill', 'none')
214
+ .attr('stroke', fill)
215
+ .attr('stroke-width', 1.5)
216
+ .attr('stroke-linecap', 'round')
217
+ .attr('stroke-linejoin', 'round');
218
+ userIcon.append('circle').attr('cx', 12).attr('cy', 9).attr('r', 3);
219
+ userIcon.append('circle').attr('cx', 12).attr('cy', 12).attr('r', 10);
220
+ 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');
221
+ break;
222
+ default:
223
+ group.append('rect')
224
+ .attr('class', 'node-shape')
225
+ .attr('x', -node.width / 2)
226
+ .attr('y', -node.height / 2)
227
+ .attr('width', node.width)
228
+ .attr('height', node.height)
229
+ .attr('rx', 6)
230
+ .attr('ry', 6)
231
+ .attr('fill', fill);
232
+ }
233
+ }
234
+
235
+ const simulation = d3.forceSimulation(nodesData)
236
+ .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))
237
+ .force('charge', d3.forceManyBody().strength((node) => node.kind === 'file' ? -420 : -220))
238
+ .force('center', d3.forceCenter(width / 2, height / 2))
239
+ .force('collide', d3.forceCollide().radius((node) => (node.presentationRadius ?? 24)))
240
+ .force('x', d3.forceX(width / 2).strength(0.05))
241
+ .force('y', d3.forceY(height / 2).strength(0.05));
242
+
243
+ const links = linkLayer.selectAll('line')
244
+ .data(linksData)
245
+ .join('line')
246
+ .attr('class', (d) => 'edge edge--' + d.kind)
247
+ .attr('marker-end', 'url(#arrow)');
248
+
249
+ const nodes = nodeLayer.selectAll('g')
250
+ .data(nodesData)
251
+ .join('g')
252
+ .attr('class', (d) => 'node node--' + d.kind)
253
+ .call(drag(simulation));
254
+
255
+ nodeLayer.raise();
256
+
257
+ nodes.each(function(d) {
258
+ const group = d3.select(this);
259
+ appendNodeShape(group, d);
260
+ group.append('text')
261
+ .attr('class', 'node-label')
262
+ .attr('text-anchor', 'middle')
263
+ .attr('dominant-baseline', display === 'shape' && d.kind === 'file' ? 'middle' : 'hanging')
264
+ .attr('y', display === 'shape' && d.kind === 'file' ? 0 : (d.height / 2) + 14)
265
+ .text(d.label);
266
+ group.append('title').text(d.label);
267
+ });
268
+
269
+ function updateDisplay() {
270
+ nodes.each(function(d) {
271
+ const group = d3.select(this);
272
+ group.selectAll('.node-shape').remove();
273
+ appendNodeShape(group, d);
274
+ group.select('.node-label')
275
+ .attr('dominant-baseline', display === 'shape' && d.kind === 'file' ? 'middle' : 'hanging')
276
+ .attr('y', display === 'shape' && d.kind === 'file' ? 0 : (d.height / 2) + 14);
277
+ });
278
+ }
279
+
280
+ if (displaySelect instanceof HTMLSelectElement) {
281
+ displaySelect.value = display;
282
+ }
283
+
284
+ const zoom = d3.zoom().scaleExtent([0.2, 4]).on('zoom', (event) => {
285
+ zoomLayer.attr('transform', event.transform);
286
+ });
287
+
288
+ svg.call(zoom);
289
+
290
+ function toggleKind(kind) {
291
+ if (hiddenKinds.has(kind)) {
292
+ hiddenKinds.delete(kind);
293
+ } else {
294
+ hiddenKinds.add(kind);
295
+ }
296
+ applyState();
297
+ }
298
+
299
+ function updateZoomState() {
300
+ if (zoomValue) {
301
+ zoomValue.textContent = formatZoom(zoomFactor) + '×';
302
+ }
303
+ if (zoomAllButton instanceof HTMLButtonElement) {
304
+ zoomAllButton.classList.toggle('is-active', focusMode === 'all');
305
+ }
306
+ if (zoomSelectedButton instanceof HTMLButtonElement) {
307
+ zoomSelectedButton.classList.toggle('is-active', focusMode === 'selected');
308
+ zoomSelectedButton.disabled = !anchorId;
309
+ }
310
+ if (focusButton instanceof HTMLButtonElement) {
311
+ focusButton.classList.toggle('is-active', focusVisibleOnly && Boolean(anchorId));
312
+ focusButton.setAttribute('aria-pressed', String(focusVisibleOnly && Boolean(anchorId)));
313
+ focusButton.disabled = !anchorId;
314
+ }
315
+ }
316
+
317
+ function updateLegendState() {
318
+ if (!legend) return;
319
+ for (const button of legend.querySelectorAll('[data-kind-toggle]')) {
320
+ const kind = button.getAttribute('data-kind-toggle');
321
+ const hidden = kind ? hiddenKinds.has(kind) : false;
322
+ button.classList.toggle('is-hidden', hidden);
323
+ button.setAttribute('aria-pressed', String(!hidden));
324
+ }
325
+ }
326
+
327
+ function formatZoom(value) {
328
+ return Number(value.toFixed(2)).toString().replace(/\.00$/, '');
329
+ }
330
+
331
+ function getVisibleSets() {
332
+ const query = filterText.trim().toLowerCase();
333
+ const matchingNodeIds = new Set(
334
+ nodesData
335
+ .filter((node) => !query || node.label.toLowerCase().includes(query))
336
+ .filter((node) => !hiddenKinds.has(node.kind))
337
+ .map((node) => node.id)
338
+ );
339
+
340
+ if (focusVisibleOnly && anchorId) {
341
+ const neighborhood = data.fullNeighborhoods && data.fullNeighborhoods[anchorId] ? data.fullNeighborhoods[anchorId] : { nodeIds: [anchorId], edgeIds: [] };
342
+ const visibleNodeIds = new Set(neighborhood.nodeIds.filter((nodeId) => matchingNodeIds.has(nodeId)));
343
+ const visibleEdgeIds = new Set(
344
+ linksData
345
+ .filter((edge) => visibleNodeIds.has(edgeEndpointId(edge.source)) && visibleNodeIds.has(edgeEndpointId(edge.target)))
346
+ .map((edge) => edge.id)
347
+ );
348
+ return { visibleNodeIds, visibleEdgeIds };
349
+ }
350
+
351
+ const visibleNodeIds = new Set([...allNodeIds].filter((nodeId) => matchingNodeIds.has(nodeId)));
352
+ const visibleEdgeIds = new Set(
353
+ linksData
354
+ .filter((edge) => visibleNodeIds.has(edgeEndpointId(edge.source)) && visibleNodeIds.has(edgeEndpointId(edge.target)))
355
+ .map((edge) => edge.id)
356
+ );
357
+ return { visibleNodeIds, visibleEdgeIds };
358
+ }
359
+
360
+ function edgeEndpointId(endpoint) {
361
+ return typeof endpoint === 'string' ? endpoint : endpoint?.id;
362
+ }
363
+
364
+ function updateStatus() {
365
+ if (anchorId && focusVisibleOnly) {
366
+ subtitle.textContent = 'Focus on ' + (nodeById.get(anchorId)?.label ?? anchorId);
367
+ return;
368
+ }
369
+
370
+ subtitle.textContent = anchorId
371
+ ? 'Selected ' + (nodeById.get(anchorId)?.label ?? anchorId)
372
+ : 'Showing entire graph';
373
+ }
374
+
375
+ function updateInfo() {
376
+ if (!nodeInfo) return;
377
+ const node = anchorId ? nodeById.get(anchorId) ?? null : null;
378
+ nodeInfo.replaceChildren();
379
+
380
+ if (!node) {
381
+ const empty = document.createElement('div');
382
+ empty.className = 'info-empty';
383
+ empty.textContent = 'Click a node to inspect it.';
384
+ nodeInfo.append(empty);
385
+ return;
386
+ }
387
+
388
+ const rows = [
389
+ ['Label', node.label],
390
+ ['Kind', node.kind],
391
+ ['Type', node.type],
392
+ ['Degree', String(node.degree)],
393
+ ['ID', node.id]
394
+ ];
395
+
396
+ for (const [label, value] of rows) {
397
+ const row = document.createElement('div');
398
+ row.className = 'info-row';
399
+
400
+ const key = document.createElement('div');
401
+ key.className = 'info-label';
402
+ key.textContent = label;
403
+
404
+ const val = document.createElement('div');
405
+ val.className = 'info-value';
406
+ val.textContent = value;
407
+
408
+ row.append(key, val);
409
+ nodeInfo.append(row);
410
+ }
411
+ }
412
+
413
+ function applyState() {
414
+ const visibleSets = getVisibleSets();
415
+ const visibleNodeIds = visibleSets.visibleNodeIds;
416
+ const visibleEdgeIds = visibleSets.visibleEdgeIds;
417
+ const activeAnchorVisible = Boolean(anchorId && visibleNodeIds.has(anchorId));
418
+
419
+ nodes
420
+ .classed('is-hidden', (node) => !visibleNodeIds.has(node.id))
421
+ .classed('is-dimmed', (node) => !focusVisibleOnly && anchorId ? !visibleNodeIds.has(node.id) : false)
422
+ .classed('is-selected', (node) => anchorId === node.id)
423
+ .classed('is-highlighted', (node) => activeAnchorVisible && visibleNodeIds.has(node.id));
424
+
425
+ links
426
+ .classed('is-hidden', (edge) => !visibleEdgeIds.has(edge.id))
427
+ .classed('is-dimmed', (edge) => !focusVisibleOnly && anchorId ? !visibleEdgeIds.has(edge.id) : false)
428
+ .classed('is-highlighted', (edge) => visibleEdgeIds.has(edge.id));
429
+
430
+ updateStatus();
431
+ updateInfo();
432
+ updateZoomState();
433
+ updateLegendState();
434
+ reframe(visibleNodeIds);
435
+ }
436
+
437
+ function setAnchor(nodeId) {
438
+ anchorId = anchorId === nodeId ? null : nodeId;
439
+ if (!anchorId) {
440
+ focusVisibleOnly = false;
441
+ }
442
+ focusMode = anchorId ? 'selected' : 'all';
443
+ applyState();
444
+ }
445
+
446
+ function setSidebarCollapsed(nextCollapsed) {
447
+ sidebarCollapsed = nextCollapsed;
448
+ app.classList.toggle('sidebar-collapsed', sidebarCollapsed);
449
+ if (sidebarToggle) {
450
+ sidebarToggle.setAttribute('aria-expanded', String(!sidebarCollapsed));
451
+ const icon = sidebarToggle.querySelector('.icon');
452
+ if (icon) icon.textContent = sidebarCollapsed ? '⟩' : '⟨';
453
+ sidebarToggle.title = sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar';
454
+ }
455
+ const nextSize = canvasSize();
456
+ svg.attr('viewBox', [0, 0, nextSize.width, nextSize.height]);
457
+ reframe(getVisibleSets().visibleNodeIds);
458
+ }
459
+
460
+ function zoomAll() {
461
+ focusMode = 'all';
462
+ applyState();
463
+ }
464
+
465
+ function zoomSelected() {
466
+ if (!anchorId) return;
467
+ focusMode = 'selected';
468
+ applyState();
469
+ }
470
+
471
+ function toggleFocus() {
472
+ if (!anchorId) return;
473
+ focusVisibleOnly = !focusVisibleOnly;
474
+ applyState();
475
+ }
476
+
477
+ function reframe(visibleNodeIds = allNodeIds) {
478
+ const selectedNode = anchorId ? nodesData.find((candidate) => candidate.id === anchorId) ?? null : null;
479
+ const { width: canvasWidth, height: canvasHeight } = canvasSize();
480
+
481
+ if (!focusVisibleOnly && focusMode === 'selected' && selectedNode) {
482
+ const transform = d3.zoomIdentity
483
+ .translate(canvasWidth / 2, canvasHeight / 2)
484
+ .scale(focusScale / zoomFactor)
485
+ .translate(-selectedNode.x, -selectedNode.y);
486
+ svg.call(zoom.transform, transform);
487
+ return;
488
+ }
489
+
490
+ const visibleNodes = nodesData.filter((node) => visibleNodeIds.has(node.id));
491
+ if (visibleNodes.length === 0) {
492
+ svg.call(zoom.transform, d3.zoomIdentity);
493
+ return;
494
+ }
495
+
496
+ const bounds = visibleNodes.reduce((acc, current) => ({
497
+ minX: Math.min(acc.minX, current.x),
498
+ maxX: Math.max(acc.maxX, current.x),
499
+ minY: Math.min(acc.minY, current.y),
500
+ maxY: Math.max(acc.maxY, current.y)
501
+ }), { minX: Number.POSITIVE_INFINITY, maxX: Number.NEGATIVE_INFINITY, minY: Number.POSITIVE_INFINITY, maxY: Number.NEGATIVE_INFINITY });
502
+
503
+ const boundedWidth = Math.max(bounds.maxX - bounds.minX, 1);
504
+ const boundedHeight = Math.max(bounds.maxY - bounds.minY, 1);
505
+ const baseScale = Math.min(
506
+ 4,
507
+ Math.max(
508
+ 0.2,
509
+ Math.min((canvasWidth - fitPadding * 2) / boundedWidth, (canvasHeight - fitPadding * 2) / boundedHeight)
510
+ )
511
+ );
512
+ const scale = baseScale / zoomFactor;
513
+ const centerX = (bounds.minX + bounds.maxX) / 2;
514
+ const centerY = (bounds.minY + bounds.maxY) / 2;
515
+ const transform = d3.zoomIdentity
516
+ .translate(canvasWidth / 2, canvasHeight / 2)
517
+ .scale(scale)
518
+ .translate(-centerX, -centerY);
519
+
520
+ svg.call(zoom.transform, transform);
521
+ }
522
+
523
+ function drag(sim) {
524
+ function dragstarted(event) {
525
+ if (!event.active) sim.alphaTarget(0.3).restart();
526
+ event.subject.fx = event.subject.x;
527
+ event.subject.fy = event.subject.y;
528
+ }
529
+
530
+ function dragged(event) {
531
+ event.subject.fx = event.x;
532
+ event.subject.fy = event.y;
533
+ }
534
+
535
+ function dragended(event) {
536
+ if (!event.active) sim.alphaTarget(0);
537
+ event.subject.fx = null;
538
+ event.subject.fy = null;
539
+ }
540
+
541
+ return d3.drag()
542
+ .on('start', dragstarted)
543
+ .on('drag', dragged)
544
+ .on('end', dragended);
545
+ }
546
+
547
+ function ticked() {
548
+ links
549
+ .attr('x1', (d) => d.source.x)
550
+ .attr('y1', (d) => d.source.y)
551
+ .attr('x2', (d) => d.target.x)
552
+ .attr('y2', (d) => d.target.y);
553
+
554
+ nodes.attr('transform', (d) => 'translate(' + d.x + ',' + d.y + ')');
555
+ }
556
+
557
+ nodes
558
+ .on('click', (event, node) => {
559
+ event.stopPropagation();
560
+ setAnchor(node.id);
561
+ })
562
+ .on('mouseenter', function(event, node) {
563
+ if (!tooltip) return;
564
+ tooltip.style.display = 'block';
565
+ tooltip.textContent = node.label + ' · ' + node.kind;
566
+ })
567
+ .on('mousemove', function(event) {
568
+ if (!tooltip) return;
569
+ tooltip.style.left = event.clientX + 14 + 'px';
570
+ tooltip.style.top = event.clientY + 14 + 'px';
571
+ })
572
+ .on('mouseleave', function() {
573
+ if (!tooltip) return;
574
+ tooltip.style.display = 'none';
575
+ });
576
+
577
+ svg.on('click', (event) => {
578
+ if (event.target === svg.node()) {
579
+ anchorId = null;
580
+ focusMode = 'all';
581
+ applyState();
582
+ }
583
+ });
584
+
585
+ displaySelect?.addEventListener('change', (event) => {
586
+ const value = event.target instanceof HTMLSelectElement ? event.target.value : 'shape';
587
+ if (value !== 'simple' && value !== 'shape' && value !== 'icon') return;
588
+ display = value;
589
+ updateDisplay();
590
+ });
591
+
592
+ searchInput.value = filterText;
593
+ searchInput.addEventListener('input', (event) => {
594
+ filterText = event.target.value;
595
+ applyState();
596
+ });
597
+
598
+ zoomInput?.addEventListener('input', (event) => {
599
+ const target = event.target;
600
+ if (!(target instanceof HTMLInputElement)) return;
601
+ zoomFactor = Number(target.value) || 1;
602
+ applyState();
603
+ });
604
+
605
+ zoomAllButton?.addEventListener('click', () => {
606
+ zoomAll();
607
+ });
608
+
609
+ zoomSelectedButton?.addEventListener('click', () => {
610
+ zoomSelected();
611
+ });
612
+
613
+ focusButton?.addEventListener('click', () => {
614
+ toggleFocus();
615
+ });
616
+
617
+ legend?.addEventListener('click', (event) => {
618
+ const target = event.target instanceof Element ? event.target.closest('[data-kind-toggle]') : null;
619
+ if (!target) return;
620
+ const kind = target.getAttribute('data-kind-toggle');
621
+ if (!kind) return;
622
+ toggleKind(kind);
623
+ });
624
+
625
+ sidebarToggle?.addEventListener('click', () => {
626
+ setSidebarCollapsed(!sidebarCollapsed);
627
+ });
628
+
629
+ window.addEventListener('resize', () => {
630
+ if (!graphPanel) return;
631
+ const nextSize = canvasSize();
632
+ svg.attr('viewBox', [0, 0, nextSize.width, nextSize.height]);
633
+ reframe(getVisibleSets().visibleNodeIds);
634
+ });
635
+
636
+ simulation.on('tick', ticked);
637
+ setSidebarCollapsed(false);
638
+ applyState();
639
+ simulation.alpha(1).restart();
640
+ </script>
641
+ </body>
642
+ </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.3",
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",