@psnext/lscg 0.1.1
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 +143 -0
- package/dist/bin/lscg.d.ts +3 -0
- package/dist/bin/lscg.js +11 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +524 -0
- package/dist/src/config/paths.d.ts +12 -0
- package/dist/src/config/paths.js +54 -0
- package/dist/src/graph/attribution.d.ts +8 -0
- package/dist/src/graph/attribution.js +112 -0
- package/dist/src/graph/extract.d.ts +4 -0
- package/dist/src/graph/extract.js +199 -0
- package/dist/src/graph/repository.d.ts +93 -0
- package/dist/src/graph/repository.js +361 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +5 -0
- package/dist/src/mcp/server.d.ts +6 -0
- package/dist/src/mcp/server.js +81 -0
- package/dist/src/parser/treeSitter.d.ts +13 -0
- package/dist/src/parser/treeSitter.js +61 -0
- package/dist/src/scanner/discover.d.ts +4 -0
- package/dist/src/scanner/discover.js +62 -0
- package/dist/src/scanner/fingerprint.d.ts +3 -0
- package/dist/src/scanner/fingerprint.js +27 -0
- package/dist/src/storage/database.d.ts +72 -0
- package/dist/src/storage/database.js +563 -0
- package/dist/src/storage/schema.d.ts +4 -0
- package/dist/src/storage/schema.js +93 -0
- package/dist/src/types.d.ts +233 -0
- package/dist/src/types.js +2 -0
- package/dist/src/view/index.d.ts +27 -0
- package/dist/src/view/index.js +42 -0
- package/dist/src/view/layout.d.ts +28 -0
- package/dist/src/view/layout.js +235 -0
- package/dist/src/view/model.d.ts +64 -0
- package/dist/src/view/model.js +396 -0
- package/dist/src/view/open.d.ts +15 -0
- package/dist/src/view/open.js +37 -0
- package/dist/src/view/render.d.ts +9 -0
- package/dist/src/view/render.js +321 -0
- package/dist/src/watch.d.ts +40 -0
- package/dist/src/watch.js +118 -0
- package/package.json +65 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { GraphEdgeRow, GraphNodeRow, RepositoryRecord, StorageScope } from '../types.js';
|
|
2
|
+
import { type GraphLike, type LayoutBounds } from './layout.js';
|
|
3
|
+
export interface ViewSnapshot {
|
|
4
|
+
repository: RepositoryRecord;
|
|
5
|
+
scope: StorageScope;
|
|
6
|
+
nodes: ViewNode[];
|
|
7
|
+
edges: ViewEdge[];
|
|
8
|
+
}
|
|
9
|
+
export interface ViewNode extends GraphNodeRow {
|
|
10
|
+
label: string;
|
|
11
|
+
degree: number;
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ViewEdge extends GraphEdgeRow {
|
|
16
|
+
}
|
|
17
|
+
export interface ViewBox {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
}
|
|
23
|
+
export interface ViewNeighborhood {
|
|
24
|
+
nodeIds: string[];
|
|
25
|
+
edgeIds: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface ViewModel extends ViewSnapshot {
|
|
28
|
+
graph: GraphLike;
|
|
29
|
+
fullNodes: ViewNode[];
|
|
30
|
+
fullEdges: ViewEdge[];
|
|
31
|
+
fullVisibleNodeIds: string[];
|
|
32
|
+
fullVisibleEdgeIds: string[];
|
|
33
|
+
fullNeighborhoods: Record<string, ViewNeighborhood>;
|
|
34
|
+
fullInitialViewBox: ViewBox;
|
|
35
|
+
search: string | null;
|
|
36
|
+
initialVisibleNodeIds: string[];
|
|
37
|
+
initialVisibleEdgeIds: string[];
|
|
38
|
+
anchorNodeId: string | null;
|
|
39
|
+
initialHighlightedNodeIds: string[];
|
|
40
|
+
initialHighlightedEdgeIds: string[];
|
|
41
|
+
neighborhoods: Record<string, ViewNeighborhood>;
|
|
42
|
+
initialViewBox: ViewBox;
|
|
43
|
+
graphBounds: LayoutBounds;
|
|
44
|
+
startWithFullGraph: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface ViewModelOptions {
|
|
47
|
+
root?: string | undefined;
|
|
48
|
+
scope?: StorageScope | 'both' | undefined;
|
|
49
|
+
anchor?: string | undefined;
|
|
50
|
+
search?: string | undefined;
|
|
51
|
+
}
|
|
52
|
+
export declare function loadViewSnapshot({ root, scope }?: ViewModelOptions): ViewSnapshot;
|
|
53
|
+
export declare function buildViewModel(snapshot: ViewSnapshot, { anchor, search }?: ViewModelOptions): ViewModel;
|
|
54
|
+
export declare function normalizeViewScope(scope: StorageScope | 'both' | undefined): StorageScope;
|
|
55
|
+
export declare function filterNodeNames(nodes: ViewNode[], search: string | undefined): ViewNode[];
|
|
56
|
+
export declare function selectAnchorNodeId({ nodes, search, anchor }: {
|
|
57
|
+
nodes: ViewNode[];
|
|
58
|
+
search: string | null;
|
|
59
|
+
anchor: string | undefined;
|
|
60
|
+
}): string | null;
|
|
61
|
+
export declare function selectVisibleNodeIds(nodes: ViewNode[], search: string | null): string[];
|
|
62
|
+
export declare function selectVisibleEdgeIds(edges: ViewEdge[], visibleNodeIds: Set<string>): string[];
|
|
63
|
+
export declare function computeInitialViewBox(nodes: ViewNode[], visibleNodeIds: string[], anchorNodeId: string | null): ViewBox;
|
|
64
|
+
//# sourceMappingURL=model.d.ts.map
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import Graphology from 'graphology';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { databasePathForScope } from '../config/paths.js';
|
|
4
|
+
import { closeDatabase, openReadOnlyGraphDatabase, selectAllEdges, selectAllNodes } from '../storage/database.js';
|
|
5
|
+
import { repositoryForRoot } from '../graph/repository.js';
|
|
6
|
+
import { assignDeterministicLayout } from './layout.js';
|
|
7
|
+
export function loadViewSnapshot({ root = process.cwd(), scope = 'repo' } = {}) {
|
|
8
|
+
const repository = repositoryForRoot(root);
|
|
9
|
+
const storageScope = normalizeViewScope(scope);
|
|
10
|
+
const databasePath = databasePathForScope(storageScope, repository.root);
|
|
11
|
+
if (!existsSync(databasePath)) {
|
|
12
|
+
return {
|
|
13
|
+
repository,
|
|
14
|
+
scope: storageScope,
|
|
15
|
+
nodes: [],
|
|
16
|
+
edges: []
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const db = openReadOnlyGraphDatabase(databasePath);
|
|
20
|
+
try {
|
|
21
|
+
const nodes = selectAllNodes(db, repository.id);
|
|
22
|
+
const edges = selectAllEdges(db, repository.id);
|
|
23
|
+
return {
|
|
24
|
+
repository,
|
|
25
|
+
scope: storageScope,
|
|
26
|
+
nodes: decorateNodes(nodes, edges),
|
|
27
|
+
edges: [...edges]
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
closeDatabase(db);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function buildViewModel(snapshot, { anchor, search } = {}) {
|
|
35
|
+
const normalizedGraph = collapseEquivalentNodesAndEdges(snapshot.nodes, snapshot.edges);
|
|
36
|
+
const normalizedSearch = normalizeSearch(search);
|
|
37
|
+
const explicitAnchor = normalizeSearch(anchor);
|
|
38
|
+
const startWithFullGraph = !explicitAnchor;
|
|
39
|
+
const anchorNodeId = selectAnchorNodeId({
|
|
40
|
+
nodes: normalizedGraph.nodes,
|
|
41
|
+
search: normalizedSearch,
|
|
42
|
+
anchor
|
|
43
|
+
});
|
|
44
|
+
const graph = buildGraph(normalizedGraph.nodes, normalizedGraph.edges);
|
|
45
|
+
const graphBounds = assignDeterministicLayout(graph);
|
|
46
|
+
const fullNodes = normalizedGraph.nodes.map((node) => {
|
|
47
|
+
const attributes = graph.getNodeAttributes(node.id);
|
|
48
|
+
return {
|
|
49
|
+
...node,
|
|
50
|
+
x: Number(attributes.x ?? 0),
|
|
51
|
+
y: Number(attributes.y ?? 0)
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
const fullEdges = [...normalizedGraph.edges];
|
|
55
|
+
const fullVisibleNodeIds = fullNodes.map((node) => node.id);
|
|
56
|
+
const fullVisibleEdgeIds = fullEdges.map((edge) => edge.id);
|
|
57
|
+
const fullNeighborhoods = buildNeighborhoods(fullNodes, fullEdges);
|
|
58
|
+
const visibleNodeIds = startWithFullGraph
|
|
59
|
+
? fullVisibleNodeIds
|
|
60
|
+
: selectVisibleNodeIds(fullNodes, normalizedSearch);
|
|
61
|
+
const visibleEdgeIds = startWithFullGraph
|
|
62
|
+
? fullVisibleEdgeIds
|
|
63
|
+
: selectVisibleEdgeIds(fullEdges, new Set(visibleNodeIds));
|
|
64
|
+
const highlighted = anchorNodeId ? fullNeighborhoods[anchorNodeId] : { nodeIds: [], edgeIds: [] };
|
|
65
|
+
const initialHighlightedNodeIds = highlighted?.nodeIds ?? [];
|
|
66
|
+
const initialHighlightedEdgeIds = highlighted?.edgeIds ?? [];
|
|
67
|
+
const initialViewBox = computeInitialViewBox(fullNodes, visibleNodeIds, startWithFullGraph ? null : anchorNodeId);
|
|
68
|
+
const fullInitialViewBox = computeInitialViewBox(fullNodes, fullVisibleNodeIds, null);
|
|
69
|
+
return {
|
|
70
|
+
...snapshot,
|
|
71
|
+
nodes: fullNodes,
|
|
72
|
+
edges: fullEdges,
|
|
73
|
+
graph,
|
|
74
|
+
fullNodes,
|
|
75
|
+
fullEdges,
|
|
76
|
+
fullVisibleNodeIds,
|
|
77
|
+
fullVisibleEdgeIds,
|
|
78
|
+
fullNeighborhoods,
|
|
79
|
+
fullInitialViewBox,
|
|
80
|
+
search: normalizedSearch,
|
|
81
|
+
initialVisibleNodeIds: visibleNodeIds,
|
|
82
|
+
initialVisibleEdgeIds: visibleEdgeIds,
|
|
83
|
+
anchorNodeId: startWithFullGraph ? null : anchorNodeId,
|
|
84
|
+
initialHighlightedNodeIds,
|
|
85
|
+
initialHighlightedEdgeIds,
|
|
86
|
+
neighborhoods: fullNeighborhoods,
|
|
87
|
+
initialViewBox,
|
|
88
|
+
graphBounds,
|
|
89
|
+
startWithFullGraph
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function normalizeViewScope(scope) {
|
|
93
|
+
if (scope === 'repo' || scope === 'home')
|
|
94
|
+
return scope;
|
|
95
|
+
if (scope === 'both') {
|
|
96
|
+
throw new Error('view supports repo or home scope only');
|
|
97
|
+
}
|
|
98
|
+
return 'repo';
|
|
99
|
+
}
|
|
100
|
+
export function filterNodeNames(nodes, search) {
|
|
101
|
+
const normalizedSearch = normalizeSearch(search);
|
|
102
|
+
if (!normalizedSearch)
|
|
103
|
+
return [...nodes];
|
|
104
|
+
return nodes.filter((node) => nodeMatchesSearch(node, normalizedSearch));
|
|
105
|
+
}
|
|
106
|
+
export function selectAnchorNodeId({ nodes, search, anchor }) {
|
|
107
|
+
if (nodes.length === 0)
|
|
108
|
+
return null;
|
|
109
|
+
const anchorTarget = normalizeSearch(anchor);
|
|
110
|
+
if (!anchorTarget) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const visibleNodes = search ? nodes.filter((node) => nodeMatchesSearch(node, search)) : nodes;
|
|
114
|
+
const rankingSource = visibleNodes.length > 0 ? visibleNodes : nodes;
|
|
115
|
+
const exactMatches = rankingSource.filter((node) => node.label.toLowerCase() === anchorTarget);
|
|
116
|
+
if (exactMatches.length > 0) {
|
|
117
|
+
return highestDegreeNode(exactMatches)?.id ?? null;
|
|
118
|
+
}
|
|
119
|
+
return highestDegreeNode(rankingSource)?.id ?? null;
|
|
120
|
+
}
|
|
121
|
+
export function selectVisibleNodeIds(nodes, search) {
|
|
122
|
+
if (!search)
|
|
123
|
+
return nodes.map((node) => node.id);
|
|
124
|
+
return nodes.filter((node) => nodeMatchesSearch(node, search)).map((node) => node.id);
|
|
125
|
+
}
|
|
126
|
+
export function selectVisibleEdgeIds(edges, visibleNodeIds) {
|
|
127
|
+
if (visibleNodeIds.size === 0)
|
|
128
|
+
return [];
|
|
129
|
+
return edges
|
|
130
|
+
.filter((edge) => visibleNodeIds.has(edge.sourceId) && visibleNodeIds.has(edge.targetId))
|
|
131
|
+
.map((edge) => edge.id);
|
|
132
|
+
}
|
|
133
|
+
function buildAdaptiveSlice(nodes, edges, anchorNodeId) {
|
|
134
|
+
if (nodes.length === 0 || !anchorNodeId) {
|
|
135
|
+
return { nodes: [], edges: [] };
|
|
136
|
+
}
|
|
137
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
138
|
+
const adjacency = buildAdjacency(nodes, edges);
|
|
139
|
+
const distances = bfsDistances(anchorNodeId, adjacency);
|
|
140
|
+
const reachableNodes = nodes.filter((node) => distances.has(node.id));
|
|
141
|
+
const cap = determineReadabilityCap(reachableNodes.length);
|
|
142
|
+
const rankedNodes = [...reachableNodes].sort((left, right) => compareSlicePriority(left, right, distances));
|
|
143
|
+
const selectedIds = new Set();
|
|
144
|
+
for (const node of rankedNodes) {
|
|
145
|
+
selectedIds.add(node.id);
|
|
146
|
+
if (selectedIds.size >= cap)
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
if (!selectedIds.has(anchorNodeId) && nodeById.has(anchorNodeId)) {
|
|
150
|
+
selectedIds.add(anchorNodeId);
|
|
151
|
+
}
|
|
152
|
+
const selectedNodes = rankedNodes.filter((node) => selectedIds.has(node.id));
|
|
153
|
+
const selectedEdges = [...edges]
|
|
154
|
+
.filter((edge) => selectedIds.has(edge.sourceId) && selectedIds.has(edge.targetId))
|
|
155
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
156
|
+
return { nodes: selectedNodes, edges: selectedEdges };
|
|
157
|
+
}
|
|
158
|
+
function determineReadabilityCap(nodeCount) {
|
|
159
|
+
if (nodeCount <= 0)
|
|
160
|
+
return 0;
|
|
161
|
+
if (nodeCount <= 8)
|
|
162
|
+
return nodeCount;
|
|
163
|
+
return Math.min(nodeCount, Math.max(8, Math.min(35, Math.ceil(nodeCount * 0.35))));
|
|
164
|
+
}
|
|
165
|
+
function compareSlicePriority(left, right, distances) {
|
|
166
|
+
const leftDistance = distances.get(left.id) ?? Number.POSITIVE_INFINITY;
|
|
167
|
+
const rightDistance = distances.get(right.id) ?? Number.POSITIVE_INFINITY;
|
|
168
|
+
if (leftDistance !== rightDistance)
|
|
169
|
+
return leftDistance - rightDistance;
|
|
170
|
+
if (right.degree !== left.degree)
|
|
171
|
+
return right.degree - left.degree;
|
|
172
|
+
const labelComparison = left.label.localeCompare(right.label);
|
|
173
|
+
if (labelComparison !== 0)
|
|
174
|
+
return labelComparison;
|
|
175
|
+
return left.id.localeCompare(right.id);
|
|
176
|
+
}
|
|
177
|
+
function buildAdjacency(nodes, edges) {
|
|
178
|
+
const adjacency = new Map();
|
|
179
|
+
for (const node of nodes) {
|
|
180
|
+
adjacency.set(node.id, []);
|
|
181
|
+
}
|
|
182
|
+
for (const edge of [...edges].sort((left, right) => left.id.localeCompare(right.id))) {
|
|
183
|
+
const source = adjacency.get(edge.sourceId);
|
|
184
|
+
const target = adjacency.get(edge.targetId);
|
|
185
|
+
if (!source || !target)
|
|
186
|
+
continue;
|
|
187
|
+
source.push({ nodeId: edge.targetId, edgeId: edge.id });
|
|
188
|
+
target.push({ nodeId: edge.sourceId, edgeId: edge.id });
|
|
189
|
+
}
|
|
190
|
+
return adjacency;
|
|
191
|
+
}
|
|
192
|
+
function bfsDistances(anchorNodeId, adjacency) {
|
|
193
|
+
const distances = new Map([[anchorNodeId, 0]]);
|
|
194
|
+
const queue = [anchorNodeId];
|
|
195
|
+
while (queue.length > 0) {
|
|
196
|
+
const nodeId = queue.shift();
|
|
197
|
+
const nextDistance = (distances.get(nodeId) ?? 0) + 1;
|
|
198
|
+
for (const neighbor of adjacency.get(nodeId) ?? []) {
|
|
199
|
+
if (distances.has(neighbor.nodeId))
|
|
200
|
+
continue;
|
|
201
|
+
distances.set(neighbor.nodeId, nextDistance);
|
|
202
|
+
queue.push(neighbor.nodeId);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return distances;
|
|
206
|
+
}
|
|
207
|
+
function buildNeighborhoods(nodes, edges) {
|
|
208
|
+
const neighborhoods = new Map();
|
|
209
|
+
for (const node of nodes) {
|
|
210
|
+
neighborhoods.set(node.id, {
|
|
211
|
+
nodeIds: new Set([node.id]),
|
|
212
|
+
edgeIds: new Set()
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
for (const edge of edges) {
|
|
216
|
+
const source = neighborhoods.get(edge.sourceId);
|
|
217
|
+
const target = neighborhoods.get(edge.targetId);
|
|
218
|
+
if (source) {
|
|
219
|
+
source.nodeIds.add(edge.targetId);
|
|
220
|
+
source.edgeIds.add(edge.id);
|
|
221
|
+
}
|
|
222
|
+
if (target) {
|
|
223
|
+
target.nodeIds.add(edge.sourceId);
|
|
224
|
+
target.edgeIds.add(edge.id);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const entries = {};
|
|
228
|
+
for (const [nodeId, neighborhood] of neighborhoods) {
|
|
229
|
+
entries[nodeId] = {
|
|
230
|
+
nodeIds: [...neighborhood.nodeIds],
|
|
231
|
+
edgeIds: [...neighborhood.edgeIds]
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return entries;
|
|
235
|
+
}
|
|
236
|
+
export function computeInitialViewBox(nodes, visibleNodeIds, anchorNodeId) {
|
|
237
|
+
if (nodes.length === 0) {
|
|
238
|
+
return { x: 0, y: 0, width: 1000, height: 800 };
|
|
239
|
+
}
|
|
240
|
+
const bounds = boundsForNodes(nodes);
|
|
241
|
+
const width = Math.max(bounds.width * 1.35, 900);
|
|
242
|
+
const height = Math.max(bounds.height * 1.35, 650);
|
|
243
|
+
const centerX = bounds.minX + bounds.width / 2;
|
|
244
|
+
const centerY = bounds.minY + bounds.height / 2;
|
|
245
|
+
return {
|
|
246
|
+
x: centerX - width / 2,
|
|
247
|
+
y: centerY - height / 2,
|
|
248
|
+
width,
|
|
249
|
+
height
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function collapseEquivalentNodesAndEdges(nodes, edges) {
|
|
253
|
+
if (nodes.length === 0) {
|
|
254
|
+
return { nodes: [], edges: [] };
|
|
255
|
+
}
|
|
256
|
+
const degreeByNodeId = new Map();
|
|
257
|
+
for (const edge of edges) {
|
|
258
|
+
degreeByNodeId.set(edge.sourceId, (degreeByNodeId.get(edge.sourceId) ?? 0) + 1);
|
|
259
|
+
degreeByNodeId.set(edge.targetId, (degreeByNodeId.get(edge.targetId) ?? 0) + 1);
|
|
260
|
+
}
|
|
261
|
+
const groups = new Map();
|
|
262
|
+
for (const node of nodes) {
|
|
263
|
+
const key = collapseKey(node);
|
|
264
|
+
const group = groups.get(key) ?? [];
|
|
265
|
+
group.push(node);
|
|
266
|
+
groups.set(key, group);
|
|
267
|
+
}
|
|
268
|
+
const canonicalByNodeId = new Map();
|
|
269
|
+
const collapsedNodes = [];
|
|
270
|
+
const sortedGroups = [...groups.entries()].sort((left, right) => left[0].localeCompare(right[0]));
|
|
271
|
+
for (const [, group] of sortedGroups) {
|
|
272
|
+
const representative = [...group].sort((left, right) => {
|
|
273
|
+
const leftDegree = degreeByNodeId.get(left.id) ?? 0;
|
|
274
|
+
const rightDegree = degreeByNodeId.get(right.id) ?? 0;
|
|
275
|
+
if (rightDegree !== leftDegree)
|
|
276
|
+
return rightDegree - leftDegree;
|
|
277
|
+
return left.id.localeCompare(right.id);
|
|
278
|
+
})[0] ?? group[0];
|
|
279
|
+
if (!representative)
|
|
280
|
+
continue;
|
|
281
|
+
for (const node of group) {
|
|
282
|
+
canonicalByNodeId.set(node.id, representative.id);
|
|
283
|
+
}
|
|
284
|
+
collapsedNodes.push(representative);
|
|
285
|
+
}
|
|
286
|
+
const collapsedEdgesMap = new Map();
|
|
287
|
+
for (const edge of edges) {
|
|
288
|
+
const sourceId = canonicalByNodeId.get(edge.sourceId) ?? edge.sourceId;
|
|
289
|
+
const targetId = canonicalByNodeId.get(edge.targetId) ?? edge.targetId;
|
|
290
|
+
const key = [sourceId, targetId, edge.kind, edge.metadataJson].join('\u0000');
|
|
291
|
+
if (collapsedEdgesMap.has(key))
|
|
292
|
+
continue;
|
|
293
|
+
collapsedEdgesMap.set(key, {
|
|
294
|
+
...edge,
|
|
295
|
+
sourceId,
|
|
296
|
+
targetId
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
const collapsedEdges = [...collapsedEdgesMap.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
300
|
+
const collapsedDegreeByNodeId = new Map();
|
|
301
|
+
for (const edge of collapsedEdges) {
|
|
302
|
+
collapsedDegreeByNodeId.set(edge.sourceId, (collapsedDegreeByNodeId.get(edge.sourceId) ?? 0) + 1);
|
|
303
|
+
collapsedDegreeByNodeId.set(edge.targetId, (collapsedDegreeByNodeId.get(edge.targetId) ?? 0) + 1);
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
nodes: collapsedNodes.map((node) => ({
|
|
307
|
+
...node,
|
|
308
|
+
degree: collapsedDegreeByNodeId.get(node.id) ?? 0
|
|
309
|
+
})),
|
|
310
|
+
edges: collapsedEdges
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function collapseKey(node) {
|
|
314
|
+
return [node.kind, node.type, node.name ?? ''].join('\u0000');
|
|
315
|
+
}
|
|
316
|
+
function buildGraph(nodes, edges) {
|
|
317
|
+
const GraphConstructor = Graphology;
|
|
318
|
+
const graph = new GraphConstructor({ type: 'directed', multi: true });
|
|
319
|
+
for (const node of nodes) {
|
|
320
|
+
graph.addNode(node.id, {
|
|
321
|
+
...node,
|
|
322
|
+
x: 0,
|
|
323
|
+
y: 0
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
for (const edge of edges) {
|
|
327
|
+
if (!graph.hasNode(edge.sourceId) || !graph.hasNode(edge.targetId))
|
|
328
|
+
continue;
|
|
329
|
+
graph.addEdgeWithKey(edge.id, edge.sourceId, edge.targetId, {
|
|
330
|
+
kind: edge.kind,
|
|
331
|
+
confidence: edge.confidence,
|
|
332
|
+
metadataJson: edge.metadataJson
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return graph;
|
|
336
|
+
}
|
|
337
|
+
function decorateNodes(nodes, edges) {
|
|
338
|
+
const degreeByNodeId = new Map();
|
|
339
|
+
for (const edge of edges) {
|
|
340
|
+
degreeByNodeId.set(edge.sourceId, (degreeByNodeId.get(edge.sourceId) ?? 0) + 1);
|
|
341
|
+
degreeByNodeId.set(edge.targetId, (degreeByNodeId.get(edge.targetId) ?? 0) + 1);
|
|
342
|
+
}
|
|
343
|
+
return nodes.map((node) => ({
|
|
344
|
+
...node,
|
|
345
|
+
label: node.name?.trim() || node.type,
|
|
346
|
+
degree: degreeByNodeId.get(node.id) ?? 0,
|
|
347
|
+
x: 0,
|
|
348
|
+
y: 0
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
function highestDegreeNode(nodes) {
|
|
352
|
+
if (nodes.length === 0)
|
|
353
|
+
return null;
|
|
354
|
+
return [...nodes].sort((left, right) => {
|
|
355
|
+
if (right.degree !== left.degree)
|
|
356
|
+
return right.degree - left.degree;
|
|
357
|
+
const labelComparison = left.label.localeCompare(right.label);
|
|
358
|
+
if (labelComparison !== 0)
|
|
359
|
+
return labelComparison;
|
|
360
|
+
return left.id.localeCompare(right.id);
|
|
361
|
+
})[0] ?? null;
|
|
362
|
+
}
|
|
363
|
+
function nodeMatchesSearch(node, search) {
|
|
364
|
+
return node.name?.toLowerCase().includes(search) ?? false;
|
|
365
|
+
}
|
|
366
|
+
function normalizeSearch(value) {
|
|
367
|
+
const trimmed = value?.trim().toLowerCase() ?? '';
|
|
368
|
+
return trimmed ? trimmed : null;
|
|
369
|
+
}
|
|
370
|
+
function boundsForNodes(nodes) {
|
|
371
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
372
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
373
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
374
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
375
|
+
for (const node of nodes) {
|
|
376
|
+
if (node.x < minX)
|
|
377
|
+
minX = node.x;
|
|
378
|
+
if (node.x > maxX)
|
|
379
|
+
maxX = node.x;
|
|
380
|
+
if (node.y < minY)
|
|
381
|
+
minY = node.y;
|
|
382
|
+
if (node.y > maxY)
|
|
383
|
+
maxY = node.y;
|
|
384
|
+
}
|
|
385
|
+
if (!Number.isFinite(minX))
|
|
386
|
+
return { minX: 0, maxX: 0, minY: 0, maxY: 0, width: 0, height: 0 };
|
|
387
|
+
return {
|
|
388
|
+
minX,
|
|
389
|
+
maxX,
|
|
390
|
+
minY,
|
|
391
|
+
maxY,
|
|
392
|
+
width: maxX - minX,
|
|
393
|
+
height: maxY - minY
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
//# sourceMappingURL=model.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface TempHtmlArtifact {
|
|
2
|
+
directoryPath: string;
|
|
3
|
+
htmlPath: string;
|
|
4
|
+
}
|
|
5
|
+
export interface BrowserOpenResult {
|
|
6
|
+
opened: boolean;
|
|
7
|
+
command?: string | undefined;
|
|
8
|
+
error?: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
export interface TempHtmlOpenResult extends TempHtmlArtifact, BrowserOpenResult {
|
|
11
|
+
}
|
|
12
|
+
export declare function writeTemporaryHtmlArtifact(html: string, prefix?: string): TempHtmlArtifact;
|
|
13
|
+
export declare function openHtmlArtifactInBrowser(html: string, opener?: (htmlPath: string) => BrowserOpenResult): TempHtmlOpenResult;
|
|
14
|
+
export declare function openInDefaultBrowser(targetPath: string): BrowserOpenResult;
|
|
15
|
+
//# sourceMappingURL=open.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
export function writeTemporaryHtmlArtifact(html, prefix = 'lscg-view-') {
|
|
6
|
+
const directoryPath = mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
7
|
+
const htmlPath = path.join(directoryPath, 'view.html');
|
|
8
|
+
writeFileSync(htmlPath, html, 'utf8');
|
|
9
|
+
return { directoryPath, htmlPath };
|
|
10
|
+
}
|
|
11
|
+
export function openHtmlArtifactInBrowser(html, opener = openInDefaultBrowser) {
|
|
12
|
+
const artifact = writeTemporaryHtmlArtifact(html);
|
|
13
|
+
const result = opener(artifact.htmlPath);
|
|
14
|
+
return { ...artifact, ...result };
|
|
15
|
+
}
|
|
16
|
+
export function openInDefaultBrowser(targetPath) {
|
|
17
|
+
const commands = process.platform === 'darwin'
|
|
18
|
+
? [['open', [targetPath]]]
|
|
19
|
+
: process.platform === 'win32'
|
|
20
|
+
? [['cmd', ['/c', 'start', '', targetPath]]]
|
|
21
|
+
: [
|
|
22
|
+
['xdg-open', [targetPath]],
|
|
23
|
+
['gio', ['open', targetPath]],
|
|
24
|
+
['gnome-open', [targetPath]]
|
|
25
|
+
];
|
|
26
|
+
for (const [command, args] of commands) {
|
|
27
|
+
const result = spawnSync(command, args, { stdio: 'ignore', windowsHide: true });
|
|
28
|
+
if (!result.error && result.status === 0) {
|
|
29
|
+
return { opened: true, command };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
opened: false,
|
|
34
|
+
error: `Unable to open ${targetPath} in a browser`
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=open.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ViewModel } from './model.js';
|
|
2
|
+
export interface RenderSvgOptions {
|
|
3
|
+
includeHiddenNodes?: boolean | undefined;
|
|
4
|
+
interactive?: boolean | undefined;
|
|
5
|
+
}
|
|
6
|
+
export declare function renderSvgMarkup(model: ViewModel, options?: RenderSvgOptions): string;
|
|
7
|
+
export declare function renderInteractiveHtml(model: ViewModel): string;
|
|
8
|
+
export declare function writeSvgSnapshot(filePath: string, svgMarkup: string): void;
|
|
9
|
+
//# sourceMappingURL=render.d.ts.map
|