@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.
Files changed (42) hide show
  1. package/README.md +143 -0
  2. package/dist/bin/lscg.d.ts +3 -0
  3. package/dist/bin/lscg.js +11 -0
  4. package/dist/src/cli.d.ts +2 -0
  5. package/dist/src/cli.js +524 -0
  6. package/dist/src/config/paths.d.ts +12 -0
  7. package/dist/src/config/paths.js +54 -0
  8. package/dist/src/graph/attribution.d.ts +8 -0
  9. package/dist/src/graph/attribution.js +112 -0
  10. package/dist/src/graph/extract.d.ts +4 -0
  11. package/dist/src/graph/extract.js +199 -0
  12. package/dist/src/graph/repository.d.ts +93 -0
  13. package/dist/src/graph/repository.js +361 -0
  14. package/dist/src/index.d.ts +5 -0
  15. package/dist/src/index.js +5 -0
  16. package/dist/src/mcp/server.d.ts +6 -0
  17. package/dist/src/mcp/server.js +81 -0
  18. package/dist/src/parser/treeSitter.d.ts +13 -0
  19. package/dist/src/parser/treeSitter.js +61 -0
  20. package/dist/src/scanner/discover.d.ts +4 -0
  21. package/dist/src/scanner/discover.js +62 -0
  22. package/dist/src/scanner/fingerprint.d.ts +3 -0
  23. package/dist/src/scanner/fingerprint.js +27 -0
  24. package/dist/src/storage/database.d.ts +72 -0
  25. package/dist/src/storage/database.js +563 -0
  26. package/dist/src/storage/schema.d.ts +4 -0
  27. package/dist/src/storage/schema.js +93 -0
  28. package/dist/src/types.d.ts +233 -0
  29. package/dist/src/types.js +2 -0
  30. package/dist/src/view/index.d.ts +27 -0
  31. package/dist/src/view/index.js +42 -0
  32. package/dist/src/view/layout.d.ts +28 -0
  33. package/dist/src/view/layout.js +235 -0
  34. package/dist/src/view/model.d.ts +64 -0
  35. package/dist/src/view/model.js +396 -0
  36. package/dist/src/view/open.d.ts +15 -0
  37. package/dist/src/view/open.js +37 -0
  38. package/dist/src/view/render.d.ts +9 -0
  39. package/dist/src/view/render.js +321 -0
  40. package/dist/src/watch.d.ts +40 -0
  41. package/dist/src/watch.js +118 -0
  42. package/package.json +65 -0
