@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.
- package/LICENSE +21 -0
- package/README.md +1674 -0
- package/explorer/README.md +282 -0
- package/explorer/src/App.css +0 -0
- package/explorer/src/App.tsx +2035 -0
- package/explorer/src/ErrorBoundary.tsx +112 -0
- package/explorer/src/ExploreWorkspaceTabs.tsx +29 -0
- package/explorer/src/assets/hero.png +0 -0
- package/explorer/src/assets/react.svg +1 -0
- package/explorer/src/assets/vite.svg +1 -0
- package/explorer/src/explorerCapabilities.ts +24 -0
- package/explorer/src/index.css +71 -0
- package/explorer/src/main.tsx +7 -0
- package/explorer/src/store/edgePairKeys.d.ts +2 -0
- package/explorer/src/store/edgePairKeys.js +7 -0
- package/explorer/src/store/graphStore.ts +183 -0
- package/explorer/src/store/registryStore.ts +78 -0
- package/explorer/src/types.d.ts +16 -0
- package/explorer/src/ui/primitives.css +414 -0
- package/explorer/src/ui/primitives.tsx +170 -0
- package/explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx +279 -0
- package/explorer/src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx +121 -0
- package/explorer/src/workspaces/EnrichWorkspace/EntityResolutionTab.tsx +454 -0
- package/explorer/src/workspaces/EnrichWorkspace/RegistryTab.tsx +285 -0
- package/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx +2673 -0
- package/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx +771 -0
- package/explorer/src/workspaces/GraphWorkspace/GraphLoadingOverlay.tsx +410 -0
- package/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +3584 -0
- package/explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx +705 -0
- package/explorer/src/workspaces/GraphWorkspace/SigmaSceneAdapter.tsx +55 -0
- package/explorer/src/workspaces/GraphWorkspace/TimelinePanel.tsx +193 -0
- package/explorer/src/workspaces/GraphWorkspace/behaviors/clickSelectionBehavior.ts +29 -0
- package/explorer/src/workspaces/GraphWorkspace/behaviors/fitViewBehavior.ts +15 -0
- package/explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts +25 -0
- package/explorer/src/workspaces/GraphWorkspace/behaviors/hoverActivationBehavior.ts +15 -0
- package/explorer/src/workspaces/GraphWorkspace/behaviors/pathHighlightBehavior.ts +52 -0
- package/explorer/src/workspaces/GraphWorkspace/behaviors/searchFocusBehavior.ts +36 -0
- package/explorer/src/workspaces/GraphWorkspace/behaviors/types.ts +41 -0
- package/explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts +29 -0
- package/explorer/src/workspaces/GraphWorkspace/graphAnalytics.ts +779 -0
- package/explorer/src/workspaces/GraphWorkspace/graphColorLegend.ts +36 -0
- package/explorer/src/workspaces/GraphWorkspace/graphConfig.ts +102 -0
- package/explorer/src/workspaces/GraphWorkspace/graphEntityShape.ts +34 -0
- package/explorer/src/workspaces/GraphWorkspace/graphLoading.ts +84 -0
- package/explorer/src/workspaces/GraphWorkspace/graphSceneLayers.ts +775 -0
- package/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts +2753 -0
- package/explorer/src/workspaces/GraphWorkspace/graphStructureLayer.ts +335 -0
- package/explorer/src/workspaces/GraphWorkspace/graphTheme.ts +1078 -0
- package/explorer/src/workspaces/GraphWorkspace/graphViewCopy.ts +41 -0
- package/explorer/src/workspaces/GraphWorkspace/localGraphTransition.ts +9 -0
- package/explorer/src/workspaces/GraphWorkspace/markdownEditorState.ts +114 -0
- package/explorer/src/workspaces/GraphWorkspace/markdownResourceClient.ts +103 -0
- package/explorer/src/workspaces/GraphWorkspace/markdownUrlSafety.ts +29 -0
- package/explorer/src/workspaces/GraphWorkspace/nodeMarkdownSync.ts +81 -0
- package/explorer/src/workspaces/GraphWorkspace/pluginRegistryPredicates.ts +24 -0
- package/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPlugin.tsx +353 -0
- package/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPluginPhaseC.tsx +522 -0
- package/explorer/src/workspaces/GraphWorkspace/plugins/index.ts +16 -0
- package/explorer/src/workspaces/GraphWorkspace/plugins/legendPlugin.tsx +120 -0
- package/explorer/src/workspaces/GraphWorkspace/plugins/neighborhoodPanelPlugin.tsx +231 -0
- package/explorer/src/workspaces/GraphWorkspace/plugins/temporalOverlayPlugin.tsx +142 -0
- package/explorer/src/workspaces/GraphWorkspace/plugins/types.ts +106 -0
- package/explorer/src/workspaces/GraphWorkspace/realtimeGraphAttributes.ts +50 -0
- package/explorer/src/workspaces/GraphWorkspace/scene.ts +79 -0
- package/explorer/src/workspaces/GraphWorkspace/sigmaNativeRendering.ts +421 -0
- package/explorer/src/workspaces/GraphWorkspace/smallGraphLayout.ts +135 -0
- package/explorer/src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts +31 -0
- package/explorer/src/workspaces/GraphWorkspace/temporalScrubberBounds.ts +46 -0
- package/explorer/src/workspaces/GraphWorkspace/temporalSnapshotGuards.ts +113 -0
- package/explorer/src/workspaces/GraphWorkspace/types.ts +395 -0
- package/explorer/src/workspaces/GraphWorkspace/useLoadGraph.ts +746 -0
- package/explorer/src/workspaces/GraphWorkspace/useMarkdownEditor.ts +177 -0
- package/explorer/src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx +196 -0
- package/explorer/src/workspaces/LineageWorkspace/LineageDiagram.tsx +177 -0
- package/explorer/src/workspaces/ManageWorkspace/KGOverviewTab.tsx +291 -0
- package/explorer/src/workspaces/ManageWorkspace/OntologySummaryTab.tsx +346 -0
- package/explorer/src/workspaces/MemoryWorkspace.tsx +464 -0
- package/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx +437 -0
- package/explorer/src/workspaces/OntologyWorkspace/HealthTab.tsx +214 -0
- package/explorer/src/workspaces/OntologyWorkspace/OntologyEditor.tsx +770 -0
- package/explorer/src/workspaces/OntologyWorkspace/OntologyLoader.tsx +913 -0
- package/explorer/src/workspaces/OntologyWorkspace/OntologyManager.tsx +930 -0
- package/explorer/src/workspaces/OntologyWorkspace/OntologySearch.tsx +590 -0
- package/explorer/src/workspaces/OntologyWorkspace/ProposalReview.tsx +429 -0
- package/explorer/src/workspaces/OntologyWorkspace/SKOSVocabularyManager.tsx +696 -0
- package/explorer/src/workspaces/OntologyWorkspace/ShaclStudio.tsx +363 -0
- package/explorer/src/workspaces/OntologyWorkspace/VersionsTab.tsx +546 -0
- package/explorer/src/workspaces/OntologyWorkspace/api.ts +164 -0
- package/explorer/src/workspaces/OntologyWorkspace/index.tsx +101 -0
- package/explorer/src/workspaces/OntologyWorkspace/ontologyEditorModel.ts +87 -0
- package/explorer/src/workspaces/OntologyWorkspace/ontologyUrlState.ts +94 -0
- package/explorer/src/workspaces/OntologyWorkspace/types.ts +119 -0
- package/explorer/src/workspaces/ReasoningWorkspace.tsx +243 -0
- package/explorer/src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx +271 -0
- package/explorer/src/workspaces/VocabularyWorkspace/ConceptTree.tsx +97 -0
- package/explorer/src/workspaces/VocabularyWorkspace/ImportDropzone.tsx +95 -0
- package/explorer/src/workspaces/VocabularyWorkspace/PropertyPanel.tsx +116 -0
- package/explorer/src/workspaces/VocabularyWorkspace/Sidebar.tsx +112 -0
- package/explorer/src/workspaces/VocabularyWorkspace/VocabularyWorkspace.tsx +70 -0
- package/explorer/src/workspaces/VocabularyWorkspace/queries.ts +52 -0
- package/explorer/src/workspaces/VocabularyWorkspace/types.ts +24 -0
- package/package.json +31 -0
- package/plugins/.claude-plugin/README.md +262 -0
- package/plugins/.claude-plugin/marketplace.json +20 -0
- package/plugins/.claude-plugin/plugin.json +33 -0
- package/plugins/.cline-plugin/README.md +32 -0
- package/plugins/.cline-plugin/marketplace.json +17 -0
- package/plugins/.cline-plugin/plugin.json +35 -0
- package/plugins/.codex-plugin/marketplace.json +21 -0
- package/plugins/.codex-plugin/plugin.json +35 -0
- package/plugins/.continue-plugin/README.md +34 -0
- package/plugins/.continue-plugin/marketplace.json +17 -0
- package/plugins/.continue-plugin/plugin.json +33 -0
- package/plugins/.cursor-plugin/marketplace.json +18 -0
- package/plugins/.cursor-plugin/plugin.json +32 -0
- package/plugins/.openclaw-plugin/README.md +62 -0
- package/plugins/.openclaw-plugin/marketplace.json +18 -0
- package/plugins/.openclaw-plugin/plugin.json +46 -0
- package/plugins/.pi-plugin/README.md +35 -0
- package/plugins/.vscode-plugin/README.md +48 -0
- package/plugins/.vscode-plugin/marketplace.json +17 -0
- package/plugins/.vscode-plugin/plugin.json +35 -0
- package/plugins/.windsurf-plugin/README.md +39 -0
- package/plugins/.windsurf-plugin/marketplace.json +17 -0
- package/plugins/.windsurf-plugin/plugin.json +35 -0
- package/plugins/agents/decision-advisor.md +126 -0
- package/plugins/agents/explainability.md +129 -0
- package/plugins/agents/kg-assistant.md +73 -0
- package/plugins/hooks/hooks.json +12 -0
- package/plugins/skills/causal/SKILL.md +74 -0
- package/plugins/skills/change/SKILL.md +80 -0
- package/plugins/skills/decision/SKILL.md +197 -0
- package/plugins/skills/deduplicate/SKILL.md +41 -0
- package/plugins/skills/embed/SKILL.md +230 -0
- package/plugins/skills/explain/SKILL.md +44 -0
- package/plugins/skills/export/SKILL.md +67 -0
- package/plugins/skills/extract/SKILL.md +93 -0
- package/plugins/skills/ingest/SKILL.md +38 -0
- package/plugins/skills/ontology/SKILL.md +89 -0
- package/plugins/skills/policy/SKILL.md +66 -0
- package/plugins/skills/provenance/SKILL.md +70 -0
- package/plugins/skills/query/SKILL.md +84 -0
- package/plugins/skills/reason/SKILL.md +201 -0
- package/plugins/skills/temporal/SKILL.md +164 -0
- package/plugins/skills/validate/SKILL.md +228 -0
- 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
|
+
}
|