@ltm-blueverse/alpha-semantic-hub 0.7.0

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 (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1674 -0
  3. package/explorer/README.md +282 -0
  4. package/explorer/src/App.css +0 -0
  5. package/explorer/src/App.tsx +2035 -0
  6. package/explorer/src/ErrorBoundary.tsx +112 -0
  7. package/explorer/src/ExploreWorkspaceTabs.tsx +29 -0
  8. package/explorer/src/assets/hero.png +0 -0
  9. package/explorer/src/assets/react.svg +1 -0
  10. package/explorer/src/assets/vite.svg +1 -0
  11. package/explorer/src/explorerCapabilities.ts +24 -0
  12. package/explorer/src/index.css +71 -0
  13. package/explorer/src/main.tsx +7 -0
  14. package/explorer/src/store/edgePairKeys.d.ts +2 -0
  15. package/explorer/src/store/edgePairKeys.js +7 -0
  16. package/explorer/src/store/graphStore.ts +183 -0
  17. package/explorer/src/store/registryStore.ts +78 -0
  18. package/explorer/src/types.d.ts +16 -0
  19. package/explorer/src/ui/primitives.css +414 -0
  20. package/explorer/src/ui/primitives.tsx +170 -0
  21. package/explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx +279 -0
  22. package/explorer/src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx +121 -0
  23. package/explorer/src/workspaces/EnrichWorkspace/EntityResolutionTab.tsx +454 -0
  24. package/explorer/src/workspaces/EnrichWorkspace/RegistryTab.tsx +285 -0
  25. package/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx +2673 -0
  26. package/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx +771 -0
  27. package/explorer/src/workspaces/GraphWorkspace/GraphLoadingOverlay.tsx +410 -0
  28. package/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +3584 -0
  29. package/explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx +705 -0
  30. package/explorer/src/workspaces/GraphWorkspace/SigmaSceneAdapter.tsx +55 -0
  31. package/explorer/src/workspaces/GraphWorkspace/TimelinePanel.tsx +193 -0
  32. package/explorer/src/workspaces/GraphWorkspace/behaviors/clickSelectionBehavior.ts +29 -0
  33. package/explorer/src/workspaces/GraphWorkspace/behaviors/fitViewBehavior.ts +15 -0
  34. package/explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts +25 -0
  35. package/explorer/src/workspaces/GraphWorkspace/behaviors/hoverActivationBehavior.ts +15 -0
  36. package/explorer/src/workspaces/GraphWorkspace/behaviors/pathHighlightBehavior.ts +52 -0
  37. package/explorer/src/workspaces/GraphWorkspace/behaviors/searchFocusBehavior.ts +36 -0
  38. package/explorer/src/workspaces/GraphWorkspace/behaviors/types.ts +41 -0
  39. package/explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts +29 -0
  40. package/explorer/src/workspaces/GraphWorkspace/graphAnalytics.ts +779 -0
  41. package/explorer/src/workspaces/GraphWorkspace/graphColorLegend.ts +36 -0
  42. package/explorer/src/workspaces/GraphWorkspace/graphConfig.ts +102 -0
  43. package/explorer/src/workspaces/GraphWorkspace/graphEntityShape.ts +34 -0
  44. package/explorer/src/workspaces/GraphWorkspace/graphLoading.ts +84 -0
  45. package/explorer/src/workspaces/GraphWorkspace/graphSceneLayers.ts +775 -0
  46. package/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts +2753 -0
  47. package/explorer/src/workspaces/GraphWorkspace/graphStructureLayer.ts +335 -0
  48. package/explorer/src/workspaces/GraphWorkspace/graphTheme.ts +1078 -0
  49. package/explorer/src/workspaces/GraphWorkspace/graphViewCopy.ts +41 -0
  50. package/explorer/src/workspaces/GraphWorkspace/localGraphTransition.ts +9 -0
  51. package/explorer/src/workspaces/GraphWorkspace/markdownEditorState.ts +114 -0
  52. package/explorer/src/workspaces/GraphWorkspace/markdownResourceClient.ts +103 -0
  53. package/explorer/src/workspaces/GraphWorkspace/markdownUrlSafety.ts +29 -0
  54. package/explorer/src/workspaces/GraphWorkspace/nodeMarkdownSync.ts +81 -0
  55. package/explorer/src/workspaces/GraphWorkspace/pluginRegistryPredicates.ts +24 -0
  56. package/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPlugin.tsx +353 -0
  57. package/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPluginPhaseC.tsx +522 -0
  58. package/explorer/src/workspaces/GraphWorkspace/plugins/index.ts +16 -0
  59. package/explorer/src/workspaces/GraphWorkspace/plugins/legendPlugin.tsx +120 -0
  60. package/explorer/src/workspaces/GraphWorkspace/plugins/neighborhoodPanelPlugin.tsx +231 -0
  61. package/explorer/src/workspaces/GraphWorkspace/plugins/temporalOverlayPlugin.tsx +142 -0
  62. package/explorer/src/workspaces/GraphWorkspace/plugins/types.ts +106 -0
  63. package/explorer/src/workspaces/GraphWorkspace/realtimeGraphAttributes.ts +50 -0
  64. package/explorer/src/workspaces/GraphWorkspace/scene.ts +79 -0
  65. package/explorer/src/workspaces/GraphWorkspace/sigmaNativeRendering.ts +421 -0
  66. package/explorer/src/workspaces/GraphWorkspace/smallGraphLayout.ts +135 -0
  67. package/explorer/src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts +31 -0
  68. package/explorer/src/workspaces/GraphWorkspace/temporalScrubberBounds.ts +46 -0
  69. package/explorer/src/workspaces/GraphWorkspace/temporalSnapshotGuards.ts +113 -0
  70. package/explorer/src/workspaces/GraphWorkspace/types.ts +395 -0
  71. package/explorer/src/workspaces/GraphWorkspace/useLoadGraph.ts +746 -0
  72. package/explorer/src/workspaces/GraphWorkspace/useMarkdownEditor.ts +177 -0
  73. package/explorer/src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx +196 -0
  74. package/explorer/src/workspaces/LineageWorkspace/LineageDiagram.tsx +177 -0
  75. package/explorer/src/workspaces/ManageWorkspace/KGOverviewTab.tsx +291 -0
  76. package/explorer/src/workspaces/ManageWorkspace/OntologySummaryTab.tsx +346 -0
  77. package/explorer/src/workspaces/MemoryWorkspace.tsx +464 -0
  78. package/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx +437 -0
  79. package/explorer/src/workspaces/OntologyWorkspace/HealthTab.tsx +214 -0
  80. package/explorer/src/workspaces/OntologyWorkspace/OntologyEditor.tsx +770 -0
  81. package/explorer/src/workspaces/OntologyWorkspace/OntologyLoader.tsx +913 -0
  82. package/explorer/src/workspaces/OntologyWorkspace/OntologyManager.tsx +930 -0
  83. package/explorer/src/workspaces/OntologyWorkspace/OntologySearch.tsx +590 -0
  84. package/explorer/src/workspaces/OntologyWorkspace/ProposalReview.tsx +429 -0
  85. package/explorer/src/workspaces/OntologyWorkspace/SKOSVocabularyManager.tsx +696 -0
  86. package/explorer/src/workspaces/OntologyWorkspace/ShaclStudio.tsx +363 -0
  87. package/explorer/src/workspaces/OntologyWorkspace/VersionsTab.tsx +546 -0
  88. package/explorer/src/workspaces/OntologyWorkspace/api.ts +164 -0
  89. package/explorer/src/workspaces/OntologyWorkspace/index.tsx +101 -0
  90. package/explorer/src/workspaces/OntologyWorkspace/ontologyEditorModel.ts +87 -0
  91. package/explorer/src/workspaces/OntologyWorkspace/ontologyUrlState.ts +94 -0
  92. package/explorer/src/workspaces/OntologyWorkspace/types.ts +119 -0
  93. package/explorer/src/workspaces/ReasoningWorkspace.tsx +243 -0
  94. package/explorer/src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx +271 -0
  95. package/explorer/src/workspaces/VocabularyWorkspace/ConceptTree.tsx +97 -0
  96. package/explorer/src/workspaces/VocabularyWorkspace/ImportDropzone.tsx +95 -0
  97. package/explorer/src/workspaces/VocabularyWorkspace/PropertyPanel.tsx +116 -0
  98. package/explorer/src/workspaces/VocabularyWorkspace/Sidebar.tsx +112 -0
  99. package/explorer/src/workspaces/VocabularyWorkspace/VocabularyWorkspace.tsx +70 -0
  100. package/explorer/src/workspaces/VocabularyWorkspace/queries.ts +52 -0
  101. package/explorer/src/workspaces/VocabularyWorkspace/types.ts +24 -0
  102. package/package.json +31 -0
  103. package/plugins/.claude-plugin/README.md +262 -0
  104. package/plugins/.claude-plugin/marketplace.json +20 -0
  105. package/plugins/.claude-plugin/plugin.json +33 -0
  106. package/plugins/.cline-plugin/README.md +32 -0
  107. package/plugins/.cline-plugin/marketplace.json +17 -0
  108. package/plugins/.cline-plugin/plugin.json +35 -0
  109. package/plugins/.codex-plugin/marketplace.json +21 -0
  110. package/plugins/.codex-plugin/plugin.json +35 -0
  111. package/plugins/.continue-plugin/README.md +34 -0
  112. package/plugins/.continue-plugin/marketplace.json +17 -0
  113. package/plugins/.continue-plugin/plugin.json +33 -0
  114. package/plugins/.cursor-plugin/marketplace.json +18 -0
  115. package/plugins/.cursor-plugin/plugin.json +32 -0
  116. package/plugins/.openclaw-plugin/README.md +62 -0
  117. package/plugins/.openclaw-plugin/marketplace.json +18 -0
  118. package/plugins/.openclaw-plugin/plugin.json +46 -0
  119. package/plugins/.pi-plugin/README.md +35 -0
  120. package/plugins/.vscode-plugin/README.md +48 -0
  121. package/plugins/.vscode-plugin/marketplace.json +17 -0
  122. package/plugins/.vscode-plugin/plugin.json +35 -0
  123. package/plugins/.windsurf-plugin/README.md +39 -0
  124. package/plugins/.windsurf-plugin/marketplace.json +17 -0
  125. package/plugins/.windsurf-plugin/plugin.json +35 -0
  126. package/plugins/agents/decision-advisor.md +126 -0
  127. package/plugins/agents/explainability.md +129 -0
  128. package/plugins/agents/kg-assistant.md +73 -0
  129. package/plugins/hooks/hooks.json +12 -0
  130. package/plugins/skills/causal/SKILL.md +74 -0
  131. package/plugins/skills/change/SKILL.md +80 -0
  132. package/plugins/skills/decision/SKILL.md +197 -0
  133. package/plugins/skills/deduplicate/SKILL.md +41 -0
  134. package/plugins/skills/embed/SKILL.md +230 -0
  135. package/plugins/skills/explain/SKILL.md +44 -0
  136. package/plugins/skills/export/SKILL.md +67 -0
  137. package/plugins/skills/extract/SKILL.md +93 -0
  138. package/plugins/skills/ingest/SKILL.md +38 -0
  139. package/plugins/skills/ontology/SKILL.md +89 -0
  140. package/plugins/skills/policy/SKILL.md +66 -0
  141. package/plugins/skills/provenance/SKILL.md +70 -0
  142. package/plugins/skills/query/SKILL.md +84 -0
  143. package/plugins/skills/reason/SKILL.md +201 -0
  144. package/plugins/skills/temporal/SKILL.md +164 -0
  145. package/plugins/skills/validate/SKILL.md +228 -0
  146. package/plugins/skills/visualize/SKILL.md +249 -0
@@ -0,0 +1,2753 @@
1
+ import Graph from "graphology";
2
+
3
+ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
4
+ import {
5
+ clamp,
6
+ blendHex,
7
+ GRAPH_THEME,
8
+ type GraphArrowVisibilityPolicy,
9
+ type GraphBadgeKind,
10
+ type GraphEdgeVariant,
11
+ type GraphEdgeVisualState,
12
+ type GraphEntityShapeVariant,
13
+ type GraphLabelVisibilityPolicy,
14
+ type GraphNodeShapeVariant,
15
+ type GraphNodeVisualState,
16
+ type GraphTheme,
17
+ type GraphZoomTier,
18
+ hashString,
19
+ withAlpha,
20
+ zoomTierAtLeast,
21
+ } from "./graphTheme";
22
+ import { getSemanticNodeColor } from "./graphColorLegend";
23
+ import { classifyEntityShape } from "./graphEntityShape";
24
+ import { computeGraphAnalyticsBase } from "./graphAnalytics";
25
+ import type {
26
+ FocusedUnavailableReason,
27
+ GraphDistanceBucketCounts,
28
+ GraphDisplayMeta,
29
+ GraphDisplayStateSnapshot,
30
+ GraphDistanceVisualState,
31
+ GraphHeatmapRenderSnapshot,
32
+ GraphFullEdgeClass,
33
+ GraphInteractionState,
34
+ GraphSelectedNodeKind,
35
+ GraphViewMode,
36
+ GroupedViewUnavailableReason,
37
+ } from "./types";
38
+
39
+ const MAX_FOCUS_NEIGHBORS = GRAPH_THEME.focus.maxNeighbors;
40
+ const FOCUS_RING_CAPACITY = GRAPH_THEME.focus.ringCapacity;
41
+ const FOCUS_RING_GAP = GRAPH_THEME.focus.ringGap;
42
+ const FOCUS_PRIMARY_LABELS = GRAPH_THEME.focus.primaryLabels;
43
+ const COLLAPSE_VISIBLE_NEIGHBORS = 8;
44
+ const GROUP_SAMPLE_MEMBERS = 8;
45
+ const AGGREGATED_EDGE_PREFIX = "__agg__:";
46
+ const COMMUNITY_NODE_PREFIX = "__community__:";
47
+ const DEBUG_GRAPH_SCENE_STATE = typeof import.meta !== "undefined" && import.meta.env?.DEV === true;
48
+ const HEATMAP_ONE_HOP_CAP = 120;
49
+ const HEATMAP_TWO_HOP_CAP = 650;
50
+ const HEATMAP_THREE_HOP_CAP = 900;
51
+
52
+ const _heatmapVisibleSetCache = new WeakMap<GraphDistanceVisualState, Set<string>>();
53
+ function getHeatmapVisibleSet(state: GraphDistanceVisualState): Set<string> {
54
+ let cached = _heatmapVisibleSetCache.get(state);
55
+ if (!cached) {
56
+ cached = new Set(state.heatmapVisibleNodeIds ?? []);
57
+ _heatmapVisibleSetCache.set(state, cached);
58
+ }
59
+ return cached;
60
+ }
61
+
62
+ type GraphRef = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
63
+
64
+ export type GraphDisplayResult = {
65
+ graph: GraphRef;
66
+ state: GraphDisplayStateSnapshot;
67
+ meta: GraphDisplayMeta;
68
+ };
69
+
70
+ const BASE_DISPLAY_META: GraphDisplayMeta = {
71
+ layoutMode: "base",
72
+ positionSource: "store",
73
+ tracksStoreNodePositions: true,
74
+ hasSyntheticNodes: false,
75
+ };
76
+
77
+ const MIRRORED_DISPLAY_META: GraphDisplayMeta = {
78
+ layoutMode: "mirrored",
79
+ positionSource: "store",
80
+ tracksStoreNodePositions: true,
81
+ hasSyntheticNodes: false,
82
+ };
83
+
84
+ const OWNED_DISPLAY_META: GraphDisplayMeta = {
85
+ layoutMode: "owned",
86
+ positionSource: "display",
87
+ tracksStoreNodePositions: false,
88
+ hasSyntheticNodes: true,
89
+ };
90
+
91
+ function getOverviewPresenceBoost(cameraRatio: number) {
92
+ return clamp(0, Math.log2(Math.max(cameraRatio, 1)) / 1.85, 1);
93
+ }
94
+
95
+ export type GraphSigmaEdgeType = "line" | "arrow" | "curve" | "curvedArrow";
96
+
97
+ export type ResolvedNodeStyle = {
98
+ color: string;
99
+ shellColor: string;
100
+ coreScale: number;
101
+ size: number;
102
+ forceLabel: boolean;
103
+ label: string;
104
+ zIndex: number;
105
+ hidden: boolean;
106
+ borderColor: string;
107
+ borderSize: number;
108
+ nodeVariant: GraphNodeShapeVariant;
109
+ entityShape: GraphEntityShapeVariant;
110
+ entityShapeKind: number;
111
+ entityAspectRatio: number;
112
+ badgeKind?: GraphBadgeKind;
113
+ badgeCount?: number;
114
+ showBadge: boolean;
115
+ showRing: boolean;
116
+ ringColor?: string;
117
+ ringSize: number;
118
+ showHalo: boolean;
119
+ haloColor: string;
120
+ };
121
+
122
+ export type ResolvedEdgeStyle = {
123
+ hidden: boolean;
124
+ type?: GraphSigmaEdgeType;
125
+ color?: string;
126
+ size?: number;
127
+ zIndex: number;
128
+ edgeVariant: GraphEdgeVariant;
129
+ arrowVisibilityPolicy: GraphArrowVisibilityPolicy;
130
+ curveStrength: number;
131
+ curvature: number;
132
+ };
133
+
134
+ export function getDistanceBandColor(distance: number): string {
135
+ if (distance <= 0) return "#7CFF9B";
136
+ if (distance <= 1) return "#43d17a";
137
+ if (distance <= 2) return "#ff2d4b";
138
+ if (distance <= 3) return "#B0883A";
139
+ if (distance <= 6) return "#B0883A";
140
+ return "#FF7B72";
141
+ }
142
+
143
+ function getSemanticScoreColor(score: number): string {
144
+ if (score >= 0.7) return "#43d17a";
145
+ if (score >= 0.4) return "#E3B341";
146
+ return "#FF7B72";
147
+ }
148
+
149
+ export function buildStructuralDistanceSnapshot(
150
+ graphRef: GraphRef,
151
+ anchorNodeId: string,
152
+ maxHops: number,
153
+ ): Record<string, number> {
154
+ const distances: Record<string, number> = {};
155
+ if (!anchorNodeId || !graphRef.hasNode(anchorNodeId)) {
156
+ return distances;
157
+ }
158
+
159
+ const queue: Array<[string, number]> = [[anchorNodeId, 0]];
160
+ const visited = new Set<string>([anchorNodeId]);
161
+ distances[anchorNodeId] = 0;
162
+
163
+ while (queue.length > 0) {
164
+ const [nodeId, hop] = queue.shift()!;
165
+ if (hop >= maxHops) {
166
+ continue;
167
+ }
168
+ graphRef.neighbors(nodeId).forEach((neighborId) => {
169
+ const stableNeighborId = String(neighborId);
170
+ if (visited.has(stableNeighborId)) {
171
+ return;
172
+ }
173
+ visited.add(stableNeighborId);
174
+ distances[stableNeighborId] = hop + 1;
175
+ queue.push([stableNeighborId, hop + 1]);
176
+ });
177
+ }
178
+
179
+ return distances;
180
+ }
181
+
182
+ export function summarizeDistanceBuckets(
183
+ distances: Record<string, number>,
184
+ totalNodeCount: number,
185
+ ): GraphDistanceBucketCounts {
186
+ const counts: GraphDistanceBucketCounts = {
187
+ anchor: 0,
188
+ oneHop: 0,
189
+ twoHop: 0,
190
+ threeHopPlus: 0,
191
+ outside: 0,
192
+ };
193
+
194
+ Object.values(distances).forEach((distance) => {
195
+ if (distance <= 0) {
196
+ counts.anchor += 1;
197
+ } else if (distance === 1) {
198
+ counts.oneHop += 1;
199
+ } else if (distance === 2) {
200
+ counts.twoHop += 1;
201
+ } else {
202
+ counts.threeHopPlus += 1;
203
+ }
204
+ });
205
+
206
+ const reachedCount = counts.anchor + counts.oneHop + counts.twoHop + counts.threeHopPlus;
207
+ counts.outside = Math.max(0, totalNodeCount - reachedCount);
208
+ return counts;
209
+ }
210
+
211
+ function createEmptyDistanceCounts(): GraphDistanceBucketCounts {
212
+ return {
213
+ anchor: 0,
214
+ oneHop: 0,
215
+ twoHop: 0,
216
+ threeHopPlus: 0,
217
+ outside: 0,
218
+ };
219
+ }
220
+
221
+ function getHeatmapRingCap(distance: number): number {
222
+ if (distance <= 0) return Number.POSITIVE_INFINITY;
223
+ if (distance === 1) return HEATMAP_ONE_HOP_CAP;
224
+ if (distance === 2) return HEATMAP_TWO_HOP_CAP;
225
+ return HEATMAP_THREE_HOP_CAP;
226
+ }
227
+
228
+ function getNodeHeatmapPriority(graphRef: GraphRef, nodeId: string): number {
229
+ if (!graphRef.hasNode(nodeId)) {
230
+ return 0;
231
+ }
232
+ const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
233
+ return Number(attrs.visualPriority ?? 0) * 8 + Number(attrs.labelPriority ?? 0);
234
+ }
235
+
236
+ function buildPreviousRingEdgeScores(
237
+ graphRef: GraphRef,
238
+ distances: Record<string, number>,
239
+ ): Map<string, number> {
240
+ const scores = new Map<string, number>();
241
+ graphRef.forEachEdge((edgeId, attrs, sourceId, targetId) => {
242
+ const source = String(sourceId);
243
+ const target = String(targetId);
244
+ const sourceDistance = distances[source];
245
+ const targetDistance = distances[target];
246
+ if (sourceDistance == null || targetDistance == null || Math.abs(sourceDistance - targetDistance) !== 1) {
247
+ return;
248
+ }
249
+
250
+ const fartherNodeId = sourceDistance > targetDistance ? source : target;
251
+ const edgeAttrs = attrs as EdgeAttributes;
252
+ const score = Number(edgeAttrs.visualPriority ?? 0) * 8
253
+ + Number(edgeAttrs.weight ?? 0)
254
+ + Number(edgeAttrs.relationshipStrength ?? 0) * 2
255
+ + Number(edgeAttrs.representativeWeight ?? 0);
256
+ scores.set(fartherNodeId, Math.max(scores.get(fartherNodeId) ?? 0, score));
257
+ void edgeId;
258
+ });
259
+ return scores;
260
+ }
261
+
262
+ export function buildHeatmapRenderSnapshot(
263
+ graphRef: GraphRef,
264
+ anchorNodeId: string,
265
+ distances: Record<string, number>,
266
+ maxHops: number,
267
+ ): GraphHeatmapRenderSnapshot {
268
+ const ringCounts = summarizeDistanceBuckets(distances, graphRef.order);
269
+ const renderedRingCounts = createEmptyDistanceCounts();
270
+ renderedRingCounts.outside = ringCounts.outside;
271
+ const visibleNodeIds = new Set<string>();
272
+
273
+ if (!anchorNodeId || !graphRef.hasNode(anchorNodeId)) {
274
+ return {
275
+ visibleNodeIds: [],
276
+ ringCounts,
277
+ renderedRingCounts,
278
+ saturationMode: "normal",
279
+ };
280
+ }
281
+
282
+ const previousRingEdgeScores = buildPreviousRingEdgeScores(graphRef, distances);
283
+ const rings = new Map<number, string[]>();
284
+ Object.entries(distances).forEach(([nodeId, distance]) => {
285
+ if (distance < 0 || distance > maxHops || distance > 3) {
286
+ return;
287
+ }
288
+ const ring = Math.max(0, Math.floor(distance));
289
+ rings.set(ring, [...(rings.get(ring) ?? []), nodeId]);
290
+ });
291
+
292
+ if (!rings.get(0)?.includes(anchorNodeId)) {
293
+ rings.set(0, [anchorNodeId, ...(rings.get(0) ?? [])]);
294
+ }
295
+
296
+ [0, 1, 2, 3].forEach((ring) => {
297
+ const cap = getHeatmapRingCap(ring);
298
+ const candidates = [...new Set(rings.get(ring) ?? [])]
299
+ .sort((left, right) => {
300
+ const edgeScoreDelta = (previousRingEdgeScores.get(right) ?? 0) - (previousRingEdgeScores.get(left) ?? 0);
301
+ if (edgeScoreDelta !== 0) return edgeScoreDelta;
302
+ const nodePriorityDelta = getNodeHeatmapPriority(graphRef, right) - getNodeHeatmapPriority(graphRef, left);
303
+ if (nodePriorityDelta !== 0) return nodePriorityDelta;
304
+ return hashString(left) - hashString(right);
305
+ })
306
+ .slice(0, cap);
307
+
308
+ candidates.forEach((nodeId) => visibleNodeIds.add(nodeId));
309
+ if (ring === 0) renderedRingCounts.anchor = candidates.length;
310
+ if (ring === 1) renderedRingCounts.oneHop = candidates.length;
311
+ if (ring === 2) renderedRingCounts.twoHop = candidates.length;
312
+ if (ring === 3) renderedRingCounts.threeHopPlus = candidates.length;
313
+ });
314
+
315
+ visibleNodeIds.add(anchorNodeId);
316
+ renderedRingCounts.anchor = Math.max(1, renderedRingCounts.anchor);
317
+
318
+ const isSampled = renderedRingCounts.oneHop < ringCounts.oneHop
319
+ || renderedRingCounts.twoHop < ringCounts.twoHop
320
+ || renderedRingCounts.threeHopPlus < ringCounts.threeHopPlus;
321
+
322
+ return {
323
+ visibleNodeIds: [...visibleNodeIds],
324
+ ringCounts,
325
+ renderedRingCounts,
326
+ saturationMode: isSampled ? "sampled" : "normal",
327
+ };
328
+ }
329
+
330
+ function isHeatmapSaturated(counts: GraphDistanceBucketCounts | undefined): boolean {
331
+ if (!counts) {
332
+ return false;
333
+ }
334
+
335
+ const total = counts.anchor + counts.oneHop + counts.twoHop + counts.threeHopPlus + counts.outside;
336
+ if (total <= 0) {
337
+ return false;
338
+ }
339
+
340
+ const broadRings = counts.twoHop + counts.threeHopPlus;
341
+ return counts.threeHopPlus > 1_500 || broadRings / total > 0.35;
342
+ }
343
+
344
+ function resolveHeatmapRingStyle(distance: number, isSaturated: boolean) {
345
+ if (distance <= 0) {
346
+ return {
347
+ alpha: 1,
348
+ shellAlpha: 0.92,
349
+ borderAlpha: 0.95,
350
+ sizeMultiplier: 1.28,
351
+ borderBoost: 1.35,
352
+ zIndex: 8,
353
+ };
354
+ }
355
+
356
+ if (distance === 1) {
357
+ return {
358
+ alpha: 0.9,
359
+ shellAlpha: 0.42,
360
+ borderAlpha: 0.78,
361
+ sizeMultiplier: 0.96,
362
+ borderBoost: 0.28,
363
+ zIndex: 5,
364
+ };
365
+ }
366
+
367
+ if (distance === 2) {
368
+ return {
369
+ alpha: isSaturated ? 0.46 : 0.62,
370
+ shellAlpha: isSaturated ? 0.1 : 0.2,
371
+ borderAlpha: isSaturated ? 0.36 : 0.52,
372
+ sizeMultiplier: isSaturated ? 0.54 : 0.7,
373
+ borderBoost: isSaturated ? -0.2 : 0.05,
374
+ zIndex: 2,
375
+ };
376
+ }
377
+
378
+ return {
379
+ alpha: isSaturated ? 0.16 : 0.34,
380
+ shellAlpha: isSaturated ? 0.03 : 0.1,
381
+ borderAlpha: isSaturated ? 0.16 : 0.32,
382
+ sizeMultiplier: isSaturated ? 0.3 : 0.48,
383
+ borderBoost: isSaturated ? -0.35 : -0.08,
384
+ zIndex: 1,
385
+ };
386
+ }
387
+
388
+ export function resolveDistanceNodeStyle(
389
+ theme: GraphTheme,
390
+ zoomTier: GraphZoomTier,
391
+ baseStyle: ResolvedNodeStyle,
392
+ distanceVisualState: GraphDistanceVisualState | undefined,
393
+ nodeId: string,
394
+ ): Partial<ResolvedNodeStyle> {
395
+ if (!distanceVisualState || distanceVisualState.mode === "off" || distanceVisualState.status !== "ready") {
396
+ return {};
397
+ }
398
+
399
+ const distance = distanceVisualState.structuralDistances[nodeId];
400
+ const isAnchor = distanceVisualState.anchorNodeId === nodeId;
401
+
402
+ if (distanceVisualState.mode === "ego") {
403
+ if (distance == null) {
404
+ return {
405
+ color: withAlpha(theme.palette.overview.nodeMuted, 0.34),
406
+ shellColor: withAlpha(theme.palette.overview.nodeBorder, 0.16),
407
+ size: Math.max(0.75, baseStyle.size * 0.28),
408
+ borderColor: withAlpha(theme.palette.overview.nodeBorder, 0.18),
409
+ borderSize: Math.max(0.25, baseStyle.borderSize * 0.45),
410
+ label: "",
411
+ forceLabel: false,
412
+ zIndex: Math.min(baseStyle.zIndex, 0),
413
+ };
414
+ }
415
+
416
+ const ratio = clamp(0, distance / Math.max(1, distanceVisualState.maxHops), 1);
417
+ const bandColor = getDistanceBandColor(distance);
418
+ return {
419
+ color: withAlpha(bandColor, isAnchor ? 0.98 : 0.88 - ratio * 0.44),
420
+ shellColor: withAlpha(bandColor, isAnchor ? 0.92 : 0.54 - ratio * 0.24),
421
+ size: Math.max(baseStyle.size * (isAnchor ? 1.18 : 1 - ratio * 0.22), baseStyle.size * 0.66),
422
+ borderColor: isAnchor ? theme.nodes.selectedRing.color : withAlpha(bandColor, 0.72 - ratio * 0.24),
423
+ borderSize: baseStyle.borderSize + (isAnchor ? 1.1 : 0.28),
424
+ forceLabel: isAnchor || baseStyle.forceLabel,
425
+ label: isAnchor ? (distanceVisualState.anchorLabel ?? baseStyle.label) || baseStyle.label : baseStyle.label,
426
+ zIndex: isAnchor ? Math.max(baseStyle.zIndex, 4) : Math.max(baseStyle.zIndex, 1),
427
+ };
428
+ }
429
+
430
+ if (distanceVisualState.mode === "heatmap") {
431
+ const visibleSet = getHeatmapVisibleSet(distanceVisualState);
432
+ const isRenderedHeatmapNode = isAnchor || visibleSet.size === 0 || visibleSet.has(nodeId);
433
+
434
+ if (distance == null || !isRenderedHeatmapNode) {
435
+ return {
436
+ color: withAlpha(theme.palette.overview.nodeMuted, zoomTier === "overview" ? 0.055 : 0.09),
437
+ shellColor: withAlpha(theme.palette.overview.nodeBorder, 0.015),
438
+ borderColor: withAlpha(theme.palette.overview.nodeBorder, 0.035),
439
+ borderSize: Math.max(0.12, baseStyle.borderSize * 0.18),
440
+ size: Math.max(0.45, baseStyle.size * 0.2),
441
+ label: "",
442
+ forceLabel: false,
443
+ zIndex: Math.min(baseStyle.zIndex, 0),
444
+ coreScale: 0,
445
+ };
446
+ }
447
+
448
+ const bandColor = getDistanceBandColor(distance);
449
+ const ringStyle = resolveHeatmapRingStyle(distance, isHeatmapSaturated(distanceVisualState.distanceCounts));
450
+ const label = isAnchor ? (distanceVisualState.anchorLabel ?? baseStyle.label) || baseStyle.label : "";
451
+ return {
452
+ color: withAlpha(bandColor, ringStyle.alpha),
453
+ shellColor: withAlpha(bandColor, ringStyle.shellAlpha),
454
+ borderColor: isAnchor ? theme.nodes.selectedRing.color : withAlpha(bandColor, ringStyle.borderAlpha),
455
+ borderSize: Math.max(0.18, baseStyle.borderSize + ringStyle.borderBoost),
456
+ size: Math.max(0.7, baseStyle.size * ringStyle.sizeMultiplier),
457
+ forceLabel: isAnchor,
458
+ label,
459
+ zIndex: Math.max(isAnchor ? baseStyle.zIndex : 0, ringStyle.zIndex),
460
+ };
461
+ }
462
+
463
+ return {};
464
+ }
465
+
466
+ export function resolveDistanceEdgeStyle(
467
+ baseStyle: ResolvedEdgeStyle,
468
+ distanceVisualState: GraphDistanceVisualState | undefined,
469
+ sourceId: string,
470
+ targetId: string,
471
+ fullEdgeClass?: GraphFullEdgeClass,
472
+ ): Partial<ResolvedEdgeStyle> {
473
+ if (!distanceVisualState || distanceVisualState.mode === "off" || distanceVisualState.status !== "ready") {
474
+ return {};
475
+ }
476
+
477
+ if (distanceVisualState.mode === "heatmap") {
478
+ if (fullEdgeClass === "path" || fullEdgeClass === "selected" || fullEdgeClass === "local-context") {
479
+ return {};
480
+ }
481
+
482
+ return {
483
+ hidden: true,
484
+ color: withAlpha("#1D2A35", 0.02),
485
+ size: Math.min(baseStyle.size ?? 0.35, 0.2),
486
+ zIndex: 0,
487
+ };
488
+ }
489
+
490
+ if (distanceVisualState.mode === "structural") {
491
+ const sourceDistance = distanceVisualState.structuralDistances[sourceId];
492
+ const targetDistance = distanceVisualState.structuralDistances[targetId];
493
+ const distance = Math.min(sourceDistance ?? Number.POSITIVE_INFINITY, targetDistance ?? Number.POSITIVE_INFINITY);
494
+ if (!Number.isFinite(distance) || distance > distanceVisualState.maxHops) {
495
+ return {};
496
+ }
497
+ const bandColor = getDistanceBandColor(distance);
498
+ return {
499
+ hidden: false,
500
+ color: withAlpha(bandColor, distance <= 1 ? 0.72 : distance <= 3 ? 0.48 : 0.32),
501
+ size: Math.max(baseStyle.size ?? 0.6, distance <= 1 ? 1.25 : distance <= 3 ? 0.95 : 0.7),
502
+ zIndex: Math.max(baseStyle.zIndex, distance <= 1 ? 5 : 3),
503
+ type: "line",
504
+ curvature: 0,
505
+ };
506
+ }
507
+
508
+ if (distanceVisualState.mode === "semantic") {
509
+ const anchorNodeId = distanceVisualState.anchorNodeId;
510
+ if (!anchorNodeId || (sourceId !== anchorNodeId && targetId !== anchorNodeId)) {
511
+ return {};
512
+ }
513
+ const otherNodeId = sourceId === anchorNodeId ? targetId : sourceId;
514
+ const score = distanceVisualState.semanticScores[otherNodeId];
515
+ if (score == null) {
516
+ return {};
517
+ }
518
+ const scoreColor = getSemanticScoreColor(score);
519
+ return {
520
+ hidden: false,
521
+ color: withAlpha(scoreColor, 0.34 + clamp(0, score, 1) * 0.42),
522
+ size: Math.max(baseStyle.size ?? 0.6, 0.8 + clamp(0, score, 1) * 0.75),
523
+ zIndex: Math.max(baseStyle.zIndex, 4),
524
+ type: "line",
525
+ curvature: 0,
526
+ };
527
+ }
528
+
529
+ return {};
530
+ }
531
+
532
+ function forEachDirectedEdgeBetween(
533
+ graphRef: GraphRef,
534
+ source: string,
535
+ target: string,
536
+ callback: (edgeId: string, attrs: EdgeAttributes) => void,
537
+ ) {
538
+ graphRef.forEachDirectedEdge(source, target, (edgeId, attrs) => {
539
+ callback(String(edgeId), attrs as EdgeAttributes);
540
+ });
541
+ }
542
+
543
+ function collectDirectedEdgeIdsBetween(
544
+ graphRef: GraphRef,
545
+ source: string,
546
+ target: string,
547
+ ): string[] {
548
+ const edgeIds: string[] = [];
549
+ forEachDirectedEdgeBetween(graphRef, source, target, (edgeId) => {
550
+ edgeIds.push(edgeId);
551
+ });
552
+ return edgeIds;
553
+ }
554
+
555
+ function isAggregatedEdgeAttributes(attrs: EdgeAttributes | undefined): boolean {
556
+ return Boolean(attrs?.isAggregated || (attrs?.rawEdgeIds?.length ?? 0) > 1);
557
+ }
558
+
559
+ function collectRawEdgeIds(attrs: EdgeAttributes | undefined, fallbackEdgeId?: string): string[] {
560
+ const rawEdgeIds = attrs?.rawEdgeIds?.map((edgeId) => String(edgeId)).filter(Boolean) ?? [];
561
+ if (rawEdgeIds.length > 0) {
562
+ return rawEdgeIds;
563
+ }
564
+ return fallbackEdgeId ? [String(fallbackEdgeId)] : [];
565
+ }
566
+
567
+ function createEmptyDisplayState(
568
+ selectedNodeId: string,
569
+ aggregationEnabled: boolean,
570
+ ): GraphDisplayStateSnapshot {
571
+ return {
572
+ aggregationEnabled,
573
+ groupedViewAvailable: false,
574
+ groupedViewReason: null,
575
+ selectedRootNodeId: selectedNodeId || null,
576
+ selectedVisibleNeighborIds: [],
577
+ selectedCollapsedNeighborIds: [],
578
+ selectedNodeKind: selectedNodeId ? "unavailable" : "none",
579
+ canActivateFocused: false,
580
+ resolvedFocusedNodeId: null,
581
+ focusedUnavailableReason: selectedNodeId ? { code: "not-in-graph" } : null,
582
+ };
583
+ }
584
+
585
+ export function resolveGroupedDisplayNodeId(
586
+ displayGraph: GraphRef,
587
+ nodeId: string,
588
+ ): string | null {
589
+ if (!nodeId) {
590
+ return null;
591
+ }
592
+
593
+ if (displayGraph.hasNode(nodeId)) {
594
+ return nodeId;
595
+ }
596
+
597
+ let resolvedNodeId: string | null = null;
598
+ displayGraph.forEachNode((candidateId, attrs) => {
599
+ if (resolvedNodeId) {
600
+ return;
601
+ }
602
+
603
+ const communityGroup = (attrs as NodeAttributes).properties?.__communityGroup as
604
+ | {
605
+ anchorNodeId?: string | null;
606
+ memberNodeIds?: string[];
607
+ sampleNodeIds?: string[];
608
+ }
609
+ | undefined;
610
+ if (!communityGroup) {
611
+ return;
612
+ }
613
+
614
+ if (communityGroup.anchorNodeId === nodeId) {
615
+ resolvedNodeId = candidateId;
616
+ return;
617
+ }
618
+
619
+ if (communityGroup.sampleNodeIds?.includes(nodeId) || communityGroup.memberNodeIds?.includes(nodeId)) {
620
+ resolvedNodeId = candidateId;
621
+ }
622
+ });
623
+
624
+ return resolvedNodeId;
625
+ }
626
+
627
+ export function checkGroupedViewAvailability(): {
628
+ available: boolean;
629
+ reason: GroupedViewUnavailableReason | null;
630
+ } {
631
+ const base = computeGraphAnalyticsBase(graph, {
632
+ computeCommunities: true,
633
+ computeCentrality: false,
634
+ });
635
+ const available = base.communitiesByNode.size > 0;
636
+ return {
637
+ available,
638
+ reason: available ? null : { code: "communities-undetected" },
639
+ };
640
+ }
641
+
642
+ function rankGroupedNeighbors(
643
+ displayGraph: GraphRef,
644
+ nodeId: string,
645
+ ): string[] {
646
+ const resolvedNodeId = resolveGroupedDisplayNodeId(displayGraph, nodeId);
647
+ if (!resolvedNodeId) {
648
+ return [];
649
+ }
650
+
651
+ const scoredNeighbors = new Map<string, number>();
652
+ displayGraph.forEachEdge((_, attrs, source, target) => {
653
+ const sourceId = String(source);
654
+ const targetId = String(target);
655
+ if (sourceId !== resolvedNodeId && targetId !== resolvedNodeId) {
656
+ return;
657
+ }
658
+
659
+ const neighborId = sourceId === resolvedNodeId ? targetId : sourceId;
660
+ if (!neighborId || neighborId === resolvedNodeId || !displayGraph.hasNode(neighborId)) {
661
+ return;
662
+ }
663
+
664
+ const weightCandidate = Number(
665
+ (attrs as EdgeAttributes).aggregateCount
666
+ ?? (attrs as EdgeAttributes).baseSize
667
+ ?? (attrs as EdgeAttributes).size
668
+ ?? (attrs as EdgeAttributes).weight
669
+ ?? 1,
670
+ );
671
+ const weight = Number.isFinite(weightCandidate) && weightCandidate > 0 ? weightCandidate : 1;
672
+ scoredNeighbors.set(neighborId, (scoredNeighbors.get(neighborId) ?? 0) + weight);
673
+ });
674
+
675
+ return Array.from(scoredNeighbors.entries())
676
+ .sort((left, right) => {
677
+ if (right[1] !== left[1]) {
678
+ return right[1] - left[1];
679
+ }
680
+ return left[0].localeCompare(right[0]);
681
+ })
682
+ .map(([neighborId]) => neighborId);
683
+ }
684
+
685
+ export function resolveGroupedDisplayStateSnapshot(
686
+ displayGraph: GraphRef,
687
+ selectedNodeId: string,
688
+ options?: {
689
+ groupedViewAvailable?: boolean;
690
+ groupedViewReason?: GroupedViewUnavailableReason | null;
691
+ selectedNodeKind?: GraphSelectedNodeKind;
692
+ resolvedFocusedNodeId?: string | null;
693
+ focusedUnavailableReason?: FocusedUnavailableReason | null;
694
+ },
695
+ ): GraphDisplayStateSnapshot {
696
+ const displayState = createEmptyDisplayState(selectedNodeId, true);
697
+ displayState.groupedViewAvailable = options?.groupedViewAvailable ?? true;
698
+ displayState.groupedViewReason = options?.groupedViewReason ?? null;
699
+ displayState.selectedNodeKind = options?.selectedNodeKind ?? (selectedNodeId ? "grouped" : "none");
700
+ displayState.resolvedFocusedNodeId = options?.resolvedFocusedNodeId ?? null;
701
+ displayState.canActivateFocused = Boolean(displayState.resolvedFocusedNodeId);
702
+ displayState.focusedUnavailableReason = displayState.canActivateFocused
703
+ ? null
704
+ : (options?.focusedUnavailableReason ?? displayState.focusedUnavailableReason);
705
+
706
+ if (!selectedNodeId) {
707
+ return displayState;
708
+ }
709
+
710
+ const resolvedNodeId = resolveGroupedDisplayNodeId(displayGraph, selectedNodeId);
711
+ if (!resolvedNodeId) {
712
+ return displayState;
713
+ }
714
+
715
+ const rankedNeighbors = rankGroupedNeighbors(displayGraph, resolvedNodeId);
716
+ displayState.selectedRootNodeId = resolvedNodeId;
717
+ displayState.selectedVisibleNeighborIds = rankedNeighbors.slice(0, MAX_FOCUS_NEIGHBORS);
718
+ displayState.selectedCollapsedNeighborIds = rankedNeighbors.slice(MAX_FOCUS_NEIGHBORS);
719
+ return displayState;
720
+ }
721
+
722
+ function validateGroupedDisplayGraph(
723
+ grouped: Graph<NodeAttributes, EdgeAttributes>,
724
+ ): GroupedViewUnavailableReason | null {
725
+ if (grouped.order === 0) {
726
+ return { code: "community-nodes-missing" };
727
+ }
728
+
729
+ let invalidReason: GroupedViewUnavailableReason | null = null;
730
+ grouped.forEachNode((nodeId, attrs) => {
731
+ if (invalidReason) {
732
+ return;
733
+ }
734
+
735
+ const nodeAttrs = attrs as NodeAttributes;
736
+ if (!Number.isFinite(Number(nodeAttrs.x)) || !Number.isFinite(Number(nodeAttrs.y))) {
737
+ invalidReason = { code: "invalid-community-layout", nodeId };
738
+ }
739
+ });
740
+
741
+ if (invalidReason) {
742
+ return invalidReason;
743
+ }
744
+
745
+ grouped.forEachEdge((edgeId, _attrs, sourceId, targetId) => {
746
+ if (invalidReason) {
747
+ return;
748
+ }
749
+
750
+ if (!grouped.hasNode(sourceId) || !grouped.hasNode(targetId)) {
751
+ invalidReason = { code: "missing-grouped-node", edgeId };
752
+ }
753
+ });
754
+
755
+ return invalidReason;
756
+ }
757
+
758
+ export function buildPathEdgeSet(
759
+ graphRef: GraphRef,
760
+ path: string[],
761
+ pathEdgeIds: string[] = [],
762
+ ): Set<string> {
763
+ if (pathEdgeIds.length > 0) {
764
+ const requested = new Set(pathEdgeIds.map((edgeId) => String(edgeId)));
765
+ const matched = new Set<string>();
766
+ graphRef.forEachEdge((edgeId, attrs) => {
767
+ const stableEdgeId = String(edgeId);
768
+ if (requested.has(stableEdgeId)) {
769
+ matched.add(stableEdgeId);
770
+ return;
771
+ }
772
+
773
+ const rawEdgeIds = collectRawEdgeIds(attrs as EdgeAttributes, stableEdgeId);
774
+ if (rawEdgeIds.some((rawEdgeId) => requested.has(rawEdgeId))) {
775
+ matched.add(stableEdgeId);
776
+ }
777
+ });
778
+ return matched;
779
+ }
780
+
781
+ const edgeIds = new Set<string>();
782
+ for (let index = 0; index < path.length - 1; index += 1) {
783
+ collectDirectedEdgeIdsBetween(graphRef, path[index], path[index + 1]).forEach((edgeId) => edgeIds.add(edgeId));
784
+ }
785
+ return edgeIds;
786
+ }
787
+
788
+ export function buildEdgeEndpointSet(
789
+ graphRef: GraphRef,
790
+ ...edgeIds: Array<string | null | undefined>
791
+ ): Set<string> {
792
+ const nodeIds = new Set<string>();
793
+
794
+ edgeIds.forEach((edgeId) => {
795
+ if (!edgeId || !graphRef.hasEdge(edgeId)) {
796
+ return;
797
+ }
798
+
799
+ const [source, target] = graphRef.extremities(edgeId);
800
+ nodeIds.add(source);
801
+ nodeIds.add(target);
802
+ });
803
+
804
+ return nodeIds;
805
+ }
806
+
807
+ function collectFocusEdgeIds(
808
+ graphRef: GraphRef,
809
+ nodeIds: Set<string>,
810
+ ): Set<string> {
811
+ const edgeIds = new Set<string>();
812
+ const ids = Array.from(nodeIds);
813
+
814
+ for (let sourceIndex = 0; sourceIndex < ids.length; sourceIndex += 1) {
815
+ const source = ids[sourceIndex];
816
+
817
+ for (let targetIndex = 0; targetIndex < ids.length; targetIndex += 1) {
818
+ const target = ids[targetIndex];
819
+ if (source === target) {
820
+ continue;
821
+ }
822
+
823
+ collectDirectedEdgeIdsBetween(graphRef, source, target).forEach((edgeId) => edgeIds.add(edgeId));
824
+ }
825
+ }
826
+
827
+ return edgeIds;
828
+ }
829
+
830
+ function collectImpactedNodeIds(
831
+ graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
832
+ interactionState: GraphInteractionState | null,
833
+ ): Set<string> {
834
+ if (!interactionState) {
835
+ return new Set<string>();
836
+ }
837
+
838
+ const impacted = new Set<string>(interactionState.activePath);
839
+ const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
840
+ if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
841
+ buildFocusSetInGraph(graphRef, primaryNodeId).forEach((nodeId) => impacted.add(nodeId));
842
+ }
843
+
844
+ buildEdgeEndpointSet(graphRef, interactionState.selectedEdgeId)
845
+ .forEach((nodeId) => impacted.add(nodeId));
846
+
847
+ return impacted;
848
+ }
849
+
850
+ function collectImpactedEdgeKeys(
851
+ graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
852
+ interactionState: GraphInteractionState | null,
853
+ ): Set<string> {
854
+ if (!interactionState) {
855
+ return new Set<string>();
856
+ }
857
+
858
+ const impacted = new Set<string>(buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds));
859
+ const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
860
+ if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
861
+ collectFocusEdgeIds(graphRef, buildFocusSetInGraph(graphRef, primaryNodeId)).forEach((edgeId) => impacted.add(edgeId));
862
+ }
863
+
864
+ if (interactionState.selectedEdgeId) {
865
+ impacted.add(interactionState.selectedEdgeId);
866
+ }
867
+
868
+ return impacted;
869
+ }
870
+
871
+ function resolveDisplayEdgeIds(
872
+ graphRef: GraphRef,
873
+ stableEdgeIds: Set<string>,
874
+ ): string[] {
875
+ const displayEdgeIds = new Set<string>();
876
+ graphRef.forEachEdge((edgeId, attrs) => {
877
+ const stableEdgeId = String(edgeId);
878
+ if (stableEdgeIds.has(stableEdgeId)) {
879
+ displayEdgeIds.add(stableEdgeId);
880
+ return;
881
+ }
882
+
883
+ const rawEdgeIds = collectRawEdgeIds(attrs as EdgeAttributes, stableEdgeId);
884
+ if (rawEdgeIds.some((rawEdgeId) => stableEdgeIds.has(rawEdgeId))) {
885
+ displayEdgeIds.add(stableEdgeId);
886
+ }
887
+ });
888
+
889
+ return Array.from(displayEdgeIds);
890
+ }
891
+
892
+ export function collectInteractionRefreshTargets(
893
+ graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
894
+ previousState: GraphInteractionState | null,
895
+ nextState: GraphInteractionState,
896
+ ): { nodes: string[]; edges: string[] } {
897
+ const nodeIds = new Set<string>();
898
+ const edgeKeys = new Set<string>();
899
+
900
+ collectImpactedNodeIds(graphRef, previousState).forEach((nodeId) => nodeIds.add(nodeId));
901
+ collectImpactedNodeIds(graphRef, nextState).forEach((nodeId) => nodeIds.add(nodeId));
902
+ collectImpactedEdgeKeys(graphRef, previousState).forEach((edgeId) => edgeKeys.add(edgeId));
903
+ collectImpactedEdgeKeys(graphRef, nextState).forEach((edgeId) => edgeKeys.add(edgeId));
904
+
905
+ return {
906
+ nodes: Array.from(nodeIds).filter((nodeId) => graphRef.hasNode(nodeId)),
907
+ edges: resolveDisplayEdgeIds(graphRef, edgeKeys),
908
+ };
909
+ }
910
+
911
+ function logGroupedGraphSkip(kind: "focus" | "neighbors", graphRef: GraphRef, nodeId: string) {
912
+ if (!DEBUG_GRAPH_SCENE_STATE || !nodeId.startsWith(COMMUNITY_NODE_PREFIX)) {
913
+ return;
914
+ }
915
+
916
+ console.debug("[graphSceneState]", `${kind}-skipped-missing-display-node`, {
917
+ nodeId,
918
+ order: graphRef.order,
919
+ size: graphRef.size,
920
+ });
921
+ }
922
+
923
+ export function getEdgeWeightBetweenInGraph(graphRef: GraphRef, source: string, target: string): number {
924
+ let weight = 0;
925
+
926
+ forEachDirectedEdgeBetween(graphRef, source, target, (_edgeId, attrs) => {
927
+ weight = Math.max(weight, Number(attrs?.weight ?? 0));
928
+ });
929
+
930
+ forEachDirectedEdgeBetween(graphRef, target, source, (_edgeId, attrs) => {
931
+ weight = Math.max(weight, Number(attrs?.weight ?? 0));
932
+ });
933
+
934
+ return weight;
935
+ }
936
+
937
+ export function getEdgeWeightBetween(source: string, target: string): number {
938
+ return getEdgeWeightBetweenInGraph(graph, source, target);
939
+ }
940
+
941
+ export function rankNeighborsInGraph(graphRef: GraphRef, nodeId: string): string[] {
942
+ if (!nodeId || !graphRef.hasNode(nodeId)) {
943
+ logGroupedGraphSkip("neighbors", graphRef, nodeId);
944
+ return [];
945
+ }
946
+
947
+ return graphRef
948
+ .neighbors(nodeId)
949
+ .map((neighborId) => ({
950
+ id: neighborId,
951
+ weight: getEdgeWeightBetweenInGraph(graphRef, nodeId, neighborId),
952
+ degree: graphRef.hasNode(neighborId) ? graphRef.degree(neighborId) : 0,
953
+ }))
954
+ .sort((left, right) => {
955
+ if (right.weight !== left.weight) {
956
+ return right.weight - left.weight;
957
+ }
958
+ if (right.degree !== left.degree) {
959
+ return right.degree - left.degree;
960
+ }
961
+ return left.id.localeCompare(right.id);
962
+ })
963
+ .map((item) => item.id);
964
+ }
965
+
966
+ export function rankNeighbors(nodeId: string): string[] {
967
+ return rankNeighborsInGraph(graph, nodeId);
968
+ }
969
+
970
+ export function buildFocusSetInGraph(graphRef: GraphRef, nodeId: string): Set<string> {
971
+ if (!nodeId || !graphRef.hasNode(nodeId)) {
972
+ logGroupedGraphSkip("focus", graphRef, nodeId);
973
+ return new Set<string>();
974
+ }
975
+
976
+ const ranked = rankNeighborsInGraph(graphRef, nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
977
+ return new Set<string>([nodeId, ...ranked]);
978
+ }
979
+
980
+ export function buildFocusSet(nodeId: string): Set<string> {
981
+ return buildFocusSetInGraph(graph, nodeId);
982
+ }
983
+
984
+ export function isEdgeInteractable(
985
+ graphRef: GraphRef,
986
+ interactionState: GraphInteractionState,
987
+ edgeId: string,
988
+ source: string,
989
+ target: string,
990
+ attrs: EdgeAttributes,
991
+ ): boolean {
992
+ const pathEdgeIds = buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds);
993
+ if (pathEdgeIds.has(edgeId) || interactionState.selectedEdgeId === edgeId) {
994
+ return true;
995
+ }
996
+
997
+ if (attrs.isAggregated && (interactionState.viewMode === "grouped" || interactionState.zoomTier === "inspection")) {
998
+ return true;
999
+ }
1000
+
1001
+ const primaryNodeId = interactionState.selectedNodeId;
1002
+ if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
1003
+ if (source === primaryNodeId || target === primaryNodeId) {
1004
+ return true;
1005
+ }
1006
+
1007
+ const focusIds = buildFocusSetInGraph(graphRef, primaryNodeId);
1008
+ return focusIds.has(source) && focusIds.has(target);
1009
+ }
1010
+
1011
+ if (interactionState.zoomTier !== "inspection") {
1012
+ return false;
1013
+ }
1014
+
1015
+ return Number(attrs.visualPriority ?? 0) >= GRAPH_THEME.zoomTiers[interactionState.zoomTier].edgePriorityThreshold;
1016
+ }
1017
+
1018
+ function resolveNodeColor(
1019
+ theme: GraphTheme,
1020
+ zoomTier: GraphZoomTier,
1021
+ state: GraphNodeVisualState,
1022
+ attrs: NodeAttributes,
1023
+ cameraRatio: number,
1024
+ ) {
1025
+ const semanticColor = getSemanticNodeColor(attrs, theme);
1026
+ const isCommunityGroup = Boolean(attrs.isCommunityGroup);
1027
+ const entityShapeConfig = theme.nodes.entityShapes[resolveEntityShape(attrs)];
1028
+ const overviewTint = state === "neighbor"
1029
+ ? theme.palette.overview.nodeTintMix + 0.09
1030
+ : theme.palette.overview.nodeTintMix;
1031
+ const overviewCore = blendHex(
1032
+ theme.palette.overview.nodeCore,
1033
+ semanticColor,
1034
+ Math.min(0.74, theme.palette.overview.nodeCoreMix + overviewTint),
1035
+ );
1036
+
1037
+ switch (theme.nodes.states[state].color) {
1038
+ case "selected":
1039
+ return theme.palette.accent.selected;
1040
+ case "hovered":
1041
+ return theme.palette.accent.hovered;
1042
+ case "path":
1043
+ return theme.palette.accent.path;
1044
+ case "muted":
1045
+ return zoomTier === "overview"
1046
+ ? withAlpha(theme.palette.overview.nodeMuted, 0.42)
1047
+ : String(attrs.mutedColor || withAlpha(semanticColor, theme.nodes.mutedAlpha));
1048
+ case "base":
1049
+ default:
1050
+ if (zoomTier === "overview") {
1051
+ const presenceBoost = getOverviewPresenceBoost(cameraRatio);
1052
+ const boostedCore = blendHex(overviewCore, semanticColor, 0.3 + 0.26 * presenceBoost);
1053
+ const overviewAlpha = Math.min(
1054
+ 0.98,
1055
+ theme.palette.overview.nodeCoreAlpha + presenceBoost * 0.18,
1056
+ );
1057
+ return withAlpha(
1058
+ boostedCore,
1059
+ isCommunityGroup ? Math.min(overviewAlpha, theme.grouped.style.fillAlpha) : Math.min(overviewAlpha, entityShapeConfig.fillAlpha),
1060
+ );
1061
+ }
1062
+ return withAlpha(semanticColor, isCommunityGroup ? theme.grouped.style.fillAlpha : entityShapeConfig.fillAlpha);
1063
+ }
1064
+ }
1065
+
1066
+ function resolveNodeShellColor(
1067
+ theme: GraphTheme,
1068
+ zoomTier: GraphZoomTier,
1069
+ state: GraphNodeVisualState,
1070
+ attrs: NodeAttributes,
1071
+ cameraRatio: number,
1072
+ ) {
1073
+ const semanticColor = getSemanticNodeColor(attrs, theme);
1074
+ const isCommunityGroup = Boolean(attrs.isCommunityGroup);
1075
+ const entityShapeConfig = theme.nodes.entityShapes[resolveEntityShape(attrs)];
1076
+ const presenceBoost = getOverviewPresenceBoost(cameraRatio);
1077
+ const overviewGlyph = blendHex(semanticColor, theme.ui.text.strong, 0.34 + presenceBoost * 0.12);
1078
+
1079
+ if (zoomTier !== "overview") {
1080
+ return withAlpha(
1081
+ blendHex(semanticColor, theme.ui.text.strong, state === "default" ? 0.26 : 0.42),
1082
+ isCommunityGroup ? theme.grouped.style.shellAlpha : Math.min(0.72, entityShapeConfig.shellAlpha + 0.12),
1083
+ );
1084
+ }
1085
+
1086
+ if (state === "selected") {
1087
+ return withAlpha(blendHex(theme.palette.accent.selected, theme.ui.text.strong, 0.24), 0.92);
1088
+ }
1089
+
1090
+ if (state === "hovered") {
1091
+ return withAlpha(blendHex(theme.palette.accent.hovered, theme.ui.text.strong, 0.22), 0.9);
1092
+ }
1093
+
1094
+ if (state === "path") {
1095
+ return withAlpha(blendHex(theme.palette.accent.path, theme.ui.text.strong, 0.22), 0.9);
1096
+ }
1097
+
1098
+ if (state === "muted" || state === "inactive") {
1099
+ return withAlpha(theme.palette.overview.nodeBorder, 0.16);
1100
+ }
1101
+
1102
+ return withAlpha(
1103
+ overviewGlyph,
1104
+ isCommunityGroup ? theme.grouped.style.shellAlpha : Math.min(0.58, entityShapeConfig.shellAlpha + 0.08),
1105
+ );
1106
+ }
1107
+
1108
+ function resolveNodeCoreScale(
1109
+ zoomTier: GraphZoomTier,
1110
+ state: GraphNodeVisualState,
1111
+ cameraRatio: number,
1112
+ ) {
1113
+ const presenceBoost = getOverviewPresenceBoost(cameraRatio);
1114
+ if (zoomTier === "overview") {
1115
+ switch (state) {
1116
+ case "selected":
1117
+ return 0.34 + presenceBoost * 0.08;
1118
+ case "hovered":
1119
+ return 0.32 + presenceBoost * 0.08;
1120
+ case "path":
1121
+ return 0.28 + presenceBoost * 0.07;
1122
+ case "neighbor":
1123
+ return 0.2 + presenceBoost * 0.06;
1124
+ case "muted":
1125
+ case "inactive":
1126
+ return 0.08 + presenceBoost * 0.03;
1127
+ case "default":
1128
+ default:
1129
+ return 0.16 + presenceBoost * 0.08;
1130
+ }
1131
+ }
1132
+
1133
+ switch (state) {
1134
+ case "selected":
1135
+ return 0.52;
1136
+ case "hovered":
1137
+ return 0.48;
1138
+ case "path":
1139
+ return 0.44;
1140
+ case "neighbor":
1141
+ return 0.3;
1142
+ case "muted":
1143
+ case "inactive":
1144
+ return 0.1;
1145
+ case "default":
1146
+ default:
1147
+ return zoomTier === "structure" ? 0.22 : 0.28;
1148
+ }
1149
+ }
1150
+
1151
+ function resolveEdgeColor(
1152
+ theme: GraphTheme,
1153
+ zoomTier: GraphZoomTier,
1154
+ state: GraphEdgeVisualState,
1155
+ attrs: EdgeAttributes,
1156
+ fallbackColor?: string,
1157
+ fullEdgeClass?: GraphFullEdgeClass,
1158
+ ) {
1159
+ const defaultInspectionColor = zoomTier === "overview"
1160
+ ? theme.palette.overview.edgeInspection
1161
+ : theme.palette.muted.edgeInspection;
1162
+ const baseColor = String(attrs.baseColor || fallbackColor || defaultInspectionColor);
1163
+
1164
+ if (fullEdgeClass === "bridge") {
1165
+ return theme.palette.muted.edgeFocus;
1166
+ }
1167
+
1168
+ switch (theme.edges.states[state].color) {
1169
+ case "hover":
1170
+ return theme.palette.accent.hovered;
1171
+ case "path":
1172
+ return theme.palette.accent.path;
1173
+ case "backbone":
1174
+ return zoomTier === "overview"
1175
+ ? theme.palette.overview.edgeBackbone
1176
+ : theme.palette.muted.edgeFocus;
1177
+ case "focus":
1178
+ return theme.palette.muted.edgeFocus;
1179
+ case "overview":
1180
+ return theme.palette.muted.edgeOverview;
1181
+ case "structure":
1182
+ return zoomTier === "overview"
1183
+ ? theme.palette.overview.edgeStructure
1184
+ : theme.palette.muted.edgeStructure;
1185
+ case "inspection":
1186
+ return zoomTier === "overview"
1187
+ ? theme.palette.overview.edgeInspection
1188
+ : theme.palette.muted.edgeInspection;
1189
+ case "muted":
1190
+ return zoomTier === "overview"
1191
+ ? theme.palette.overview.edgeStructure
1192
+ : theme.palette.muted.edgeOverview;
1193
+ default:
1194
+ return baseColor;
1195
+ }
1196
+ }
1197
+
1198
+ function resolveEdgeVisibilityPolicy(
1199
+ theme: GraphTheme,
1200
+ viewMode: GraphViewMode,
1201
+ zoomTier: GraphZoomTier,
1202
+ isCommunityBundle: boolean,
1203
+ ) {
1204
+ const policyMode = isCommunityBundle ? "grouped" : viewMode;
1205
+ return theme.edges.visibility[policyMode][zoomTier];
1206
+ }
1207
+
1208
+ function isContextEdgeState(state: GraphEdgeVisualState): boolean {
1209
+ return state === "path"
1210
+ || state === "selected"
1211
+ || state === "hovered"
1212
+ || state === "neighbor"
1213
+ || state === "backbone";
1214
+ }
1215
+
1216
+ function isNonCriticalEdgeVariant(variant: GraphEdgeVariant): boolean {
1217
+ return variant === "line"
1218
+ || variant === "directional"
1219
+ || variant === "parallelCurve"
1220
+ || variant === "bidirectionalCurve";
1221
+ }
1222
+
1223
+ function shouldSampleOutBackgroundEdge(
1224
+ sampleRate: number,
1225
+ visualPriority: number,
1226
+ edgeId?: string,
1227
+ sourceId?: string,
1228
+ targetId?: string,
1229
+ ): boolean {
1230
+ if (sampleRate >= 1) {
1231
+ return false;
1232
+ }
1233
+
1234
+ const sampleKey = edgeId || `${sourceId ?? "?"}->${targetId ?? "?"}`;
1235
+ const bucket = (hashString(sampleKey) % 10000) / 10000;
1236
+ const priorityAdjustedSampleRate = Math.min(1, sampleRate + visualPriority * 0.08);
1237
+ return bucket > priorityAdjustedSampleRate;
1238
+ }
1239
+
1240
+ function resolveEdgeLodAlpha(
1241
+ theme: GraphTheme,
1242
+ viewMode: GraphViewMode,
1243
+ zoomTier: GraphZoomTier,
1244
+ state: GraphEdgeVisualState,
1245
+ attrs: EdgeAttributes,
1246
+ isCommunityBundle: boolean,
1247
+ fullEdgeClass?: GraphFullEdgeClass,
1248
+ ): number | null {
1249
+ const policy = resolveEdgeVisibilityPolicy(theme, viewMode, zoomTier, isCommunityBundle);
1250
+ if (viewMode === "full") {
1251
+ if (fullEdgeClass === "path" || state === "path") {
1252
+ return theme.interaction.pathEdgeAlpha;
1253
+ }
1254
+ if (fullEdgeClass === "selected" || state === "selected") {
1255
+ return theme.interaction.selectedEdgeAlpha;
1256
+ }
1257
+ if (fullEdgeClass === "local-context" || state === "hovered") {
1258
+ return theme.interaction.localContextAlpha;
1259
+ }
1260
+ if (fullEdgeClass === "bridge") {
1261
+ return theme.edges.fullGraphStructure.bridgeAlpha;
1262
+ }
1263
+ if (fullEdgeClass === "backbone") {
1264
+ return theme.edges.fullGraphStructure.backboneAlpha;
1265
+ }
1266
+ if (state === "backbone") {
1267
+ return theme.edges.fullGraphStructure.ambientBackboneAlpha;
1268
+ }
1269
+ if (state === "default" && zoomTier === "structure") {
1270
+ return theme.edges.fullGraphStructure.structureEdgeAlpha;
1271
+ }
1272
+ if (state === "default" && zoomTier === "inspection") {
1273
+ return theme.edges.fullGraphStructure.inspectionEdgeAlpha;
1274
+ }
1275
+ }
1276
+ if (state === "path") {
1277
+ return theme.interaction.pathEdgeAlpha;
1278
+ }
1279
+ if (state === "selected" || state === "hovered") {
1280
+ return state === "selected" ? theme.interaction.selectedEdgeAlpha : theme.interaction.hoverContextAlpha;
1281
+ }
1282
+ if (state === "default") {
1283
+ return policy.defaultAlpha;
1284
+ }
1285
+ if (state === "muted") {
1286
+ return policy.mutedAlpha;
1287
+ }
1288
+ if (state === "inactive") {
1289
+ return policy.inactiveAlpha;
1290
+ }
1291
+ if (state === "neighbor") {
1292
+ if (attrs.bundleKind === "community") {
1293
+ return Math.max(policy.neighborAlpha, theme.grouped.style.edgeAlpha);
1294
+ }
1295
+ return policy.neighborAlpha;
1296
+ }
1297
+ if (isCommunityBundle && !isContextEdgeState(state)) {
1298
+ return theme.grouped.style.edgeAlpha;
1299
+ }
1300
+ return null;
1301
+ }
1302
+
1303
+ function resolveNodeRingColor(
1304
+ theme: GraphTheme,
1305
+ state: GraphNodeVisualState,
1306
+ attrs: NodeAttributes,
1307
+ ) {
1308
+ if (state === "selected") {
1309
+ return attrs.ringColor || theme.nodes.selectedRing.color;
1310
+ }
1311
+ if (state === "hovered") {
1312
+ return theme.palette.accent.hovered;
1313
+ }
1314
+ if (state === "path") {
1315
+ return theme.palette.accent.path;
1316
+ }
1317
+
1318
+ return undefined;
1319
+ }
1320
+
1321
+ function resolveNodeRingSize(
1322
+ theme: GraphTheme,
1323
+ state: GraphNodeVisualState,
1324
+ zoomTier: GraphZoomTier,
1325
+ ) {
1326
+ if (state === "selected" && zoomTierAtLeast(zoomTier, theme.nodes.selectedRing.visibleFrom)) {
1327
+ return theme.nodes.selectedRing.nativeSize;
1328
+ }
1329
+ if (state === "hovered") {
1330
+ return Math.max(1.45, theme.nodes.selectedRing.nativeSize - 0.35);
1331
+ }
1332
+ if (state === "path") {
1333
+ return Math.max(1.2, theme.nodes.selectedRing.nativeSize - 0.55);
1334
+ }
1335
+
1336
+ return 0;
1337
+ }
1338
+
1339
+ export function resolveNodeVisualState(
1340
+ nodeId: string,
1341
+ zoomTier: GraphZoomTier,
1342
+ hoveredNodeId: string | null,
1343
+ selectedNodeId: string,
1344
+ selectedEdgeId: string,
1345
+ focusIds: Set<string>,
1346
+ edgeEndpointIds: Set<string>,
1347
+ pathNodeIds: Set<string>,
1348
+ ): GraphNodeVisualState {
1349
+ if (hoveredNodeId && nodeId === hoveredNodeId) {
1350
+ return "hovered";
1351
+ }
1352
+ if (selectedNodeId && nodeId === selectedNodeId) {
1353
+ return "selected";
1354
+ }
1355
+ if (pathNodeIds.has(nodeId)) {
1356
+ return "path";
1357
+ }
1358
+ if (focusIds.has(nodeId)) {
1359
+ return "neighbor";
1360
+ }
1361
+ if (edgeEndpointIds.has(nodeId)) {
1362
+ return "neighbor";
1363
+ }
1364
+ if (hoveredNodeId || selectedNodeId || selectedEdgeId || pathNodeIds.size > 0) {
1365
+ if (zoomTier !== "inspection") {
1366
+ return "default";
1367
+ }
1368
+ return "muted";
1369
+ }
1370
+ return "default";
1371
+ }
1372
+
1373
+ export function resolveEdgeVisualState(
1374
+ edgeId: string,
1375
+ source: string,
1376
+ target: string,
1377
+ zoomTier: GraphZoomTier,
1378
+ hoveredNodeId: string | null,
1379
+ selectedNodeId: string,
1380
+ selectedEdgeId: string,
1381
+ focusIds: Set<string>,
1382
+ pathEdgeIds: Set<string>,
1383
+ highlightedIncidentEdgeIds: Set<string> = new Set(),
1384
+ ): GraphEdgeVisualState {
1385
+ const primaryNodeId = hoveredNodeId || selectedNodeId;
1386
+
1387
+ if (pathEdgeIds.has(edgeId)) {
1388
+ return "path";
1389
+ }
1390
+
1391
+ if (selectedEdgeId && edgeId === selectedEdgeId) {
1392
+ return "selected";
1393
+ }
1394
+
1395
+ if (primaryNodeId && (source === primaryNodeId || target === primaryNodeId)) {
1396
+ if (highlightedIncidentEdgeIds.has(edgeId)) {
1397
+ return hoveredNodeId ? "hovered" : "selected";
1398
+ }
1399
+ return "muted";
1400
+ }
1401
+
1402
+ if (zoomTier !== "overview" && focusIds.has(source) && focusIds.has(target)) {
1403
+ return "neighbor";
1404
+ }
1405
+
1406
+ if (hoveredNodeId || selectedNodeId || selectedEdgeId || pathEdgeIds.size > 0) {
1407
+ return "muted";
1408
+ }
1409
+
1410
+ if (zoomTier === "overview") {
1411
+ return "inactive";
1412
+ }
1413
+
1414
+ return "default";
1415
+ }
1416
+
1417
+ function getNodeBridgeGroup(attrs?: NodeAttributes): string {
1418
+ if (!attrs) {
1419
+ return "";
1420
+ }
1421
+ return String(attrs.communityId || attrs.semanticGroup || attrs.nodeType || "");
1422
+ }
1423
+
1424
+ function isCuratedBridgeEdge(sourceAttrs?: NodeAttributes, targetAttrs?: NodeAttributes): boolean {
1425
+ const sourceGroup = getNodeBridgeGroup(sourceAttrs);
1426
+ const targetGroup = getNodeBridgeGroup(targetAttrs);
1427
+ return Boolean(sourceGroup && targetGroup && sourceGroup !== targetGroup);
1428
+ }
1429
+
1430
+ export function classifyFullGraphEdge(
1431
+ edgeId: string,
1432
+ source: string,
1433
+ target: string,
1434
+ zoomTier: GraphZoomTier,
1435
+ hoveredNodeId: string | null,
1436
+ selectedNodeId: string,
1437
+ selectedEdgeId: string,
1438
+ focusIds: Set<string>,
1439
+ pathEdgeIds: Set<string>,
1440
+ highlightedIncidentEdgeIds: Set<string> = new Set(),
1441
+ overviewBackboneEdgeIds: Set<string> = new Set(),
1442
+ sourceAttrs?: NodeAttributes,
1443
+ targetAttrs?: NodeAttributes,
1444
+ ): GraphFullEdgeClass {
1445
+ const primaryNodeId = hoveredNodeId || selectedNodeId;
1446
+
1447
+ if (pathEdgeIds.has(edgeId)) {
1448
+ return "path";
1449
+ }
1450
+
1451
+ if (selectedEdgeId && edgeId === selectedEdgeId) {
1452
+ return "selected";
1453
+ }
1454
+
1455
+ if (highlightedIncidentEdgeIds.has(edgeId)) {
1456
+ return "local-context";
1457
+ }
1458
+
1459
+ if (primaryNodeId && (source === primaryNodeId || target === primaryNodeId)) {
1460
+ return "muted";
1461
+ }
1462
+
1463
+ if (overviewBackboneEdgeIds.has(edgeId)) {
1464
+ return isCuratedBridgeEdge(sourceAttrs, targetAttrs) ? "bridge" : "backbone";
1465
+ }
1466
+
1467
+ if (zoomTier === "overview") {
1468
+ return "hidden";
1469
+ }
1470
+
1471
+ if (focusIds.has(source) && focusIds.has(target)) {
1472
+ return "muted";
1473
+ }
1474
+
1475
+ return "muted";
1476
+ }
1477
+
1478
+ export function mapFullEdgeClassToVisualState(
1479
+ edgeClass: GraphFullEdgeClass,
1480
+ options: { hoveredNodeId: string | null; hasActiveInteraction: boolean },
1481
+ ): GraphEdgeVisualState {
1482
+ switch (edgeClass) {
1483
+ case "path":
1484
+ return "path";
1485
+ case "selected":
1486
+ return "selected";
1487
+ case "local-context":
1488
+ return options.hoveredNodeId ? "hovered" : "selected";
1489
+ case "backbone":
1490
+ return "backbone";
1491
+ case "bridge":
1492
+ return "backbone";
1493
+ case "hidden":
1494
+ return "inactive";
1495
+ case "muted":
1496
+ default:
1497
+ return options.hasActiveInteraction ? "muted" : "default";
1498
+ }
1499
+ }
1500
+
1501
+ export function resolveNodeVariant(state: GraphNodeVisualState, attrs: NodeAttributes): GraphNodeShapeVariant {
1502
+ if (state === "selected") {
1503
+ return "selected";
1504
+ }
1505
+
1506
+ return attrs.nodeShapeVariant || attrs.nodeVariant || "default";
1507
+ }
1508
+
1509
+ export function resolveEntityShape(attrs: NodeAttributes): GraphEntityShapeVariant {
1510
+ if (attrs.isCommunityGroup) {
1511
+ return "community";
1512
+ }
1513
+
1514
+ // Prefer the shape stamped at load time; fall back to classifyEntityShape for
1515
+ // nodes created programmatically that bypassed useLoadGraph.
1516
+ return attrs.entityShape
1517
+ || classifyEntityShape(
1518
+ attrs.nodeType,
1519
+ attrs.semanticGroup,
1520
+ attrs.content,
1521
+ attrs.properties as Record<string, unknown> | undefined,
1522
+ );
1523
+ }
1524
+
1525
+ export function resolveEdgeVariant(state: GraphEdgeVisualState, attrs: EdgeAttributes): GraphEdgeVariant {
1526
+ if (state === "path") {
1527
+ return "pathSignal";
1528
+ }
1529
+
1530
+ if (attrs.bundleKind === "community") {
1531
+ return "line";
1532
+ }
1533
+
1534
+ if ((attrs.parallelCount ?? 1) > 1) {
1535
+ return "parallelCurve";
1536
+ }
1537
+
1538
+ if (attrs.edgeVariant) {
1539
+ return attrs.edgeVariant;
1540
+ }
1541
+
1542
+ if (attrs.isBidirectional) {
1543
+ return "bidirectionalCurve";
1544
+ }
1545
+
1546
+ if (attrs.arrowVisibilityPolicy === "contextual") {
1547
+ return "directional";
1548
+ }
1549
+
1550
+ return "line";
1551
+ }
1552
+
1553
+ export function shouldForceNodeLabel(
1554
+ theme: GraphTheme,
1555
+ zoomTier: GraphZoomTier,
1556
+ state: GraphNodeVisualState,
1557
+ attrs: NodeAttributes,
1558
+ labelPriority: number,
1559
+ ): boolean {
1560
+ const tierConfig = theme.zoomTiers[zoomTier];
1561
+ const forceVisibleState = theme.labels.forceVisibleStates.includes(state);
1562
+ const policy = attrs.labelVisibilityPolicy || "priority";
1563
+
1564
+ if (forceVisibleState || theme.nodes.states[state].forceLabel) {
1565
+ return true;
1566
+ }
1567
+
1568
+ switch (policy as GraphLabelVisibilityPolicy) {
1569
+ case "always":
1570
+ return true;
1571
+ case "local":
1572
+ return zoomTier !== "overview" && state !== "default" && state !== "muted" && state !== "inactive";
1573
+ case "priority":
1574
+ return labelPriority >= tierConfig.labelThreshold;
1575
+ case "none":
1576
+ default:
1577
+ return false;
1578
+ }
1579
+ }
1580
+
1581
+ function resolveNodeBorderColor(
1582
+ theme: GraphTheme,
1583
+ zoomTier: GraphZoomTier,
1584
+ state: GraphNodeVisualState,
1585
+ variant: GraphNodeShapeVariant,
1586
+ attrs: NodeAttributes,
1587
+ baseColor: string,
1588
+ ) {
1589
+ if (state === "selected" || variant === "selected") {
1590
+ return attrs.ringColor || theme.nodes.selectedRing.color;
1591
+ }
1592
+ if (state === "hovered") {
1593
+ return theme.palette.accent.hovered;
1594
+ }
1595
+ if (state === "path") {
1596
+ return theme.palette.accent.path;
1597
+ }
1598
+ if (state === "muted" || state === "inactive") {
1599
+ return withAlpha(
1600
+ attrs.strokeColor || attrs.borderColor || theme.palette.overview.nodeBorder || theme.palette.background.nodeBorder,
1601
+ zoomTier === "overview" ? 0.26 : 0.7,
1602
+ );
1603
+ }
1604
+
1605
+ if (variant === "temporal") {
1606
+ return theme.palette.accent.temporal;
1607
+ }
1608
+ if (variant === "provenance") {
1609
+ return theme.palette.accent.provenance;
1610
+ }
1611
+ if (variant === "inferred") {
1612
+ return theme.palette.accent.inferred;
1613
+ }
1614
+
1615
+ if (zoomTier === "overview") {
1616
+ return withAlpha(
1617
+ blendHex(theme.palette.overview.nodeBorder, baseColor, state === "neighbor" ? 0.08 : 0.03),
1618
+ state === "neighbor" ? 0.24 : 0.06,
1619
+ );
1620
+ }
1621
+
1622
+ return attrs.strokeColor || attrs.borderColor || theme.palette.background.nodeBorder || baseColor;
1623
+ }
1624
+
1625
+ export function resolveNodeElementStyle(
1626
+ theme: GraphTheme,
1627
+ zoomTier: GraphZoomTier,
1628
+ state: GraphNodeVisualState,
1629
+ attrs: NodeAttributes,
1630
+ label: string,
1631
+ cameraRatio = 1,
1632
+ ): ResolvedNodeStyle {
1633
+ const tierConfig = theme.zoomTiers[zoomTier];
1634
+ const stateConfig = theme.nodes.states[state];
1635
+ const nodeVariant = resolveNodeVariant(state, attrs);
1636
+ const variantConfig = theme.nodes.variants[nodeVariant];
1637
+ const entityShape = resolveEntityShape(attrs);
1638
+ const entityShapeConfig = theme.nodes.entityShapes[entityShape];
1639
+ const isCommunityGroup = Boolean(attrs.isCommunityGroup);
1640
+ const baseSize = Number(attrs.baseSize || attrs.size || 4);
1641
+ const labelPriority = Number(attrs.labelPriority ?? 0);
1642
+ const color = resolveNodeColor(theme, zoomTier, state, attrs, cameraRatio);
1643
+ const shellColor = resolveNodeShellColor(theme, zoomTier, state, attrs, cameraRatio);
1644
+ const sizeMultiplier = (state === "default" ? tierConfig.nodeScale : stateConfig.sizeMultiplier)
1645
+ * variantConfig.sizeMultiplier
1646
+ * (isCommunityGroup ? theme.grouped.style.nodeSizeScale : 1);
1647
+ const overviewPresence = zoomTier === "overview" ? 1 + getOverviewPresenceBoost(cameraRatio) * 1.2 : 1;
1648
+ const forceLabel = shouldForceNodeLabel(theme, zoomTier, state, attrs, labelPriority);
1649
+ const badgeKind = attrs.badgeKind || variantConfig.badgeKind;
1650
+ const forceVisibleState = theme.labels.forceVisibleStates.includes(state);
1651
+ const showBadge = Boolean(
1652
+ badgeKind
1653
+ && (forceVisibleState || (tierConfig.showBadges && zoomTierAtLeast(zoomTier, variantConfig.badgeVisibleFrom)))
1654
+ && state !== "muted"
1655
+ && state !== "inactive",
1656
+ );
1657
+ const ringSize = resolveNodeRingSize(theme, state, zoomTier);
1658
+ const ringColor = resolveNodeRingColor(theme, state, attrs);
1659
+ const showRing = ringSize > 0;
1660
+ const showHalo = state === "hovered" || state === "selected" || state === "path";
1661
+ const strokeBase = state === "muted" || state === "inactive"
1662
+ ? theme.nodes.strokeHierarchy[zoomTier].muted
1663
+ : forceVisibleState
1664
+ ? theme.nodes.strokeHierarchy[zoomTier].emphasis
1665
+ : theme.nodes.strokeHierarchy[zoomTier].base;
1666
+ const rawCoreScale = resolveNodeCoreScale(zoomTier, state, cameraRatio) * entityShapeConfig.coreScale;
1667
+ const shouldShowGlyphHint = zoomTier !== "overview"
1668
+ || state === "hovered"
1669
+ || state === "selected"
1670
+ || state === "path";
1671
+
1672
+ return {
1673
+ color,
1674
+ shellColor,
1675
+ coreScale: shouldShowGlyphHint ? clamp(0.06, rawCoreScale, 0.5) : 0,
1676
+ size: Math.max(baseSize * sizeMultiplier * overviewPresence, stateConfig.minSize, entityShapeConfig.minSize),
1677
+ forceLabel,
1678
+ label: forceLabel ? label : "",
1679
+ zIndex: forceLabel && stateConfig.zIndex === 0 ? 1 : stateConfig.zIndex,
1680
+ hidden: false,
1681
+ borderColor: resolveNodeBorderColor(theme, zoomTier, state, nodeVariant, attrs, color),
1682
+ borderSize: Math.max(
1683
+ 0.4,
1684
+ Number(attrs.borderSize ?? 0.85)
1685
+ + strokeBase
1686
+ + stateConfig.borderBoost
1687
+ + variantConfig.borderBoost
1688
+ + entityShapeConfig.borderBoost
1689
+ + (isCommunityGroup ? theme.grouped.style.nodeBorderBoost : 0)
1690
+ - 0.8,
1691
+ ),
1692
+ nodeVariant,
1693
+ entityShape,
1694
+ entityShapeKind: entityShapeConfig.shapeKind,
1695
+ entityAspectRatio: entityShapeConfig.aspectRatio,
1696
+ badgeKind,
1697
+ badgeCount: attrs.badgeCount,
1698
+ showBadge,
1699
+ showRing,
1700
+ ringColor,
1701
+ ringSize,
1702
+ showHalo,
1703
+ haloColor: attrs.haloColor || attrs.glowColor || withAlpha(
1704
+ color,
1705
+ (isCommunityGroup ? theme.grouped.style.glowAlpha : theme.overlays.hoverGlowAlpha) + variantConfig.haloBoost,
1706
+ ),
1707
+ };
1708
+ }
1709
+
1710
+ function resolveStraightEdgeType(
1711
+ theme: GraphTheme,
1712
+ zoomTier: GraphZoomTier,
1713
+ state: GraphEdgeVisualState,
1714
+ variant: GraphEdgeVariant,
1715
+ attrs: EdgeAttributes,
1716
+ viewMode: GraphViewMode,
1717
+ ): "line" | "arrow" {
1718
+ const variantConfig = theme.edges.variants[variant];
1719
+
1720
+ if (theme.edges.states[state].forceArrow || variantConfig.arrowPolicy === "always") {
1721
+ return "arrow";
1722
+ }
1723
+
1724
+ if ((viewMode === "full" || viewMode === "grouped") && state === "default") {
1725
+ return "line";
1726
+ }
1727
+
1728
+ if (viewMode === "full" && state === "backbone") {
1729
+ return "line";
1730
+ }
1731
+
1732
+ if (variantConfig.arrowPolicy === "contextual" && theme.zoomTiers[zoomTier].showContextualArrows) {
1733
+ return "arrow";
1734
+ }
1735
+
1736
+ if (state !== "default") {
1737
+ return (attrs.type as "line" | "arrow" | undefined) || variantConfig.baseType;
1738
+ }
1739
+
1740
+ return Number(attrs.visualPriority ?? 0) >= theme.zoomTiers[zoomTier].arrowPriorityThreshold && theme.zoomTiers[zoomTier].showContextualArrows
1741
+ ? "arrow"
1742
+ : "line";
1743
+ }
1744
+
1745
+ function resolveEdgeCurvature(
1746
+ theme: GraphTheme,
1747
+ state: GraphEdgeVisualState,
1748
+ edgeVariant: GraphEdgeVariant,
1749
+ attrs: EdgeAttributes,
1750
+ sourceId: string | undefined,
1751
+ targetId: string | undefined,
1752
+ ) {
1753
+ const variantConfig = theme.edges.variants[edgeVariant];
1754
+ const baseCurvature = edgeVariant === "line" && state === "selected"
1755
+ ? Math.max(variantConfig.curveStrength, 0.14)
1756
+ : variantConfig.curveStrength;
1757
+
1758
+ if (baseCurvature === 0) {
1759
+ return 0;
1760
+ }
1761
+
1762
+ if (typeof attrs.parallelCount === "number" && attrs.parallelCount > 1 && typeof attrs.parallelIndex === "number") {
1763
+ const center = (attrs.parallelCount - 1) / 2;
1764
+ return (attrs.parallelIndex - center) * baseCurvature;
1765
+ }
1766
+
1767
+ if ((edgeVariant === "bidirectionalCurve" || edgeVariant === "parallelCurve" || attrs.isBidirectional) && sourceId && targetId) {
1768
+ return sourceId.localeCompare(targetId) <= 0 ? baseCurvature : -baseCurvature;
1769
+ }
1770
+
1771
+ return baseCurvature;
1772
+ }
1773
+
1774
+ export function resolveEdgeElementStyle(
1775
+ theme: GraphTheme,
1776
+ zoomTier: GraphZoomTier,
1777
+ state: GraphEdgeVisualState,
1778
+ attrs: EdgeAttributes,
1779
+ sourceId?: string,
1780
+ targetId?: string,
1781
+ viewMode: GraphViewMode = "full",
1782
+ edgeId?: string,
1783
+ fullEdgeClass?: GraphFullEdgeClass,
1784
+ ): ResolvedEdgeStyle {
1785
+ const tierConfig = theme.zoomTiers[zoomTier];
1786
+ const stateConfig = theme.edges.states[state];
1787
+ const edgeVariant = resolveEdgeVariant(state, attrs);
1788
+ const variantConfig = theme.edges.variants[edgeVariant];
1789
+ const isCommunityBundle = attrs.bundleKind === "community";
1790
+ const baseSize = Number(attrs.baseSize || attrs.size || 0.9);
1791
+ const visualPriority = Number(attrs.visualPriority ?? 0);
1792
+ const isSmallGraphEdge = viewMode === "full" && attrs.isSmallGraph === true;
1793
+ const isFullBridgeEdge = viewMode === "full" && fullEdgeClass === "bridge";
1794
+ const isFullBackboneEdge = viewMode === "full" && fullEdgeClass === "backbone";
1795
+ const shouldCurveBridge = isFullBridgeEdge
1796
+ && visualPriority >= theme.edges.fullGraphStructure.bridgeCurvePriorityThreshold;
1797
+ const visibilityPolicy = resolveEdgeVisibilityPolicy(theme, viewMode, zoomTier, isCommunityBundle);
1798
+ const isContextEdge = isContextEdgeState(state);
1799
+ const isNonCriticalEdge = isNonCriticalEdgeVariant(edgeVariant);
1800
+ const belowPriorityThreshold = !isSmallGraphEdge && state === "default"
1801
+ && visualPriority < Math.max(tierConfig.edgePriorityThreshold, visibilityPolicy.defaultPriorityThreshold)
1802
+ && isNonCriticalEdge;
1803
+ const hiddenByMutedState = !isSmallGraphEdge
1804
+ && (state === "muted" || state === "inactive")
1805
+ && visibilityPolicy.hideMuted;
1806
+ const sampledOut = !isSmallGraphEdge && isNonCriticalEdge
1807
+ && (
1808
+ (state === "default" && !isContextEdge && shouldSampleOutBackgroundEdge(visibilityPolicy.backgroundSampleRate, visualPriority, edgeId, sourceId, targetId))
1809
+ || (
1810
+ state === "backbone"
1811
+ && zoomTier === "overview"
1812
+ && viewMode !== "full"
1813
+ && shouldSampleOutBackgroundEdge(0.15, visualPriority, edgeId, sourceId, targetId)
1814
+ )
1815
+ );
1816
+
1817
+ if (stateConfig.hide || belowPriorityThreshold || hiddenByMutedState || sampledOut) {
1818
+ return {
1819
+ hidden: true,
1820
+ zIndex: 0,
1821
+ edgeVariant,
1822
+ arrowVisibilityPolicy: variantConfig.arrowPolicy,
1823
+ curveStrength: variantConfig.curveStrength,
1824
+ curvature: 0,
1825
+ };
1826
+ }
1827
+
1828
+ const lodSizeMultiplier = isContextEdge ? 1 : visibilityPolicy.sizeMultiplier;
1829
+ const sizeMultiplier = (state === "default" ? tierConfig.edgeSizeScale : stateConfig.sizeMultiplier)
1830
+ * variantConfig.sizeMultiplier
1831
+ * lodSizeMultiplier;
1832
+ const straightType = resolveStraightEdgeType(theme, zoomTier, state, edgeVariant, attrs, viewMode);
1833
+ const useCurvedRenderer = tierConfig.showCurves
1834
+ && (
1835
+ edgeVariant === "pathSignal"
1836
+ || state === "selected"
1837
+ || shouldCurveBridge
1838
+ || edgeVariant === "bidirectionalCurve"
1839
+ || edgeVariant === "parallelCurve"
1840
+ );
1841
+ const curvature = shouldCurveBridge
1842
+ ? (sourceId && targetId && sourceId.localeCompare(targetId) > 0
1843
+ ? -theme.edges.fullGraphStructure.bridgeCurveStrength
1844
+ : theme.edges.fullGraphStructure.bridgeCurveStrength)
1845
+ : useCurvedRenderer
1846
+ ? resolveEdgeCurvature(theme, state, edgeVariant, attrs, sourceId, targetId)
1847
+ : 0;
1848
+ const baseColor = resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color, fullEdgeClass);
1849
+ const resolvedLodAlpha = resolveEdgeLodAlpha(theme, viewMode, zoomTier, state, attrs, isCommunityBundle, fullEdgeClass);
1850
+ const lodAlpha = isSmallGraphEdge
1851
+ ? Math.max(resolvedLodAlpha ?? 1, isContextEdge ? 0.62 : 0.46)
1852
+ : resolvedLodAlpha;
1853
+ const color = lodAlpha === null ? baseColor : withAlpha(baseColor, lodAlpha);
1854
+ const rawSize = Math.max(
1855
+ baseSize * sizeMultiplier * (isCommunityBundle ? theme.grouped.style.edgeSizeScale : 1),
1856
+ isSmallGraphEdge ? Math.max(stateConfig.minSize, 0.9) : stateConfig.minSize,
1857
+ );
1858
+
1859
+ const interactionMaxSize = (fullEdgeClass === "path" || state === "path")
1860
+ ? theme.interaction.pathEdgeMaxSize
1861
+ : (fullEdgeClass === "selected" || state === "selected")
1862
+ ? theme.interaction.selectedEdgeMaxSize
1863
+ : (fullEdgeClass === "local-context" || state === "hovered" || state === "neighbor")
1864
+ ? theme.interaction.localContextMaxSize
1865
+ : Number.POSITIVE_INFINITY;
1866
+
1867
+ const size = isFullBridgeEdge
1868
+ ? Math.min(rawSize, theme.edges.fullGraphStructure.bridgeMaxSize)
1869
+ : isFullBackboneEdge
1870
+ ? Math.min(rawSize, theme.edges.fullGraphStructure.backboneMaxSize)
1871
+ : Math.min(rawSize, interactionMaxSize);
1872
+
1873
+ return {
1874
+ hidden: false,
1875
+ type: useCurvedRenderer
1876
+ ? (straightType === "arrow" ? "curvedArrow" : "curve")
1877
+ : straightType,
1878
+ color: color,
1879
+ size,
1880
+ zIndex: stateConfig.zIndex,
1881
+ edgeVariant,
1882
+ arrowVisibilityPolicy: variantConfig.arrowPolicy,
1883
+ curveStrength: variantConfig.curveStrength,
1884
+ curvature,
1885
+ };
1886
+ }
1887
+
1888
+ function addNodeIfMissing(targetGraph: GraphRef, nodeId: string, attrs: NodeAttributes) {
1889
+ if (!targetGraph.hasNode(nodeId)) {
1890
+ targetGraph.addNode(nodeId, attrs);
1891
+ }
1892
+ }
1893
+
1894
+ function truncateGroupedLabel(label: string | null | undefined): string {
1895
+ const value = String(label || "").trim();
1896
+ if (value.length <= 22) {
1897
+ return value;
1898
+ }
1899
+ return `${value.slice(0, 21).trimEnd()}…`;
1900
+ }
1901
+
1902
+ function estimateGroupedRingCapacity(radius: number): number {
1903
+ const circumference = 2 * Math.PI * Math.max(radius, 1);
1904
+ return Math.max(6, Math.floor(circumference / GRAPH_THEME.grouped.initialLayout.minNodeSpacing));
1905
+ }
1906
+
1907
+ function getGroupedCommunityNodeSize(memberCount: number): number {
1908
+ return Math.max(16, 10 + Math.log2(memberCount + 1) * 4.6);
1909
+ }
1910
+
1911
+ function assignGroupedCommunityPositions(
1912
+ communities: Array<{
1913
+ communityId: number;
1914
+ memberCount: number;
1915
+ centralityScore: number;
1916
+ connectivityWeight: number;
1917
+ radius: number;
1918
+ }>,
1919
+ ): Map<number, {
1920
+ x: number;
1921
+ y: number;
1922
+ labelPriority: number;
1923
+ visualPriority: number;
1924
+ }> {
1925
+ const positioned = new Map<number, {
1926
+ x: number;
1927
+ y: number;
1928
+ labelPriority: number;
1929
+ visualPriority: number;
1930
+ }>();
1931
+ const ranked = communities
1932
+ .slice()
1933
+ .sort((left, right) => {
1934
+ const leftProminence = Math.log2(left.memberCount + 1) * 2.2 + Math.log2(left.connectivityWeight + 1) * 1.6 + left.centralityScore * 5;
1935
+ const rightProminence = Math.log2(right.memberCount + 1) * 2.2 + Math.log2(right.connectivityWeight + 1) * 1.6 + right.centralityScore * 5;
1936
+ if (rightProminence !== leftProminence) {
1937
+ return rightProminence - leftProminence;
1938
+ }
1939
+ return left.communityId - right.communityId;
1940
+ });
1941
+
1942
+ if (ranked.length === 0) {
1943
+ return positioned;
1944
+ }
1945
+
1946
+ const seeded = new Map<number, { x: number; y: number }>();
1947
+
1948
+ const primaryLabelCount = Math.min(GRAPH_THEME.grouped.initialLayout.primaryLabelCount, ranked.length);
1949
+ const normalizedVisual = (index: number) => Math.max(0.24, 1.24 - index * 0.055);
1950
+ const normalizedLabel = (index: number) => (index < primaryLabelCount ? Math.max(1.02, 1.42 - index * 0.05) : 0.56);
1951
+
1952
+ const centerPosition = {
1953
+ x: 0,
1954
+ y: 0,
1955
+ };
1956
+ seeded.set(ranked[0].communityId, centerPosition);
1957
+ positioned.set(ranked[0].communityId, {
1958
+ ...centerPosition,
1959
+ labelPriority: normalizedLabel(0),
1960
+ visualPriority: normalizedVisual(0),
1961
+ });
1962
+
1963
+ let cursor = 1;
1964
+ let ring = 1;
1965
+ while (cursor < ranked.length) {
1966
+ const radius = GRAPH_THEME.grouped.initialLayout.innerRadius + (ring - 1) * GRAPH_THEME.grouped.initialLayout.ringSpacing;
1967
+ const capacity = estimateGroupedRingCapacity(radius);
1968
+ const count = Math.min(capacity, ranked.length - cursor);
1969
+ const angleOffset = ((hashString(`community-ring:${ring}`) % 360) * Math.PI) / 180;
1970
+
1971
+ for (let index = 0; index < count; index += 1) {
1972
+ const entry = ranked[cursor + index];
1973
+ const angle = angleOffset + (Math.PI * 2 * index) / count;
1974
+ const seedPosition = {
1975
+ x: Number((radius * Math.cos(angle)).toFixed(3)),
1976
+ y: Number((radius * Math.sin(angle)).toFixed(3)),
1977
+ };
1978
+ seeded.set(entry.communityId, seedPosition);
1979
+ positioned.set(entry.communityId, {
1980
+ ...seedPosition,
1981
+ labelPriority: normalizedLabel(cursor + index),
1982
+ visualPriority: normalizedVisual(cursor + index),
1983
+ });
1984
+ }
1985
+
1986
+ cursor += count;
1987
+ ring += 1;
1988
+ }
1989
+
1990
+ for (let iteration = 0; iteration < GRAPH_THEME.grouped.initialLayout.overlapIterations; iteration += 1) {
1991
+ let moved = false;
1992
+
1993
+ for (let leftIndex = 0; leftIndex < ranked.length; leftIndex += 1) {
1994
+ for (let rightIndex = leftIndex + 1; rightIndex < ranked.length; rightIndex += 1) {
1995
+ const leftCommunity = ranked[leftIndex];
1996
+ const rightCommunity = ranked[rightIndex];
1997
+ const leftPosition = positioned.get(leftCommunity.communityId);
1998
+ const rightPosition = positioned.get(rightCommunity.communityId);
1999
+ if (!leftPosition || !rightPosition) {
2000
+ continue;
2001
+ }
2002
+
2003
+ let dx = rightPosition.x - leftPosition.x;
2004
+ let dy = rightPosition.y - leftPosition.y;
2005
+ let distance = Math.hypot(dx, dy);
2006
+ if (distance < 0.001) {
2007
+ const angle = ((hashString(`grouped-collision:${leftCommunity.communityId}:${rightCommunity.communityId}`) % 360) * Math.PI) / 180;
2008
+ dx = Math.cos(angle);
2009
+ dy = Math.sin(angle);
2010
+ distance = 1;
2011
+ }
2012
+
2013
+ const minimumDistance = leftCommunity.radius + rightCommunity.radius + GRAPH_THEME.grouped.initialLayout.nodePadding;
2014
+ if (distance >= minimumDistance) {
2015
+ continue;
2016
+ }
2017
+
2018
+ const overlap = minimumDistance - distance;
2019
+ const ux = dx / distance;
2020
+ const uy = dy / distance;
2021
+ const leftMobility = rightCommunity.radius / (leftCommunity.radius + rightCommunity.radius);
2022
+ const rightMobility = leftCommunity.radius / (leftCommunity.radius + rightCommunity.radius);
2023
+
2024
+ leftPosition.x -= ux * overlap * 0.52 * leftMobility;
2025
+ leftPosition.y -= uy * overlap * 0.52 * leftMobility;
2026
+ rightPosition.x += ux * overlap * 0.52 * rightMobility;
2027
+ rightPosition.y += uy * overlap * 0.52 * rightMobility;
2028
+ moved = true;
2029
+ }
2030
+ }
2031
+
2032
+ ranked.forEach((community) => {
2033
+ const position = positioned.get(community.communityId);
2034
+ const seedPosition = seeded.get(community.communityId);
2035
+ if (!position || !seedPosition) {
2036
+ return;
2037
+ }
2038
+ position.x += (seedPosition.x - position.x) * 0.08;
2039
+ position.y += (seedPosition.y - position.y) * 0.08;
2040
+ });
2041
+
2042
+ if (!moved) {
2043
+ break;
2044
+ }
2045
+ }
2046
+
2047
+ return positioned;
2048
+ }
2049
+
2050
+ function buildCollapsedNeighborhoodState(
2051
+ nodeId: string,
2052
+ activePath: string[],
2053
+ ): Pick<GraphDisplayStateSnapshot, "selectedVisibleNeighborIds" | "selectedCollapsedNeighborIds"> {
2054
+ if (!nodeId || !graph.hasNode(nodeId)) {
2055
+ return {
2056
+ selectedVisibleNeighborIds: [],
2057
+ selectedCollapsedNeighborIds: [],
2058
+ };
2059
+ }
2060
+
2061
+ const rankedNeighbors = rankNeighbors(nodeId);
2062
+ const forcedVisible = new Set(activePath.filter((candidateId) => candidateId !== nodeId && graph.hasNode(candidateId)));
2063
+ const visible = new Set<string>();
2064
+ rankedNeighbors.forEach((neighborId, index) => {
2065
+ if (index < COLLAPSE_VISIBLE_NEIGHBORS || forcedVisible.has(neighborId)) {
2066
+ visible.add(neighborId);
2067
+ }
2068
+ });
2069
+
2070
+ return {
2071
+ selectedVisibleNeighborIds: Array.from(visible),
2072
+ selectedCollapsedNeighborIds: rankedNeighbors.filter((neighborId) => !visible.has(neighborId)),
2073
+ };
2074
+ }
2075
+
2076
+ function createCollapsedNeighborhoodGraph(
2077
+ nodeId: string,
2078
+ activePath: string[],
2079
+ ): Graph<NodeAttributes, EdgeAttributes> {
2080
+ const collapsedState = buildCollapsedNeighborhoodState(nodeId, activePath);
2081
+ const hiddenNeighbors = new Set(collapsedState.selectedCollapsedNeighborIds);
2082
+ if (hiddenNeighbors.size === 0) {
2083
+ return graph.copy() as Graph<NodeAttributes, EdgeAttributes>;
2084
+ }
2085
+
2086
+ const collapsedGraph = graph.copy() as Graph<NodeAttributes, EdgeAttributes>;
2087
+ hiddenNeighbors.forEach((neighborId) => {
2088
+ if (!collapsedGraph.hasNode(neighborId)) {
2089
+ return;
2090
+ }
2091
+
2092
+ const incidentEdges = collapsedGraph.edges(neighborId).map((edgeId) => String(edgeId));
2093
+ incidentEdges.forEach((edgeId) => {
2094
+ if (!collapsedGraph.hasEdge(edgeId)) {
2095
+ return;
2096
+ }
2097
+ const [sourceId, targetId] = collapsedGraph.extremities(edgeId);
2098
+ const touchesSelectedPair =
2099
+ (sourceId === nodeId && targetId === neighborId)
2100
+ || (sourceId === neighborId && targetId === nodeId);
2101
+ if (touchesSelectedPair) {
2102
+ collapsedGraph.dropEdge(edgeId);
2103
+ }
2104
+ });
2105
+
2106
+ if (collapsedGraph.hasNode(neighborId) && collapsedGraph.degree(neighborId) === 0) {
2107
+ collapsedGraph.dropNode(neighborId);
2108
+ }
2109
+ });
2110
+
2111
+ return collapsedGraph;
2112
+ }
2113
+
2114
+ // Normalize an edge relationship type: empty string, null, and undefined all
2115
+ // fall back to the project-wide default used consistently across every
2116
+ // aggregation path. Keep this local — it exists only to guarantee that the
2117
+ // three code paths (single-entry, multi-entry, community-grouped) produce the
2118
+ // same semantics and do not diverge again.
2119
+ function normalizeEdgeType(value: string | null | undefined): string {
2120
+ return value || "related_to";
2121
+ }
2122
+
2123
+ function aggregateDisplayGraph(graphRef: GraphRef): Graph<NodeAttributes, EdgeAttributes> {
2124
+ const aggregated = new Graph<NodeAttributes, EdgeAttributes>({
2125
+ type: "directed",
2126
+ multi: true,
2127
+ allowSelfLoops: false,
2128
+ });
2129
+
2130
+ graphRef.forEachNode((nodeId, attrs) => {
2131
+ aggregated.addNode(nodeId, { ...(attrs as NodeAttributes) });
2132
+ });
2133
+
2134
+ const groupedEdges = new Map<string, Array<{ edgeId: string; attrs: EdgeAttributes }>>();
2135
+ graphRef.forEachEdge((edgeId, attrs, sourceId, targetId) => {
2136
+ const key = `${sourceId}→${targetId}`;
2137
+ const bucket = groupedEdges.get(key) ?? [];
2138
+ bucket.push({ edgeId: String(edgeId), attrs: attrs as EdgeAttributes });
2139
+ groupedEdges.set(key, bucket);
2140
+ });
2141
+
2142
+ groupedEdges.forEach((entries, key) => {
2143
+ const [sourceId, targetId] = key.split("→");
2144
+ if (entries.length === 1) {
2145
+ const [{ edgeId, attrs }] = entries;
2146
+ aggregated.mergeDirectedEdgeWithKey(edgeId, sourceId, targetId, {
2147
+ ...attrs,
2148
+ // #1009: normalize empty/null/undefined edgeType so Sigma's label
2149
+ // renderer never receives a blank string on the single-entry path.
2150
+ edgeType: normalizeEdgeType(attrs.edgeType),
2151
+ dominantEdgeType: normalizeEdgeType(attrs.dominantEdgeType ?? attrs.edgeType),
2152
+ rawEdgeIds: collectRawEdgeIds(attrs, edgeId),
2153
+ isAggregated: isAggregatedEdgeAttributes(attrs),
2154
+ aggregateCount: attrs.aggregateCount ?? collectRawEdgeIds(attrs, edgeId).length,
2155
+ representativeWeight: attrs.representativeWeight ?? Number(attrs.weight ?? 1),
2156
+ });
2157
+ return;
2158
+ }
2159
+
2160
+ const sorted = entries
2161
+ .slice()
2162
+ .sort((left, right) => {
2163
+ const weightDelta = Number(right.attrs.weight ?? 0) - Number(left.attrs.weight ?? 0);
2164
+ if (weightDelta !== 0) {
2165
+ return weightDelta;
2166
+ }
2167
+ const priorityDelta = Number(right.attrs.visualPriority ?? 0) - Number(left.attrs.visualPriority ?? 0);
2168
+ if (priorityDelta !== 0) {
2169
+ return priorityDelta;
2170
+ }
2171
+ return left.edgeId.localeCompare(right.edgeId);
2172
+ });
2173
+ const representative = sorted[0];
2174
+ const rawEdgeIds = entries.flatMap(({ edgeId, attrs }) => collectRawEdgeIds(attrs, edgeId));
2175
+ const typeCounts = new Map<string, number>();
2176
+ entries.forEach(({ attrs }) => {
2177
+ const edgeType = normalizeEdgeType(attrs.edgeType);
2178
+ typeCounts.set(edgeType, (typeCounts.get(edgeType) ?? 0) + 1);
2179
+ });
2180
+ const dominantEdgeType = [...typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0]
2181
+ ?? normalizeEdgeType(representative.attrs.edgeType);
2182
+ const reverseKey = `${targetId}→${sourceId}`;
2183
+ const isBidirectionalBundle = groupedEdges.has(reverseKey);
2184
+ const syntheticEdgeId = `${AGGREGATED_EDGE_PREFIX}${sourceId}::${targetId}`;
2185
+
2186
+ aggregated.mergeDirectedEdgeWithKey(syntheticEdgeId, sourceId, targetId, {
2187
+ ...representative.attrs,
2188
+ edgeId: syntheticEdgeId,
2189
+ familyId: syntheticEdgeId,
2190
+ sourceId,
2191
+ targetId,
2192
+ rawEdgeIds,
2193
+ isAggregated: true,
2194
+ aggregateCount: rawEdgeIds.length,
2195
+ dominantEdgeType: dominantEdgeType,
2196
+ representativeWeight: Number(representative.attrs.weight ?? 1),
2197
+ weight: Number(representative.attrs.weight ?? 1),
2198
+ edgeType: representative.attrs.edgeType || dominantEdgeType,
2199
+ parallelCount: rawEdgeIds.length,
2200
+ familySize: rawEdgeIds.length,
2201
+ bundleKind: isBidirectionalBundle ? "bidirectional" : "parallel",
2202
+ isBidirectional: isBidirectionalBundle,
2203
+ });
2204
+ });
2205
+
2206
+ return aggregated;
2207
+ }
2208
+
2209
+ function buildCommunityGroupedGraph(): GraphDisplayResult {
2210
+ const base = computeGraphAnalyticsBase(graph, {
2211
+ computeCommunities: true,
2212
+ computeCentrality: true,
2213
+ });
2214
+ const state = createEmptyDisplayState("", true);
2215
+ state.groupedViewAvailable = base.communitiesByNode.size > 0;
2216
+ if (base.communitiesByNode.size === 0) {
2217
+ state.groupedViewReason = { code: "communities-undetected" };
2218
+ return { graph: aggregateDisplayGraph(graph), state, meta: MIRRORED_DISPLAY_META };
2219
+ }
2220
+
2221
+ const grouped = new Graph<NodeAttributes, EdgeAttributes>({
2222
+ type: "directed",
2223
+ multi: true,
2224
+ allowSelfLoops: false,
2225
+ });
2226
+ const communityMembers = new Map<number, string[]>();
2227
+ graph.forEachNode((nodeId) => {
2228
+ const communityId = base.communitiesByNode.get(nodeId);
2229
+ if (communityId === undefined) {
2230
+ return;
2231
+ }
2232
+ const bucket = communityMembers.get(communityId) ?? [];
2233
+ bucket.push(nodeId);
2234
+ communityMembers.set(communityId, bucket);
2235
+ });
2236
+
2237
+ const communitySummaries = new Map<number, {
2238
+ memberIds: string[];
2239
+ rankedMembers: string[];
2240
+ anchorNodeId: string | null;
2241
+ anchorAttrs: NodeAttributes | null;
2242
+ dominantSemanticGroup: string;
2243
+ color: string;
2244
+ memberCount: number;
2245
+ centralityScore: number;
2246
+ }>();
2247
+
2248
+ communityMembers.forEach((memberIds, communityId) => {
2249
+ const rankedMembers = memberIds
2250
+ .slice()
2251
+ .sort((left, right) => {
2252
+ const leftScore = base.centralityByNode.get(left)?.score ?? 0;
2253
+ const rightScore = base.centralityByNode.get(right)?.score ?? 0;
2254
+ if (rightScore !== leftScore) {
2255
+ return rightScore - leftScore;
2256
+ }
2257
+ return left.localeCompare(right);
2258
+ });
2259
+ const anchorNodeId = rankedMembers[0] ?? null;
2260
+ const anchorAttrs = anchorNodeId ? (graph.getNodeAttributes(anchorNodeId) as NodeAttributes) : null;
2261
+ const semanticCounts = new Map<string, number>();
2262
+ memberIds.forEach((memberId) => {
2263
+ const memberAttrs = graph.getNodeAttributes(memberId) as NodeAttributes;
2264
+ const semanticGroup = String(memberAttrs.semanticGroup || memberAttrs.nodeType || "entity");
2265
+ semanticCounts.set(semanticGroup, (semanticCounts.get(semanticGroup) ?? 0) + 1);
2266
+ });
2267
+ const dominantSemanticGroup = [...semanticCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "entity";
2268
+ const color = anchorAttrs?.baseColor || anchorAttrs?.color || GRAPH_THEME.palette.semantic[communityId % GRAPH_THEME.palette.semantic.length];
2269
+ communitySummaries.set(communityId, {
2270
+ memberIds,
2271
+ rankedMembers,
2272
+ anchorNodeId,
2273
+ anchorAttrs,
2274
+ dominantSemanticGroup,
2275
+ color,
2276
+ memberCount: memberIds.length,
2277
+ centralityScore: anchorNodeId ? (base.centralityByNode.get(anchorNodeId)?.score ?? 0) : 0,
2278
+ });
2279
+ });
2280
+
2281
+ const groupedEdges = new Map<string, {
2282
+ sourceId: string;
2283
+ targetId: string;
2284
+ rawEdgeIds: string[];
2285
+ weight: number;
2286
+ typeCounts: Map<string, number>;
2287
+ }>();
2288
+
2289
+ graph.forEachEdge((edgeId, attrs, sourceId, targetId) => {
2290
+ const sourceCommunity = base.communitiesByNode.get(sourceId);
2291
+ const targetCommunity = base.communitiesByNode.get(targetId);
2292
+ if (sourceCommunity === undefined || targetCommunity === undefined || sourceCommunity === targetCommunity) {
2293
+ return;
2294
+ }
2295
+
2296
+ const groupedSourceId = `${COMMUNITY_NODE_PREFIX}${sourceCommunity}`;
2297
+ const groupedTargetId = `${COMMUNITY_NODE_PREFIX}${targetCommunity}`;
2298
+ const key = `${groupedSourceId}→${groupedTargetId}`;
2299
+ const bucket = groupedEdges.get(key) ?? {
2300
+ sourceId: groupedSourceId,
2301
+ targetId: groupedTargetId,
2302
+ rawEdgeIds: [],
2303
+ weight: 0,
2304
+ typeCounts: new Map<string, number>(),
2305
+ };
2306
+ bucket.rawEdgeIds.push(String(edgeId));
2307
+ bucket.weight = Math.max(bucket.weight, Number((attrs as EdgeAttributes).weight ?? 1));
2308
+ const edgeType = normalizeEdgeType((attrs as EdgeAttributes).edgeType);
2309
+ bucket.typeCounts.set(edgeType, (bucket.typeCounts.get(edgeType) ?? 0) + 1);
2310
+ groupedEdges.set(key, bucket);
2311
+ });
2312
+
2313
+ const connectivityByCommunity = new Map<number, number>();
2314
+ groupedEdges.forEach((bundle) => {
2315
+ const sourceCommunityId = Number(bundle.sourceId.replace(COMMUNITY_NODE_PREFIX, ""));
2316
+ const targetCommunityId = Number(bundle.targetId.replace(COMMUNITY_NODE_PREFIX, ""));
2317
+ const weight = bundle.rawEdgeIds.length + bundle.weight * 0.35;
2318
+ connectivityByCommunity.set(sourceCommunityId, (connectivityByCommunity.get(sourceCommunityId) ?? 0) + weight);
2319
+ connectivityByCommunity.set(targetCommunityId, (connectivityByCommunity.get(targetCommunityId) ?? 0) + weight);
2320
+ });
2321
+
2322
+ const groupedEdgeStrength = new Map<string, number>();
2323
+ const incidentEdgeStrengths = new Map<string, Array<{ key: string; strength: number }>>();
2324
+ let strongestGroupedEdge = 0;
2325
+ groupedEdges.forEach((bundle, key) => {
2326
+ const strength = bundle.rawEdgeIds.length + bundle.weight * 0.35;
2327
+ groupedEdgeStrength.set(key, strength);
2328
+ strongestGroupedEdge = Math.max(strongestGroupedEdge, strength);
2329
+
2330
+ const sourceBucket = incidentEdgeStrengths.get(bundle.sourceId) ?? [];
2331
+ sourceBucket.push({ key, strength });
2332
+ incidentEdgeStrengths.set(bundle.sourceId, sourceBucket);
2333
+
2334
+ const targetBucket = incidentEdgeStrengths.get(bundle.targetId) ?? [];
2335
+ targetBucket.push({ key, strength });
2336
+ incidentEdgeStrengths.set(bundle.targetId, targetBucket);
2337
+ });
2338
+
2339
+ const visibleGroupedEdgeKeys = new Set<string>();
2340
+ const groupedEdgeStrengthCutoff = Math.max(1.25, strongestGroupedEdge * GRAPH_THEME.grouped.style.edgeVisibilityRatio);
2341
+ groupedEdges.forEach((_bundle, key) => {
2342
+ const strength = groupedEdgeStrength.get(key) ?? 0;
2343
+ if (strength >= groupedEdgeStrengthCutoff) {
2344
+ visibleGroupedEdgeKeys.add(key);
2345
+ }
2346
+ });
2347
+ incidentEdgeStrengths.forEach((entries) => {
2348
+ entries
2349
+ .slice()
2350
+ .sort((left, right) => right.strength - left.strength)
2351
+ .slice(0, GRAPH_THEME.grouped.style.topIncidentEdges)
2352
+ .forEach(({ key }) => visibleGroupedEdgeKeys.add(key));
2353
+ });
2354
+ if (visibleGroupedEdgeKeys.size === 0 && groupedEdges.size > 0) {
2355
+ const strongestKey = Array.from(groupedEdges.keys()).sort(
2356
+ (left, right) => (groupedEdgeStrength.get(right) ?? 0) - (groupedEdgeStrength.get(left) ?? 0),
2357
+ )[0];
2358
+ if (strongestKey) {
2359
+ visibleGroupedEdgeKeys.add(strongestKey);
2360
+ }
2361
+ }
2362
+
2363
+ const groupedPositions = assignGroupedCommunityPositions(
2364
+ Array.from(communitySummaries.entries()).map(([communityId, summary]) => ({
2365
+ communityId,
2366
+ memberCount: summary.memberCount,
2367
+ centralityScore: summary.centralityScore,
2368
+ connectivityWeight: connectivityByCommunity.get(communityId) ?? 0,
2369
+ radius: getGroupedCommunityNodeSize(summary.memberCount) * GRAPH_THEME.grouped.style.nodeSizeScale * 0.92,
2370
+ })),
2371
+ );
2372
+
2373
+ communitySummaries.forEach((summary, communityId) => {
2374
+ const communityNodeId = `${COMMUNITY_NODE_PREFIX}${communityId}`;
2375
+ const position = groupedPositions.get(communityId) ?? {
2376
+ x: 0,
2377
+ y: 0,
2378
+ labelPriority: 0.56,
2379
+ visualPriority: 0.42,
2380
+ };
2381
+ const size = getGroupedCommunityNodeSize(summary.memberCount);
2382
+ const anchorLabel = summary.anchorAttrs?.label || summary.anchorNodeId || `Community ${communityId}`;
2383
+ addNodeIfMissing(grouped, communityNodeId, {
2384
+ label: truncateGroupedLabel(anchorLabel),
2385
+ content: summary.anchorAttrs?.content || anchorLabel,
2386
+ x: position.x,
2387
+ y: position.y,
2388
+ size,
2389
+ baseSize: size,
2390
+ color: summary.color,
2391
+ baseColor: summary.color,
2392
+ mutedColor: withAlpha(summary.color, 0.22),
2393
+ glowColor: withAlpha(summary.color, GRAPH_THEME.grouped.style.glowAlpha),
2394
+ borderColor: withAlpha(summary.color, 0.74),
2395
+ entityShape: "community",
2396
+ nodeType: "community",
2397
+ semanticGroup: summary.dominantSemanticGroup,
2398
+ labelVisibilityPolicy: position.labelPriority > 0.9 ? "priority" : "none",
2399
+ properties: {
2400
+ __communityGroup: {
2401
+ communityId: String(communityId),
2402
+ memberCount: summary.memberCount,
2403
+ memberNodeIds: summary.memberIds,
2404
+ sampleNodeIds: summary.rankedMembers.slice(0, GROUP_SAMPLE_MEMBERS),
2405
+ anchorNodeId: summary.anchorNodeId,
2406
+ anchorLabel,
2407
+ dominantSemanticGroup: summary.dominantSemanticGroup,
2408
+ color: summary.color,
2409
+ },
2410
+ },
2411
+ isCommunityGroup: true,
2412
+ communityId: String(communityId),
2413
+ memberCount: summary.memberCount,
2414
+ anchorNodeId: summary.anchorNodeId,
2415
+ labelPriority: position.labelPriority,
2416
+ visualPriority: position.visualPriority,
2417
+ });
2418
+ });
2419
+
2420
+ groupedEdges.forEach((bundle, key) => {
2421
+ if (!visibleGroupedEdgeKeys.has(key)) {
2422
+ return;
2423
+ }
2424
+ const dominantEdgeType = [...bundle.typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0]
2425
+ ?? "related_to";
2426
+ const reverseKey = `${bundle.targetId}→${bundle.sourceId}`;
2427
+ const syntheticEdgeId = `${AGGREGATED_EDGE_PREFIX}${key}`;
2428
+ const aggregateCount = bundle.rawEdgeIds.length;
2429
+ const baseSize = Math.max(0.9, 0.72 + Math.log2(aggregateCount + 1) * 0.2);
2430
+ grouped.mergeDirectedEdgeWithKey(syntheticEdgeId, bundle.sourceId, bundle.targetId, {
2431
+ edgeId: syntheticEdgeId,
2432
+ familyId: syntheticEdgeId,
2433
+ sourceId: bundle.sourceId,
2434
+ targetId: bundle.targetId,
2435
+ edgeType: dominantEdgeType,
2436
+ dominantEdgeType,
2437
+ weight: bundle.weight,
2438
+ representativeWeight: bundle.weight,
2439
+ properties: {},
2440
+ rawEdgeIds: bundle.rawEdgeIds,
2441
+ isAggregated: true,
2442
+ aggregateCount,
2443
+ familySize: aggregateCount,
2444
+ parallelCount: aggregateCount,
2445
+ isBidirectional: groupedEdges.has(reverseKey),
2446
+ bundleKind: "community",
2447
+ edgeVariant: "line",
2448
+ arrowVisibilityPolicy: "hidden",
2449
+ baseSize,
2450
+ size: baseSize,
2451
+ color: withAlpha("#9BB7D6", GRAPH_THEME.grouped.style.edgeAlpha),
2452
+ mutedColor: withAlpha("#9BB7D6", Math.max(0.14, GRAPH_THEME.grouped.style.edgeAlpha * 0.4)),
2453
+ visualPriority: Math.min(1.05, 0.38 + Math.log2(aggregateCount + 1) * 0.14),
2454
+ });
2455
+ });
2456
+
2457
+ const groupedValidationError = validateGroupedDisplayGraph(grouped);
2458
+ if (groupedValidationError) {
2459
+ state.groupedViewAvailable = false;
2460
+ state.groupedViewReason = groupedValidationError;
2461
+ return {
2462
+ graph: aggregateDisplayGraph(graph),
2463
+ state,
2464
+ meta: MIRRORED_DISPLAY_META,
2465
+ };
2466
+ }
2467
+
2468
+ return {
2469
+ graph: grouped,
2470
+ state: {
2471
+ aggregationEnabled: true,
2472
+ groupedViewAvailable: true,
2473
+ groupedViewReason: null,
2474
+ selectedRootNodeId: null,
2475
+ selectedVisibleNeighborIds: [],
2476
+ selectedCollapsedNeighborIds: [],
2477
+ selectedNodeKind: "none",
2478
+ canActivateFocused: false,
2479
+ resolvedFocusedNodeId: null,
2480
+ focusedUnavailableReason: null,
2481
+ },
2482
+ meta: OWNED_DISPLAY_META,
2483
+ };
2484
+ }
2485
+
2486
+ export function resolveDisplayStateSnapshot(
2487
+ selectedNodeId: string,
2488
+ activePath: string[],
2489
+ viewMode: GraphViewMode,
2490
+ options?: {
2491
+ aggregationEnabled?: boolean;
2492
+ collapsedNeighborhoodNodeIds?: Iterable<string>;
2493
+ groupedViewAvailable?: boolean;
2494
+ groupedViewReason?: GroupedViewUnavailableReason | null;
2495
+ selectedNodeKind?: GraphSelectedNodeKind;
2496
+ resolvedFocusedNodeId?: string | null;
2497
+ focusedUnavailableReason?: FocusedUnavailableReason | null;
2498
+ },
2499
+ ): GraphDisplayStateSnapshot {
2500
+ const aggregationEnabled = options?.aggregationEnabled ?? true;
2501
+ const collapsedNeighborhoodNodeIds = new Set(
2502
+ Array.from(options?.collapsedNeighborhoodNodeIds ?? []).filter((nodeId) => typeof nodeId === "string"),
2503
+ );
2504
+ const displayState = createEmptyDisplayState(selectedNodeId, aggregationEnabled);
2505
+ displayState.selectedNodeKind = options?.selectedNodeKind ?? (selectedNodeId ? "unavailable" : "none");
2506
+ displayState.resolvedFocusedNodeId = options?.resolvedFocusedNodeId ?? null;
2507
+ displayState.canActivateFocused = Boolean(displayState.resolvedFocusedNodeId);
2508
+ displayState.focusedUnavailableReason = displayState.canActivateFocused
2509
+ ? null
2510
+ : (options?.focusedUnavailableReason ?? displayState.focusedUnavailableReason);
2511
+ displayState.groupedViewAvailable = options?.groupedViewAvailable ?? computeGraphAnalyticsBase(graph, {
2512
+ computeCommunities: true,
2513
+ computeCentrality: false,
2514
+ }).communitiesByNode.size > 0;
2515
+ displayState.groupedViewReason = options?.groupedViewReason
2516
+ ?? (displayState.groupedViewAvailable ? null : { code: "communities-undetected" });
2517
+
2518
+ if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
2519
+ return displayState;
2520
+ }
2521
+
2522
+ const shouldCollapseNeighborhood = Boolean(
2523
+ viewMode !== "grouped"
2524
+ && collapsedNeighborhoodNodeIds.has(selectedNodeId),
2525
+ );
2526
+ const collapsedState = shouldCollapseNeighborhood
2527
+ ? buildCollapsedNeighborhoodState(selectedNodeId, activePath)
2528
+ : {
2529
+ selectedVisibleNeighborIds: rankNeighbors(selectedNodeId),
2530
+ selectedCollapsedNeighborIds: [],
2531
+ };
2532
+
2533
+ displayState.selectedRootNodeId = selectedNodeId;
2534
+ displayState.selectedVisibleNeighborIds = collapsedState.selectedVisibleNeighborIds;
2535
+ displayState.selectedCollapsedNeighborIds = collapsedState.selectedCollapsedNeighborIds;
2536
+ return displayState;
2537
+ }
2538
+
2539
+ export function createFocusedGraph(
2540
+ nodeId: string,
2541
+ activePath: string[],
2542
+ activePathEdgeIds: string[] = [],
2543
+ collapseNeighborhood = false,
2544
+ ): Graph<NodeAttributes, EdgeAttributes> {
2545
+ const focused = new Graph<NodeAttributes, EdgeAttributes>({
2546
+ type: "directed",
2547
+ multi: true,
2548
+ allowSelfLoops: false,
2549
+ });
2550
+
2551
+ const rankedNeighbors = rankNeighbors(nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
2552
+ const collapsedState = collapseNeighborhood
2553
+ ? buildCollapsedNeighborhoodState(nodeId, activePath)
2554
+ : {
2555
+ selectedVisibleNeighborIds: rankedNeighbors,
2556
+ selectedCollapsedNeighborIds: [],
2557
+ };
2558
+ const visibleNeighborIds = rankedNeighbors.filter((neighborId) => collapsedState.selectedVisibleNeighborIds.includes(neighborId));
2559
+ const focusIds = new Set<string>([nodeId, ...visibleNeighborIds]);
2560
+ const labelledNeighborIds = new Set(visibleNeighborIds.slice(0, FOCUS_PRIMARY_LABELS));
2561
+ const pathNodeIds = new Set(activePath);
2562
+ const pathEdgeIds = buildPathEdgeSet(graph, activePath, activePathEdgeIds);
2563
+
2564
+ const addNode = (id: string, attrs: NodeAttributes) => {
2565
+ if (!focused.hasNode(id)) {
2566
+ focused.addNode(id, attrs);
2567
+ }
2568
+ };
2569
+
2570
+ const selectedAttrs = graph.getNodeAttributes(nodeId) as NodeAttributes;
2571
+ const selectedState = resolveNodeElementStyle(GRAPH_THEME, "inspection", "selected", selectedAttrs, selectedAttrs.label);
2572
+ addNode(nodeId, {
2573
+ ...selectedAttrs,
2574
+ x: Number.isFinite(selectedAttrs.x) ? selectedAttrs.x : 0,
2575
+ y: Number.isFinite(selectedAttrs.y) ? selectedAttrs.y : 0,
2576
+ color: selectedState.color,
2577
+ size: Math.max(selectedState.size, 22),
2578
+ baseColor: selectedState.color,
2579
+ semanticBaseColor: getSemanticNodeColor(selectedAttrs),
2580
+ baseSize: Math.max(selectedState.size, 22),
2581
+ label: selectedState.label,
2582
+ });
2583
+
2584
+ visibleNeighborIds.forEach((neighborId, index) => {
2585
+ const baseAttrs = graph.getNodeAttributes(neighborId) as NodeAttributes;
2586
+ const ring = Math.floor(index / FOCUS_RING_CAPACITY);
2587
+ const ringIndex = index % FOCUS_RING_CAPACITY;
2588
+ const itemsInRing = Math.min(
2589
+ FOCUS_RING_CAPACITY,
2590
+ rankedNeighbors.length - ring * FOCUS_RING_CAPACITY,
2591
+ );
2592
+ const radius = FOCUS_RING_GAP * (ring + 1);
2593
+ const angle = (Math.PI * 2 * ringIndex) / itemsInRing - Math.PI / 2;
2594
+ const visualState: GraphNodeVisualState = pathNodeIds.has(neighborId)
2595
+ ? "path"
2596
+ : labelledNeighborIds.has(neighborId)
2597
+ ? "neighbor"
2598
+ : "default";
2599
+ const style = resolveNodeElementStyle(
2600
+ GRAPH_THEME,
2601
+ "inspection",
2602
+ visualState,
2603
+ {
2604
+ ...baseAttrs,
2605
+ labelPriority: labelledNeighborIds.has(neighborId) || pathNodeIds.has(neighborId)
2606
+ ? Math.max(Number(baseAttrs.labelPriority ?? 0), 1)
2607
+ : 0,
2608
+ },
2609
+ baseAttrs.label,
2610
+ );
2611
+
2612
+ addNode(neighborId, {
2613
+ ...baseAttrs,
2614
+ x: Number.isFinite(baseAttrs.x) ? baseAttrs.x : Math.cos(angle) * radius,
2615
+ y: Number.isFinite(baseAttrs.y) ? baseAttrs.y : Math.sin(angle) * radius,
2616
+ color: style.color,
2617
+ size: Math.max(style.size, 8.5),
2618
+ baseColor: style.color,
2619
+ semanticBaseColor: getSemanticNodeColor(baseAttrs),
2620
+ baseSize: Math.max(style.size, 8.5),
2621
+ label: style.label,
2622
+ });
2623
+ });
2624
+
2625
+ collectFocusEdgeIds(graph, focusIds).forEach((edgeId) => {
2626
+ if (!graph.hasEdge(edgeId)) {
2627
+ return;
2628
+ }
2629
+
2630
+ const [source, target] = graph.extremities(edgeId);
2631
+ if (!focusIds.has(source) || !focusIds.has(target) || source === target) {
2632
+ if (DEBUG_GRAPH_SCENE_STATE) {
2633
+ console.debug("[graphSceneState]", "focused-edge-skipped-invalid-endpoints", {
2634
+ edgeId,
2635
+ source,
2636
+ target,
2637
+ selectedNodeId: nodeId,
2638
+ });
2639
+ }
2640
+ return;
2641
+ }
2642
+
2643
+ const attrs = graph.getEdgeAttributes(edgeId) as EdgeAttributes;
2644
+ const state: GraphEdgeVisualState = pathEdgeIds.has(edgeId)
2645
+ ? "path"
2646
+ : source === nodeId || target === nodeId
2647
+ ? "selected"
2648
+ : "neighbor";
2649
+ const style = resolveEdgeElementStyle(GRAPH_THEME, "inspection", state, attrs, source, target, "focused", edgeId);
2650
+
2651
+ focused.mergeDirectedEdgeWithKey(edgeId, source, target, {
2652
+ ...attrs,
2653
+ type: style.type,
2654
+ size: style.size,
2655
+ color: style.color,
2656
+ baseSize: style.size,
2657
+ baseColor: style.color,
2658
+ curvature: style.curvature,
2659
+ });
2660
+ });
2661
+
2662
+ return aggregateDisplayGraph(focused);
2663
+ }
2664
+
2665
+ export function resolveDisplayGraph(
2666
+ selectedNodeId: string,
2667
+ activePath: string[],
2668
+ activePathEdgeIds: string[],
2669
+ viewMode: GraphViewMode,
2670
+ options?: {
2671
+ aggregationEnabled?: boolean;
2672
+ collapsedNeighborhoodNodeIds?: Iterable<string>;
2673
+ groupedViewAvailable?: boolean;
2674
+ },
2675
+ ): GraphDisplayResult {
2676
+ const aggregationEnabled = options?.aggregationEnabled ?? true;
2677
+ const collapsedNeighborhoodNodeIds = new Set(
2678
+ Array.from(options?.collapsedNeighborhoodNodeIds ?? []).filter((nodeId) => typeof nodeId === "string"),
2679
+ );
2680
+ const displayState = resolveDisplayStateSnapshot(selectedNodeId, activePath, viewMode, {
2681
+ aggregationEnabled,
2682
+ collapsedNeighborhoodNodeIds,
2683
+ groupedViewAvailable: options?.groupedViewAvailable,
2684
+ });
2685
+ const isFocusedView = viewMode === "focused" && Boolean(selectedNodeId) && graph.hasNode(selectedNodeId);
2686
+ const isGroupedView = viewMode === "grouped";
2687
+ const shouldCollapseNeighborhood = Boolean(selectedNodeId && collapsedNeighborhoodNodeIds.has(selectedNodeId));
2688
+
2689
+ if (isGroupedView) {
2690
+ const grouped = buildCommunityGroupedGraph();
2691
+ return {
2692
+ graph: grouped.graph,
2693
+ state: {
2694
+ ...grouped.state,
2695
+ selectedRootNodeId: displayState.selectedRootNodeId,
2696
+ selectedVisibleNeighborIds: displayState.selectedVisibleNeighborIds,
2697
+ selectedCollapsedNeighborIds: displayState.selectedCollapsedNeighborIds,
2698
+ groupedViewReason: grouped.state.groupedViewReason,
2699
+ selectedNodeKind: displayState.selectedNodeKind,
2700
+ canActivateFocused: displayState.canActivateFocused,
2701
+ resolvedFocusedNodeId: displayState.resolvedFocusedNodeId,
2702
+ focusedUnavailableReason: displayState.focusedUnavailableReason,
2703
+ },
2704
+ meta: grouped.meta,
2705
+ };
2706
+ }
2707
+
2708
+ if (isFocusedView && selectedNodeId) {
2709
+ return {
2710
+ graph: createFocusedGraph(selectedNodeId, activePath, activePathEdgeIds, shouldCollapseNeighborhood),
2711
+ state: displayState,
2712
+ meta: MIRRORED_DISPLAY_META,
2713
+ };
2714
+ }
2715
+
2716
+ const baseGraph = shouldCollapseNeighborhood && selectedNodeId
2717
+ ? createCollapsedNeighborhoodGraph(selectedNodeId, activePath)
2718
+ : graph;
2719
+
2720
+ return {
2721
+ graph: aggregationEnabled ? aggregateDisplayGraph(baseGraph) : baseGraph,
2722
+ state: displayState,
2723
+ meta: aggregationEnabled
2724
+ ? MIRRORED_DISPLAY_META
2725
+ : baseGraph === graph
2726
+ ? BASE_DISPLAY_META
2727
+ : MIRRORED_DISPLAY_META,
2728
+ };
2729
+ }
2730
+
2731
+ export function createInteractionState(
2732
+ hoveredNodeId: string | null,
2733
+ selectedNodeId: string,
2734
+ focusedNodeId: string,
2735
+ selectedEdgeId: string,
2736
+ activePath: string[],
2737
+ activePathEdgeIds: string[],
2738
+ viewMode: GraphViewMode,
2739
+ zoomTier: GraphZoomTier,
2740
+ isLayoutRunning: boolean,
2741
+ ): GraphInteractionState {
2742
+ return {
2743
+ hoveredNodeId,
2744
+ selectedNodeId,
2745
+ selectedEdgeId,
2746
+ focusedNodeId,
2747
+ activePath,
2748
+ activePathEdgeIds,
2749
+ viewMode,
2750
+ zoomTier,
2751
+ isLayoutRunning,
2752
+ };
2753
+ }