@@ -0,0 +1,93 @@
1
+ export const SCHEMA_VERSION = 2;
2
+ export const CREATE_SCHEMA_SQL = `
3
+ CREATE TABLE IF NOT EXISTS schema_migrations (
4
+ version INTEGER PRIMARY KEY,
5
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
6
+ );
7
+
8
+ CREATE TABLE IF NOT EXISTS repositories (
9
+ id TEXT PRIMARY KEY,
10
+ root TEXT NOT NULL,
11
+ name TEXT NOT NULL,
12
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
13
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
14
+ );
15
+
16
+ CREATE TABLE IF NOT EXISTS files (
17
+ id TEXT PRIMARY KEY,
18
+ repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
19
+ path TEXT NOT NULL,
20
+ language TEXT NOT NULL,
21
+ hash TEXT NOT NULL,
22
+ size INTEGER NOT NULL,
23
+ mtime_ms INTEGER NOT NULL,
24
+ scanned_at TEXT NOT NULL DEFAULT (datetime('now')),
25
+ UNIQUE(repository_id, path)
26
+ );
27
+
28
+ CREATE TABLE IF NOT EXISTS nodes (
29
+ id TEXT PRIMARY KEY,
30
+ repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
31
+ file_id TEXT REFERENCES files(id) ON DELETE CASCADE,
32
+ kind TEXT NOT NULL,
33
+ type TEXT NOT NULL,
34
+ name TEXT,
35
+ start_byte INTEGER NOT NULL,
36
+ end_byte INTEGER NOT NULL,
37
+ start_point TEXT NOT NULL,
38
+ end_point TEXT NOT NULL,
39
+ source_hash TEXT NOT NULL,
40
+ parser TEXT NOT NULL,
41
+ parser_version TEXT NOT NULL,
42
+ metadata_json TEXT NOT NULL DEFAULT '{}',
43
+ last_modified_user_id TEXT
44
+ );
45
+
46
+ CREATE TABLE IF NOT EXISTS edges (
47
+ id TEXT PRIMARY KEY,
48
+ repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
49
+ file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
50
+ source_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
51
+ target_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
52
+ kind TEXT NOT NULL,
53
+ confidence REAL NOT NULL DEFAULT 1.0,
54
+ metadata_json TEXT NOT NULL DEFAULT '{}'
55
+ );
56
+
57
+ CREATE TABLE IF NOT EXISTS overlays (
58
+ id TEXT PRIMARY KEY,
59
+ repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
60
+ subject_id TEXT NOT NULL,
61
+ subject_kind TEXT NOT NULL,
62
+ operation TEXT NOT NULL,
63
+ metadata_json TEXT NOT NULL DEFAULT '{}',
64
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
65
+ );
66
+
67
+ CREATE INDEX IF NOT EXISTS idx_files_repo_path ON files(repository_id, path);
68
+ CREATE INDEX IF NOT EXISTS idx_nodes_repo_kind ON nodes(repository_id, kind);
69
+ CREATE INDEX IF NOT EXISTS idx_nodes_repo_name ON nodes(repository_id, name);
70
+ CREATE INDEX IF NOT EXISTS idx_edges_repo_kind ON edges(repository_id, kind);
71
+ CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
72
+ CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);
73
+ `;
74
+ export const CREATE_NODES_TABLE_SQL = `
75
+ CREATE TABLE nodes_new (
76
+ id TEXT PRIMARY KEY,
77
+ repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
78
+ file_id TEXT REFERENCES files(id) ON DELETE CASCADE,
79
+ kind TEXT NOT NULL,
80
+ type TEXT NOT NULL,
81
+ name TEXT,
82
+ start_byte INTEGER NOT NULL,
83
+ end_byte INTEGER NOT NULL,
84
+ start_point TEXT NOT NULL,
85
+ end_point TEXT NOT NULL,
86
+ source_hash TEXT NOT NULL,
87
+ parser TEXT NOT NULL,
88
+ parser_version TEXT NOT NULL,
89
+ metadata_json TEXT NOT NULL DEFAULT '{}',
90
+ last_modified_user_id TEXT
91
+ );
92
+ `;
93
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1,233 @@
1
+ import type Parser from 'tree-sitter';
2
+ export type GraphScope = 'repo' | 'home' | 'both';
3
+ export type StorageScope = Exclude<GraphScope, 'both'>;
4
+ export type GraphNodeKind = 'file' | 'symbol' | 'import' | 'export' | 'call' | 'user';
5
+ export type GraphEdgeKind = 'contains' | 'defines' | 'imports' | 'exports' | 'calls' | 'attributed_to';
6
+ export interface Point {
7
+ row: number;
8
+ column: number;
9
+ }
10
+ export interface RepositoryRecord {
11
+ id: string;
12
+ root: string;
13
+ name: string;
14
+ }
15
+ export interface FileRecord {
16
+ id: string;
17
+ path: string;
18
+ language: string;
19
+ hash: string;
20
+ size: number;
21
+ mtimeMs: number;
22
+ }
23
+ export interface GraphNode {
24
+ id: string;
25
+ kind: GraphNodeKind;
26
+ type: string;
27
+ name: string | null;
28
+ startByte: number;
29
+ endByte: number;
30
+ startPoint: Point;
31
+ endPoint: Point;
32
+ sourceHash: string;
33
+ parser: string;
34
+ parserVersion: string;
35
+ metadata: Record<string, unknown>;
36
+ lastModifiedUserId: string | null;
37
+ }
38
+ export interface GraphEdge {
39
+ id: string;
40
+ sourceId: string;
41
+ targetId: string;
42
+ kind: GraphEdgeKind;
43
+ confidence: number;
44
+ metadata: Record<string, unknown>;
45
+ }
46
+ export interface ParseResult {
47
+ tree: Parser.Tree;
48
+ language: string;
49
+ parser: string;
50
+ parserVersion: string;
51
+ }
52
+ export interface ExtractGraphInput {
53
+ repositoryId: string;
54
+ fileId: string;
55
+ relativePath: string;
56
+ source: string;
57
+ sourceHash: string;
58
+ parseResult: ParseResult;
59
+ }
60
+ export interface GraphExtractionResult {
61
+ nodes: GraphNode[];
62
+ edges: GraphEdge[];
63
+ }
64
+ export interface ContributorIdentity {
65
+ email: string;
66
+ }
67
+ export interface NodeAttribution {
68
+ nodeId: string;
69
+ contributorEmails: string[];
70
+ lastModifiedEmail: string | null;
71
+ }
72
+ export interface FileAttribution {
73
+ contributorEmails: ContributorIdentity[];
74
+ nodeAttributions: NodeAttribution[];
75
+ }
76
+ export interface ScanSummary {
77
+ repository: RepositoryRecord;
78
+ scopes: Array<{
79
+ scope: StorageScope;
80
+ databasePath: string;
81
+ }>;
82
+ filesDiscovered: number;
83
+ filesScanned: number;
84
+ nodesWritten: number;
85
+ edgesWritten: number;
86
+ skipped: string[];
87
+ }
88
+ export interface RepositoryInfo {
89
+ id: string;
90
+ root: string;
91
+ name: string;
92
+ }
93
+ export interface GraphNodeRow {
94
+ id: string;
95
+ kind: GraphNodeKind;
96
+ type: string;
97
+ name: string | null;
98
+ fileId: string | null;
99
+ startByte: number;
100
+ endByte: number;
101
+ metadataJson: string;
102
+ lastModifiedUserId: string | null;
103
+ }
104
+ export interface GraphNodeTextRow extends GraphNodeRow {
105
+ path: string | null;
106
+ startPoint: Point;
107
+ endPoint: Point;
108
+ }
109
+ export interface GraphEdgeRow {
110
+ id: string;
111
+ kind: GraphEdgeKind;
112
+ sourceId: string;
113
+ targetId: string;
114
+ fileId: string;
115
+ confidence: number;
116
+ metadataJson: string;
117
+ }
118
+ export interface NeighborRow {
119
+ id: string;
120
+ kind: GraphNodeKind;
121
+ type: string;
122
+ name: string | null;
123
+ viaEdgeId: string | null;
124
+ depth: number;
125
+ }
126
+ export interface CallGraphRelation {
127
+ id: string;
128
+ kind: GraphNodeKind;
129
+ type: string;
130
+ name: string | null;
131
+ viaEdgeId: string;
132
+ edgeKind: GraphEdgeKind;
133
+ depth: number;
134
+ }
135
+ export interface CallGraphMatch extends GraphNodeRow {
136
+ upstream: CallGraphRelation[];
137
+ downstream: CallGraphRelation[];
138
+ }
139
+ export interface StatusResult {
140
+ repository: RepositoryRecord | null;
141
+ files: number;
142
+ nodes: number;
143
+ edges: number;
144
+ }
145
+ /** Stable, transport-neutral contract returned by the context retrieval command. */
146
+ export type ContextResultState = 'ok' | 'ambiguous' | 'not_found' | 'refresh_failed' | 'stale';
147
+ export type ContextFreshnessState = 'unchanged' | 'refreshed' | 'stale' | 'refresh_failed';
148
+ export type ContextFreshnessVerification = 'metadata_only' | 'content_hash';
149
+ export type ContextRelationCategory = 'definition' | 'impact' | 'dependency';
150
+ export interface ContextBudget {
151
+ depth: number;
152
+ relationshipLimit: number;
153
+ candidateLimit: number;
154
+ excerptLines: number;
155
+ excerptBytes: number;
156
+ }
157
+ export interface ContextRequest {
158
+ symbol: string;
159
+ root: string;
160
+ scope: GraphScope;
161
+ kind?: string;
162
+ file?: string;
163
+ budget: ContextBudget;
164
+ excerpts: boolean;
165
+ }
166
+ export interface ContextSpan {
167
+ start: Point;
168
+ end: Point;
169
+ }
170
+ export interface ContextSourceReference {
171
+ id: string;
172
+ path: string | null;
173
+ kind: GraphNodeKind;
174
+ type: string;
175
+ name: string | null;
176
+ span: ContextSpan;
177
+ sourceHash: string | null;
178
+ parser: string | null;
179
+ parserVersion: string | null;
180
+ metadata: Record<string, unknown>;
181
+ excerpt?: string;
182
+ }
183
+ export interface ContextRelationship {
184
+ category: ContextRelationCategory;
185
+ edgeKind: GraphEdgeKind;
186
+ direction: 'caller' | 'callee' | 'import' | 'export' | 'contains';
187
+ source: ContextSourceReference;
188
+ target: ContextSourceReference;
189
+ confidence: number;
190
+ provenance: Record<string, unknown>;
191
+ depth: number;
192
+ }
193
+ export interface ContextCandidate extends ContextSourceReference {
194
+ rank: number;
195
+ match: 'qualified' | 'exact' | 'prefix' | 'substring';
196
+ }
197
+ export interface ContextTruncation {
198
+ section: 'candidates' | 'impact' | 'dependencies' | 'definition' | 'work';
199
+ reason: 'limit' | 'depth' | 'candidate_limit' | 'work_budget';
200
+ omitted: number;
201
+ }
202
+ export interface ContextRefreshSummary {
203
+ outcome: 'reused' | 'refreshed' | 'refresh_failed';
204
+ filesDiscovered: number;
205
+ filesScanned: number;
206
+ skipped: string[];
207
+ }
208
+ export interface ContextScopeResult {
209
+ scope: StorageScope;
210
+ databasePath: string;
211
+ result: {
212
+ state: ContextResultState;
213
+ anchor?: ContextSourceReference;
214
+ candidates?: ContextCandidate[];
215
+ definition?: ContextSourceReference;
216
+ impact: ContextRelationship[];
217
+ dependencies: ContextRelationship[];
218
+ warnings: string[];
219
+ truncation: ContextTruncation[];
220
+ };
221
+ freshness: {
222
+ state: ContextFreshnessState;
223
+ verification: ContextFreshnessVerification;
224
+ refresh: ContextRefreshSummary;
225
+ };
226
+ }
227
+ export interface ContextGraphResult {
228
+ contract_version: 1;
229
+ repository: RepositoryRecord;
230
+ request: ContextRequest;
231
+ scopes: ContextScopeResult[];
232
+ }
233
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,27 @@
1
+ import type { StorageScope } from '../types.js';
2
+ import { type ViewModelOptions } from './model.js';
3
+ import { type BrowserOpenResult } from './open.js';
4
+ export interface ViewCommandOptions extends ViewModelOptions {
5
+ output?: string | undefined;
6
+ }
7
+ export interface ViewRunResult {
8
+ mode: 'interactive' | 'export';
9
+ scope: StorageScope;
10
+ artifactPath: string;
11
+ opened: boolean;
12
+ command?: string | undefined;
13
+ error?: string | undefined;
14
+ anchorNodeId: string | null;
15
+ search: string | null;
16
+ nodeCount: number;
17
+ edgeCount: number;
18
+ }
19
+ export interface ViewRunDependencies {
20
+ opener?: (targetPath: string) => BrowserOpenResult;
21
+ }
22
+ export declare function viewGraph(options?: ViewCommandOptions, dependencies?: ViewRunDependencies): Promise<ViewRunResult>;
23
+ export { buildViewModel, loadViewSnapshot } from './model.js';
24
+ export type { ViewSnapshot, ViewNode, ViewEdge, ViewBox, ViewModel } from './model.js';
25
+ export { renderInteractiveHtml, renderSvgMarkup, writeSvgSnapshot } from './render.js';
26
+ export { openHtmlArtifactInBrowser, openInDefaultBrowser, writeTemporaryHtmlArtifact } from './open.js';
27
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,42 @@
1
+ import path from 'node:path';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { buildViewModel, loadViewSnapshot } from './model.js';
4
+ import { openHtmlArtifactInBrowser } from './open.js';
5
+ import { renderInteractiveHtml, renderSvgMarkup, writeSvgSnapshot } from './render.js';
6
+ export async function viewGraph(options = {}, dependencies = {}) {
7
+ const snapshot = loadViewSnapshot(options);
8
+ const model = buildViewModel(snapshot, options);
9
+ if (options.output) {
10
+ const svg = renderSvgMarkup(model, { includeHiddenNodes: false, interactive: false });
11
+ mkdirSync(path.dirname(options.output), { recursive: true });
12
+ writeSvgSnapshot(options.output, svg);
13
+ return {
14
+ mode: 'export',
15
+ scope: model.scope,
16
+ artifactPath: options.output,
17
+ opened: false,
18
+ anchorNodeId: model.anchorNodeId,
19
+ search: model.search,
20
+ nodeCount: model.nodes.length,
21
+ edgeCount: model.edges.length
22
+ };
23
+ }
24
+ const html = renderInteractiveHtml(model);
25
+ const opened = openHtmlArtifactInBrowser(html, dependencies.opener);
26
+ return {
27
+ mode: 'interactive',
28
+ scope: model.scope,
29
+ artifactPath: opened.htmlPath,
30
+ opened: opened.opened,
31
+ command: opened.command,
32
+ error: opened.error,
33
+ anchorNodeId: model.anchorNodeId,
34
+ search: model.search,
35
+ nodeCount: model.nodes.length,
36
+ edgeCount: model.edges.length
37
+ };
38
+ }
39
+ export { buildViewModel, loadViewSnapshot } from './model.js';
40
+ export { renderInteractiveHtml, renderSvgMarkup, writeSvgSnapshot } from './render.js';
41
+ export { openHtmlArtifactInBrowser, openInDefaultBrowser, writeTemporaryHtmlArtifact } from './open.js';
42
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,28 @@
1
+ export interface GraphLike {
2
+ order: number;
3
+ forEachNode(callback: (nodeId: string, attributes: Record<string, unknown>) => void): void;
4
+ setNodeAttribute(nodeId: string, attribute: 'x' | 'y', value: number): void;
5
+ addNode(nodeId: string, attributes: Record<string, unknown>): void;
6
+ addEdgeWithKey(edgeId: string, sourceId: string, targetId: string, attributes: Record<string, unknown>): void;
7
+ hasNode(nodeId: string): boolean;
8
+ getNodeAttributes(nodeId: string): Record<string, unknown>;
9
+ }
10
+ export interface LayoutBounds {
11
+ minX: number;
12
+ maxX: number;
13
+ minY: number;
14
+ maxY: number;
15
+ width: number;
16
+ height: number;
17
+ centerX: number;
18
+ centerY: number;
19
+ }
20
+ export interface LayoutOptions {
21
+ size?: number | undefined;
22
+ padding?: number | undefined;
23
+ }
24
+ export declare function assignDeterministicLayout(graph: GraphLike, options?: LayoutOptions): LayoutBounds;
25
+ export declare function collectBounds(graph: GraphLike): LayoutBounds;
26
+ export declare function emptyBounds(): LayoutBounds;
27
+ export declare function finalizeBounds(minX: number, maxX: number, minY: number, maxY: number): LayoutBounds;
28
+ //# sourceMappingURL=layout.d.ts.map
@@ -0,0 +1,235 @@
1
+ const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
2
+ const NODE_GAP = 14;
3
+ const CLUSTER_GAP = 72;
4
+ const MAX_RELAX_ITERATIONS = 80;
5
+ export function assignDeterministicLayout(graph, options = {}) {
6
+ const size = options.size ?? 1000;
7
+ const padding = options.padding ?? 80;
8
+ if (graph.order === 0) {
9
+ return emptyBounds();
10
+ }
11
+ const nodes = [];
12
+ graph.forEachNode((nodeId, attributes) => {
13
+ nodes.push({
14
+ nodeId,
15
+ index: nodes.length,
16
+ kind: normalizeKind(attributes.kind),
17
+ degree: Number(attributes.degree ?? 0),
18
+ label: normalizeLabel(attributes.label, nodeId)
19
+ });
20
+ });
21
+ if (nodes.length === 1) {
22
+ const onlyNode = nodes[0];
23
+ graph.setNodeAttribute(onlyNode.nodeId, 'x', size / 2);
24
+ graph.setNodeAttribute(onlyNode.nodeId, 'y', size / 2);
25
+ return collectBounds(graph);
26
+ }
27
+ const clusters = buildClusters(nodes);
28
+ const allPositions = placeClusteredNodes(clusters, size, padding);
29
+ for (const position of allPositions) {
30
+ graph.setNodeAttribute(position.nodeId, 'x', position.x);
31
+ graph.setNodeAttribute(position.nodeId, 'y', position.y);
32
+ }
33
+ return collectBounds(graph);
34
+ }
35
+ export function collectBounds(graph) {
36
+ if (graph.order === 0)
37
+ return emptyBounds();
38
+ let minX = Number.POSITIVE_INFINITY;
39
+ let maxX = Number.NEGATIVE_INFINITY;
40
+ let minY = Number.POSITIVE_INFINITY;
41
+ let maxY = Number.NEGATIVE_INFINITY;
42
+ graph.forEachNode((_nodeId, attributes) => {
43
+ const x = Number(attributes.x ?? 0);
44
+ const y = Number(attributes.y ?? 0);
45
+ if (x < minX)
46
+ minX = x;
47
+ if (x > maxX)
48
+ maxX = x;
49
+ if (y < minY)
50
+ minY = y;
51
+ if (y > maxY)
52
+ maxY = y;
53
+ });
54
+ return finalizeBounds(minX, maxX, minY, maxY);
55
+ }
56
+ export function emptyBounds() {
57
+ return {
58
+ minX: 0,
59
+ maxX: 0,
60
+ minY: 0,
61
+ maxY: 0,
62
+ width: 0,
63
+ height: 0,
64
+ centerX: 0,
65
+ centerY: 0
66
+ };
67
+ }
68
+ export function finalizeBounds(minX, maxX, minY, maxY) {
69
+ if (!Number.isFinite(minX) || !Number.isFinite(maxX) || !Number.isFinite(minY) || !Number.isFinite(maxY)) {
70
+ return emptyBounds();
71
+ }
72
+ const width = maxX - minX;
73
+ const height = maxY - minY;
74
+ return {
75
+ minX,
76
+ maxX,
77
+ minY,
78
+ maxY,
79
+ width,
80
+ height,
81
+ centerX: minX + width / 2,
82
+ centerY: minY + height / 2
83
+ };
84
+ }
85
+ function buildClusters(nodes) {
86
+ const byKind = new Map();
87
+ for (const node of nodes) {
88
+ const bucket = byKind.get(node.kind) ?? [];
89
+ bucket.push(node);
90
+ byKind.set(node.kind, bucket);
91
+ }
92
+ const clusters = [...byKind.entries()]
93
+ .map(([kind, clusterNodes]) => ({
94
+ kind,
95
+ nodes: [...clusterNodes].sort(compareLayoutNode),
96
+ radius: clusterRadius(clusterNodes)
97
+ }))
98
+ .sort((left, right) => {
99
+ if (right.nodes.length !== left.nodes.length)
100
+ return right.nodes.length - left.nodes.length;
101
+ if (right.radius !== left.radius)
102
+ return right.radius - left.radius;
103
+ return left.kind.localeCompare(right.kind);
104
+ });
105
+ return clusters;
106
+ }
107
+ function placeClusteredNodes(clusters, size, padding) {
108
+ if (clusters.length === 0)
109
+ return [];
110
+ const center = size / 2;
111
+ const maxClusterRadius = Math.max(...clusters.map((cluster) => cluster.radius));
112
+ const ringRadius = clusters.length === 1
113
+ ? 0
114
+ : Math.max((2 * maxClusterRadius + CLUSTER_GAP) / (2 * Math.sin(Math.PI / clusters.length)), center - padding - maxClusterRadius);
115
+ const clusterRingRadius = Math.max(ringRadius, maxClusterRadius + 48);
116
+ const centerRadius = clusters.length === 1 ? 0 : clusterRingRadius;
117
+ const positionedClusters = clusters.map((cluster, index) => {
118
+ const angle = clusters.length === 1 ? 0 : -Math.PI / 2 + (index * (Math.PI * 2)) / clusters.length;
119
+ const centerX = center + Math.cos(angle) * centerRadius;
120
+ const centerY = center + Math.sin(angle) * centerRadius;
121
+ return { ...cluster, centerX, centerY };
122
+ });
123
+ const positions = [];
124
+ for (const cluster of positionedClusters) {
125
+ const localPositions = placeNodesWithinCluster(cluster);
126
+ positions.push(...localPositions);
127
+ }
128
+ relaxCollisions(positions);
129
+ return positions.map(({ nodeId, x, y }) => ({ nodeId, x, y }));
130
+ }
131
+ function placeNodesWithinCluster(cluster) {
132
+ const maxRadius = Math.max(...cluster.nodes.map((node) => nodeRadius(node.degree, maxNodeDegree(cluster.nodes))), 6);
133
+ const spiralSpacing = Math.max(maxRadius * 2 + 20, 44);
134
+ const ringSpacing = spiralSpacing * 0.9;
135
+ return cluster.nodes.map((node, index) => {
136
+ if (index === 0) {
137
+ return {
138
+ nodeId: node.nodeId,
139
+ x: cluster.centerX,
140
+ y: cluster.centerY,
141
+ radius: nodeRadius(node.degree, maxNodeDegree(cluster.nodes)),
142
+ centerX: cluster.centerX,
143
+ centerY: cluster.centerY
144
+ };
145
+ }
146
+ const radius = nodeRadius(node.degree, maxNodeDegree(cluster.nodes));
147
+ const orbit = Math.max(ringSpacing * Math.sqrt(index), radius + NODE_GAP);
148
+ const angle = index * GOLDEN_ANGLE + stableAngleOffset(cluster.kind);
149
+ return {
150
+ nodeId: node.nodeId,
151
+ x: cluster.centerX + Math.cos(angle) * orbit,
152
+ y: cluster.centerY + Math.sin(angle) * orbit,
153
+ radius,
154
+ centerX: cluster.centerX,
155
+ centerY: cluster.centerY
156
+ };
157
+ });
158
+ }
159
+ function relaxCollisions(nodes) {
160
+ if (nodes.length <= 1)
161
+ return;
162
+ for (let iteration = 0; iteration < MAX_RELAX_ITERATIONS; iteration += 1) {
163
+ let moved = false;
164
+ for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
165
+ for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) {
166
+ const left = nodes[leftIndex];
167
+ const right = nodes[rightIndex];
168
+ const dx = right.x - left.x;
169
+ const dy = right.y - left.y;
170
+ const distance = Math.hypot(dx, dy);
171
+ const required = left.radius + right.radius + NODE_GAP;
172
+ if (distance >= required)
173
+ continue;
174
+ const overlap = required - distance;
175
+ const [nx, ny] = normalizedVector(dx, dy, left, right);
176
+ const shift = overlap / 2;
177
+ left.x -= nx * shift;
178
+ left.y -= ny * shift;
179
+ right.x += nx * shift;
180
+ right.y += ny * shift;
181
+ moved = true;
182
+ }
183
+ }
184
+ if (!moved)
185
+ return;
186
+ }
187
+ }
188
+ function normalizedVector(dx, dy, left, right) {
189
+ if (dx !== 0 || dy !== 0) {
190
+ const length = Math.hypot(dx, dy) || 1;
191
+ return [dx / length, dy / length];
192
+ }
193
+ const fallbackDx = right.centerX - left.centerX;
194
+ const fallbackDy = right.centerY - left.centerY;
195
+ if (fallbackDx !== 0 || fallbackDy !== 0) {
196
+ const length = Math.hypot(fallbackDx, fallbackDy) || 1;
197
+ return [fallbackDx / length, fallbackDy / length];
198
+ }
199
+ return [1, 0];
200
+ }
201
+ function clusterRadius(nodes) {
202
+ const maxRadius = Math.max(...nodes.map((node) => nodeRadius(node.degree, maxNodeDegree(nodes))), 6);
203
+ const spread = Math.sqrt(nodes.length) * (maxRadius * 2 + 18);
204
+ return maxRadius + 36 + spread / Math.PI;
205
+ }
206
+ function compareLayoutNode(left, right) {
207
+ if (right.degree !== left.degree)
208
+ return right.degree - left.degree;
209
+ const labelComparison = left.label.localeCompare(right.label);
210
+ if (labelComparison !== 0)
211
+ return labelComparison;
212
+ return left.nodeId.localeCompare(right.nodeId);
213
+ }
214
+ function normalizeKind(value) {
215
+ return typeof value === 'string' && value.trim() ? value.trim() : 'unknown';
216
+ }
217
+ function normalizeLabel(value, fallback) {
218
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
219
+ }
220
+ function stableAngleOffset(kind) {
221
+ let hash = 0;
222
+ for (let index = 0; index < kind.length; index += 1) {
223
+ hash = (hash * 31 + kind.charCodeAt(index)) >>> 0;
224
+ }
225
+ return (hash % 997) / 997 * Math.PI * 2;
226
+ }
227
+ function nodeRadius(degree, maxDegree) {
228
+ if (maxDegree <= 0)
229
+ return 4;
230
+ return Math.max(4, Math.max(0, degree) * 4);
231
+ }
232
+ function maxNodeDegree(nodes) {
233
+ return nodes.reduce((max, node) => Math.max(max, node.degree), 0);
234
+ }
235
+ //# sourceMappingURL=layout.js.map