Files
semantica/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts
T

2740 lines
89 KiB
TypeScript

import Graph from "graphology";
import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
import {
clamp,
blendHex,
GRAPH_THEME,
type GraphArrowVisibilityPolicy,
type GraphBadgeKind,
type GraphEdgeVariant,
type GraphEdgeVisualState,
type GraphEntityShapeVariant,
type GraphLabelVisibilityPolicy,
type GraphNodeShapeVariant,
type GraphNodeVisualState,
type GraphTheme,
type GraphZoomTier,
hashString,
withAlpha,
zoomTierAtLeast,
} from "./graphTheme";
import { classifyEntityShape } from "./graphEntityShape";
import { computeGraphAnalyticsBase } from "./graphAnalytics";
import type {
GraphDistanceBucketCounts,
GraphDisplayMeta,
GraphDisplayStateSnapshot,
GraphDistanceVisualState,
GraphHeatmapRenderSnapshot,
GraphFullEdgeClass,
GraphInteractionState,
GraphSelectedNodeKind,
GraphViewMode,
} from "./types";
const MAX_FOCUS_NEIGHBORS = GRAPH_THEME.focus.maxNeighbors;
const FOCUS_RING_CAPACITY = GRAPH_THEME.focus.ringCapacity;
const FOCUS_RING_GAP = GRAPH_THEME.focus.ringGap;
const FOCUS_PRIMARY_LABELS = GRAPH_THEME.focus.primaryLabels;
const COLLAPSE_VISIBLE_NEIGHBORS = 8;
const GROUP_SAMPLE_MEMBERS = 8;
const AGGREGATED_EDGE_PREFIX = "__agg__:";
const COMMUNITY_NODE_PREFIX = "__community__:";
const DEBUG_GRAPH_SCENE_STATE = typeof import.meta !== "undefined" && import.meta.env?.DEV === true;
const HEATMAP_ONE_HOP_CAP = 120;
const HEATMAP_TWO_HOP_CAP = 650;
const HEATMAP_THREE_HOP_CAP = 900;
const _heatmapVisibleSetCache = new WeakMap<GraphDistanceVisualState, Set<string>>();
function getHeatmapVisibleSet(state: GraphDistanceVisualState): Set<string> {
let cached = _heatmapVisibleSetCache.get(state);
if (!cached) {
cached = new Set(state.heatmapVisibleNodeIds ?? []);
_heatmapVisibleSetCache.set(state, cached);
}
return cached;
}
type GraphRef = typeof graph | Graph<NodeAttributes, EdgeAttributes>;
export type GraphDisplayResult = {
graph: GraphRef;
state: GraphDisplayStateSnapshot;
meta: GraphDisplayMeta;
};
const BASE_DISPLAY_META: GraphDisplayMeta = {
layoutMode: "base",
positionSource: "store",
tracksStoreNodePositions: true,
hasSyntheticNodes: false,
};
const MIRRORED_DISPLAY_META: GraphDisplayMeta = {
layoutMode: "mirrored",
positionSource: "store",
tracksStoreNodePositions: true,
hasSyntheticNodes: false,
};
const OWNED_DISPLAY_META: GraphDisplayMeta = {
layoutMode: "owned",
positionSource: "display",
tracksStoreNodePositions: false,
hasSyntheticNodes: true,
};
function getOverviewPresenceBoost(cameraRatio: number) {
return clamp(0, Math.log2(Math.max(cameraRatio, 1)) / 1.85, 1);
}
export type GraphSigmaEdgeType = "line" | "arrow" | "curve" | "curvedArrow";
export type ResolvedNodeStyle = {
color: string;
shellColor: string;
coreScale: number;
size: number;
forceLabel: boolean;
label: string;
zIndex: number;
hidden: boolean;
borderColor: string;
borderSize: number;
nodeVariant: GraphNodeShapeVariant;
entityShape: GraphEntityShapeVariant;
entityShapeKind: number;
entityAspectRatio: number;
badgeKind?: GraphBadgeKind;
badgeCount?: number;
showBadge: boolean;
showRing: boolean;
ringColor?: string;
ringSize: number;
showHalo: boolean;
haloColor: string;
};
export type ResolvedEdgeStyle = {
hidden: boolean;
type?: GraphSigmaEdgeType;
color?: string;
size?: number;
zIndex: number;
edgeVariant: GraphEdgeVariant;
arrowVisibilityPolicy: GraphArrowVisibilityPolicy;
curveStrength: number;
curvature: number;
};
export function getDistanceBandColor(distance: number): string {
if (distance <= 0) return "#7CFF9B";
if (distance <= 1) return "#56D364";
if (distance <= 2) return "#58A6FF";
if (distance <= 3) return "#B0883A";
if (distance <= 6) return "#B0883A";
return "#FF7B72";
}
function getSemanticScoreColor(score: number): string {
if (score >= 0.7) return "#56D364";
if (score >= 0.4) return "#E3B341";
return "#FF7B72";
}
export function buildStructuralDistanceSnapshot(
graphRef: GraphRef,
anchorNodeId: string,
maxHops: number,
): Record<string, number> {
const distances: Record<string, number> = {};
if (!anchorNodeId || !graphRef.hasNode(anchorNodeId)) {
return distances;
}
const queue: Array<[string, number]> = [[anchorNodeId, 0]];
const visited = new Set<string>([anchorNodeId]);
distances[anchorNodeId] = 0;
while (queue.length > 0) {
const [nodeId, hop] = queue.shift()!;
if (hop >= maxHops) {
continue;
}
graphRef.neighbors(nodeId).forEach((neighborId) => {
const stableNeighborId = String(neighborId);
if (visited.has(stableNeighborId)) {
return;
}
visited.add(stableNeighborId);
distances[stableNeighborId] = hop + 1;
queue.push([stableNeighborId, hop + 1]);
});
}
return distances;
}
export function summarizeDistanceBuckets(
distances: Record<string, number>,
totalNodeCount: number,
): GraphDistanceBucketCounts {
const counts: GraphDistanceBucketCounts = {
anchor: 0,
oneHop: 0,
twoHop: 0,
threeHopPlus: 0,
outside: 0,
};
Object.values(distances).forEach((distance) => {
if (distance <= 0) {
counts.anchor += 1;
} else if (distance === 1) {
counts.oneHop += 1;
} else if (distance === 2) {
counts.twoHop += 1;
} else {
counts.threeHopPlus += 1;
}
});
const reachedCount = counts.anchor + counts.oneHop + counts.twoHop + counts.threeHopPlus;
counts.outside = Math.max(0, totalNodeCount - reachedCount);
return counts;
}
function createEmptyDistanceCounts(): GraphDistanceBucketCounts {
return {
anchor: 0,
oneHop: 0,
twoHop: 0,
threeHopPlus: 0,
outside: 0,
};
}
function getHeatmapRingCap(distance: number): number {
if (distance <= 0) return Number.POSITIVE_INFINITY;
if (distance === 1) return HEATMAP_ONE_HOP_CAP;
if (distance === 2) return HEATMAP_TWO_HOP_CAP;
return HEATMAP_THREE_HOP_CAP;
}
function getNodeHeatmapPriority(graphRef: GraphRef, nodeId: string): number {
if (!graphRef.hasNode(nodeId)) {
return 0;
}
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
return Number(attrs.visualPriority ?? 0) * 8 + Number(attrs.labelPriority ?? 0);
}
function buildPreviousRingEdgeScores(
graphRef: GraphRef,
distances: Record<string, number>,
): Map<string, number> {
const scores = new Map<string, number>();
graphRef.forEachEdge((edgeId, attrs, sourceId, targetId) => {
const source = String(sourceId);
const target = String(targetId);
const sourceDistance = distances[source];
const targetDistance = distances[target];
if (sourceDistance == null || targetDistance == null || Math.abs(sourceDistance - targetDistance) !== 1) {
return;
}
const fartherNodeId = sourceDistance > targetDistance ? source : target;
const edgeAttrs = attrs as EdgeAttributes;
const score = Number(edgeAttrs.visualPriority ?? 0) * 8
+ Number(edgeAttrs.weight ?? 0)
+ Number(edgeAttrs.relationshipStrength ?? 0) * 2
+ Number(edgeAttrs.representativeWeight ?? 0);
scores.set(fartherNodeId, Math.max(scores.get(fartherNodeId) ?? 0, score));
void edgeId;
});
return scores;
}
export function buildHeatmapRenderSnapshot(
graphRef: GraphRef,
anchorNodeId: string,
distances: Record<string, number>,
maxHops: number,
): GraphHeatmapRenderSnapshot {
const ringCounts = summarizeDistanceBuckets(distances, graphRef.order);
const renderedRingCounts = createEmptyDistanceCounts();
renderedRingCounts.outside = ringCounts.outside;
const visibleNodeIds = new Set<string>();
if (!anchorNodeId || !graphRef.hasNode(anchorNodeId)) {
return {
visibleNodeIds: [],
ringCounts,
renderedRingCounts,
saturationMode: "normal",
};
}
const previousRingEdgeScores = buildPreviousRingEdgeScores(graphRef, distances);
const rings = new Map<number, string[]>();
Object.entries(distances).forEach(([nodeId, distance]) => {
if (distance < 0 || distance > maxHops || distance > 3) {
return;
}
const ring = Math.max(0, Math.floor(distance));
rings.set(ring, [...(rings.get(ring) ?? []), nodeId]);
});
if (!rings.get(0)?.includes(anchorNodeId)) {
rings.set(0, [anchorNodeId, ...(rings.get(0) ?? [])]);
}
[0, 1, 2, 3].forEach((ring) => {
const cap = getHeatmapRingCap(ring);
const candidates = [...new Set(rings.get(ring) ?? [])]
.sort((left, right) => {
const edgeScoreDelta = (previousRingEdgeScores.get(right) ?? 0) - (previousRingEdgeScores.get(left) ?? 0);
if (edgeScoreDelta !== 0) return edgeScoreDelta;
const nodePriorityDelta = getNodeHeatmapPriority(graphRef, right) - getNodeHeatmapPriority(graphRef, left);
if (nodePriorityDelta !== 0) return nodePriorityDelta;
return hashString(left) - hashString(right);
})
.slice(0, cap);
candidates.forEach((nodeId) => visibleNodeIds.add(nodeId));
if (ring === 0) renderedRingCounts.anchor = candidates.length;
if (ring === 1) renderedRingCounts.oneHop = candidates.length;
if (ring === 2) renderedRingCounts.twoHop = candidates.length;
if (ring === 3) renderedRingCounts.threeHopPlus = candidates.length;
});
visibleNodeIds.add(anchorNodeId);
renderedRingCounts.anchor = Math.max(1, renderedRingCounts.anchor);
const isSampled = renderedRingCounts.oneHop < ringCounts.oneHop
|| renderedRingCounts.twoHop < ringCounts.twoHop
|| renderedRingCounts.threeHopPlus < ringCounts.threeHopPlus;
return {
visibleNodeIds: [...visibleNodeIds],
ringCounts,
renderedRingCounts,
saturationMode: isSampled ? "sampled" : "normal",
};
}
function isHeatmapSaturated(counts: GraphDistanceBucketCounts | undefined): boolean {
if (!counts) {
return false;
}
const total = counts.anchor + counts.oneHop + counts.twoHop + counts.threeHopPlus + counts.outside;
if (total <= 0) {
return false;
}
const broadRings = counts.twoHop + counts.threeHopPlus;
return counts.threeHopPlus > 1_500 || broadRings / total > 0.35;
}
function resolveHeatmapRingStyle(distance: number, isSaturated: boolean) {
if (distance <= 0) {
return {
alpha: 1,
shellAlpha: 0.92,
borderAlpha: 0.95,
sizeMultiplier: 1.28,
borderBoost: 1.35,
zIndex: 8,
};
}
if (distance === 1) {
return {
alpha: 0.9,
shellAlpha: 0.42,
borderAlpha: 0.78,
sizeMultiplier: 0.96,
borderBoost: 0.28,
zIndex: 5,
};
}
if (distance === 2) {
return {
alpha: isSaturated ? 0.46 : 0.62,
shellAlpha: isSaturated ? 0.1 : 0.2,
borderAlpha: isSaturated ? 0.36 : 0.52,
sizeMultiplier: isSaturated ? 0.54 : 0.7,
borderBoost: isSaturated ? -0.2 : 0.05,
zIndex: 2,
};
}
return {
alpha: isSaturated ? 0.16 : 0.34,
shellAlpha: isSaturated ? 0.03 : 0.1,
borderAlpha: isSaturated ? 0.16 : 0.32,
sizeMultiplier: isSaturated ? 0.3 : 0.48,
borderBoost: isSaturated ? -0.35 : -0.08,
zIndex: 1,
};
}
export function resolveDistanceNodeStyle(
theme: GraphTheme,
zoomTier: GraphZoomTier,
baseStyle: ResolvedNodeStyle,
distanceVisualState: GraphDistanceVisualState | undefined,
nodeId: string,
): Partial<ResolvedNodeStyle> {
if (!distanceVisualState || distanceVisualState.mode === "off" || distanceVisualState.status !== "ready") {
return {};
}
const distance = distanceVisualState.structuralDistances[nodeId];
const isAnchor = distanceVisualState.anchorNodeId === nodeId;
if (distanceVisualState.mode === "ego") {
if (distance == null) {
return {
color: withAlpha(theme.palette.overview.nodeMuted, 0.34),
shellColor: withAlpha(theme.palette.overview.nodeBorder, 0.16),
size: Math.max(0.75, baseStyle.size * 0.28),
borderColor: withAlpha(theme.palette.overview.nodeBorder, 0.18),
borderSize: Math.max(0.25, baseStyle.borderSize * 0.45),
label: "",
forceLabel: false,
zIndex: Math.min(baseStyle.zIndex, 0),
};
}
const ratio = clamp(0, distance / Math.max(1, distanceVisualState.maxHops), 1);
const bandColor = getDistanceBandColor(distance);
return {
color: withAlpha(bandColor, isAnchor ? 0.98 : 0.88 - ratio * 0.44),
shellColor: withAlpha(bandColor, isAnchor ? 0.92 : 0.54 - ratio * 0.24),
size: Math.max(baseStyle.size * (isAnchor ? 1.18 : 1 - ratio * 0.22), baseStyle.size * 0.66),
borderColor: isAnchor ? theme.nodes.selectedRing.color : withAlpha(bandColor, 0.72 - ratio * 0.24),
borderSize: baseStyle.borderSize + (isAnchor ? 1.1 : 0.28),
forceLabel: isAnchor || baseStyle.forceLabel,
label: isAnchor ? (distanceVisualState.anchorLabel ?? baseStyle.label) || baseStyle.label : baseStyle.label,
zIndex: isAnchor ? Math.max(baseStyle.zIndex, 4) : Math.max(baseStyle.zIndex, 1),
};
}
if (distanceVisualState.mode === "heatmap") {
const visibleSet = getHeatmapVisibleSet(distanceVisualState);
const isRenderedHeatmapNode = isAnchor || visibleSet.size === 0 || visibleSet.has(nodeId);
if (distance == null || !isRenderedHeatmapNode) {
return {
color: withAlpha(theme.palette.overview.nodeMuted, zoomTier === "overview" ? 0.055 : 0.09),
shellColor: withAlpha(theme.palette.overview.nodeBorder, 0.015),
borderColor: withAlpha(theme.palette.overview.nodeBorder, 0.035),
borderSize: Math.max(0.12, baseStyle.borderSize * 0.18),
size: Math.max(0.45, baseStyle.size * 0.2),
label: "",
forceLabel: false,
zIndex: Math.min(baseStyle.zIndex, 0),
coreScale: 0,
};
}
const bandColor = getDistanceBandColor(distance);
const ringStyle = resolveHeatmapRingStyle(distance, isHeatmapSaturated(distanceVisualState.distanceCounts));
const label = isAnchor ? (distanceVisualState.anchorLabel ?? baseStyle.label) || baseStyle.label : "";
return {
color: withAlpha(bandColor, ringStyle.alpha),
shellColor: withAlpha(bandColor, ringStyle.shellAlpha),
borderColor: isAnchor ? theme.nodes.selectedRing.color : withAlpha(bandColor, ringStyle.borderAlpha),
borderSize: Math.max(0.18, baseStyle.borderSize + ringStyle.borderBoost),
size: Math.max(0.7, baseStyle.size * ringStyle.sizeMultiplier),
forceLabel: isAnchor,
label,
zIndex: Math.max(isAnchor ? baseStyle.zIndex : 0, ringStyle.zIndex),
};
}
return {};
}
export function resolveDistanceEdgeStyle(
baseStyle: ResolvedEdgeStyle,
distanceVisualState: GraphDistanceVisualState | undefined,
sourceId: string,
targetId: string,
fullEdgeClass?: GraphFullEdgeClass,
): Partial<ResolvedEdgeStyle> {
if (!distanceVisualState || distanceVisualState.mode === "off" || distanceVisualState.status !== "ready") {
return {};
}
if (distanceVisualState.mode === "heatmap") {
if (fullEdgeClass === "path" || fullEdgeClass === "selected" || fullEdgeClass === "local-context") {
return {};
}
return {
hidden: true,
color: withAlpha("#1D2A35", 0.02),
size: Math.min(baseStyle.size ?? 0.35, 0.2),
zIndex: 0,
};
}
if (distanceVisualState.mode === "structural") {
const sourceDistance = distanceVisualState.structuralDistances[sourceId];
const targetDistance = distanceVisualState.structuralDistances[targetId];
const distance = Math.min(sourceDistance ?? Number.POSITIVE_INFINITY, targetDistance ?? Number.POSITIVE_INFINITY);
if (!Number.isFinite(distance) || distance > distanceVisualState.maxHops) {
return {};
}
const bandColor = getDistanceBandColor(distance);
return {
hidden: false,
color: withAlpha(bandColor, distance <= 1 ? 0.72 : distance <= 3 ? 0.48 : 0.32),
size: Math.max(baseStyle.size ?? 0.6, distance <= 1 ? 1.25 : distance <= 3 ? 0.95 : 0.7),
zIndex: Math.max(baseStyle.zIndex, distance <= 1 ? 5 : 3),
type: "line",
curvature: 0,
};
}
if (distanceVisualState.mode === "semantic") {
const anchorNodeId = distanceVisualState.anchorNodeId;
if (!anchorNodeId || (sourceId !== anchorNodeId && targetId !== anchorNodeId)) {
return {};
}
const otherNodeId = sourceId === anchorNodeId ? targetId : sourceId;
const score = distanceVisualState.semanticScores[otherNodeId];
if (score == null) {
return {};
}
const scoreColor = getSemanticScoreColor(score);
return {
hidden: false,
color: withAlpha(scoreColor, 0.34 + clamp(0, score, 1) * 0.42),
size: Math.max(baseStyle.size ?? 0.6, 0.8 + clamp(0, score, 1) * 0.75),
zIndex: Math.max(baseStyle.zIndex, 4),
type: "line",
curvature: 0,
};
}
return {};
}
function forEachDirectedEdgeBetween(
graphRef: GraphRef,
source: string,
target: string,
callback: (edgeId: string, attrs: EdgeAttributes) => void,
) {
graphRef.forEachDirectedEdge(source, target, (edgeId, attrs) => {
callback(String(edgeId), attrs as EdgeAttributes);
});
}
function collectDirectedEdgeIdsBetween(
graphRef: GraphRef,
source: string,
target: string,
): string[] {
const edgeIds: string[] = [];
forEachDirectedEdgeBetween(graphRef, source, target, (edgeId) => {
edgeIds.push(edgeId);
});
return edgeIds;
}
function isAggregatedEdgeAttributes(attrs: EdgeAttributes | undefined): boolean {
return Boolean(attrs?.isAggregated || (attrs?.rawEdgeIds?.length ?? 0) > 1);
}
function collectRawEdgeIds(attrs: EdgeAttributes | undefined, fallbackEdgeId?: string): string[] {
const rawEdgeIds = attrs?.rawEdgeIds?.map((edgeId) => String(edgeId)).filter(Boolean) ?? [];
if (rawEdgeIds.length > 0) {
return rawEdgeIds;
}
return fallbackEdgeId ? [String(fallbackEdgeId)] : [];
}
function createEmptyDisplayState(
selectedNodeId: string,
aggregationEnabled: boolean,
): GraphDisplayStateSnapshot {
return {
aggregationEnabled,
groupedViewAvailable: false,
groupedViewReason: null,
selectedRootNodeId: selectedNodeId || null,
selectedVisibleNeighborIds: [],
selectedCollapsedNeighborIds: [],
selectedNodeKind: selectedNodeId ? "unavailable" : "none",
canActivateFocused: false,
resolvedFocusedNodeId: null,
focusedUnavailableReason: selectedNodeId ? "Selected item is not available in the current graph." : null,
};
}
export function resolveGroupedDisplayNodeId(
displayGraph: GraphRef,
nodeId: string,
): string | null {
if (!nodeId) {
return null;
}
if (displayGraph.hasNode(nodeId)) {
return nodeId;
}
let resolvedNodeId: string | null = null;
displayGraph.forEachNode((candidateId, attrs) => {
if (resolvedNodeId) {
return;
}
const communityGroup = (attrs as NodeAttributes).properties?.__communityGroup as
| {
anchorNodeId?: string | null;
memberNodeIds?: string[];
sampleNodeIds?: string[];
}
| undefined;
if (!communityGroup) {
return;
}
if (communityGroup.anchorNodeId === nodeId) {
resolvedNodeId = candidateId;
return;
}
if (communityGroup.sampleNodeIds?.includes(nodeId) || communityGroup.memberNodeIds?.includes(nodeId)) {
resolvedNodeId = candidateId;
}
});
return resolvedNodeId;
}
export function checkGroupedViewAvailability(): { available: boolean; reason: string | null } {
const base = computeGraphAnalyticsBase(graph, {
computeCommunities: true,
computeCentrality: false,
});
const available = base.communitiesByNode.size > 0;
return {
available,
reason: available ? null : "Grouped view is unavailable until communities can be detected.",
};
}
function rankGroupedNeighbors(
displayGraph: GraphRef,
nodeId: string,
): string[] {
const resolvedNodeId = resolveGroupedDisplayNodeId(displayGraph, nodeId);
if (!resolvedNodeId) {
return [];
}
const scoredNeighbors = new Map<string, number>();
displayGraph.forEachEdge((_, attrs, source, target) => {
const sourceId = String(source);
const targetId = String(target);
if (sourceId !== resolvedNodeId && targetId !== resolvedNodeId) {
return;
}
const neighborId = sourceId === resolvedNodeId ? targetId : sourceId;
if (!neighborId || neighborId === resolvedNodeId || !displayGraph.hasNode(neighborId)) {
return;
}
const weightCandidate = Number(
(attrs as EdgeAttributes).aggregateCount
?? (attrs as EdgeAttributes).baseSize
?? (attrs as EdgeAttributes).size
?? (attrs as EdgeAttributes).weight
?? 1,
);
const weight = Number.isFinite(weightCandidate) && weightCandidate > 0 ? weightCandidate : 1;
scoredNeighbors.set(neighborId, (scoredNeighbors.get(neighborId) ?? 0) + weight);
});
return Array.from(scoredNeighbors.entries())
.sort((left, right) => {
if (right[1] !== left[1]) {
return right[1] - left[1];
}
return left[0].localeCompare(right[0]);
})
.map(([neighborId]) => neighborId);
}
export function resolveGroupedDisplayStateSnapshot(
displayGraph: GraphRef,
selectedNodeId: string,
options?: {
groupedViewAvailable?: boolean;
groupedViewReason?: string | null;
selectedNodeKind?: GraphSelectedNodeKind;
resolvedFocusedNodeId?: string | null;
focusedUnavailableReason?: string | null;
},
): GraphDisplayStateSnapshot {
const displayState = createEmptyDisplayState(selectedNodeId, true);
displayState.groupedViewAvailable = options?.groupedViewAvailable ?? true;
displayState.groupedViewReason = options?.groupedViewReason ?? null;
displayState.selectedNodeKind = options?.selectedNodeKind ?? (selectedNodeId ? "grouped" : "none");
displayState.resolvedFocusedNodeId = options?.resolvedFocusedNodeId ?? null;
displayState.canActivateFocused = Boolean(displayState.resolvedFocusedNodeId);
displayState.focusedUnavailableReason = displayState.canActivateFocused
? null
: (options?.focusedUnavailableReason ?? displayState.focusedUnavailableReason);
if (!selectedNodeId) {
return displayState;
}
const resolvedNodeId = resolveGroupedDisplayNodeId(displayGraph, selectedNodeId);
if (!resolvedNodeId) {
return displayState;
}
const rankedNeighbors = rankGroupedNeighbors(displayGraph, resolvedNodeId);
displayState.selectedRootNodeId = resolvedNodeId;
displayState.selectedVisibleNeighborIds = rankedNeighbors.slice(0, MAX_FOCUS_NEIGHBORS);
displayState.selectedCollapsedNeighborIds = rankedNeighbors.slice(MAX_FOCUS_NEIGHBORS);
return displayState;
}
function validateGroupedDisplayGraph(grouped: Graph<NodeAttributes, EdgeAttributes>): string | null {
if (grouped.order === 0) {
return "Grouped view is unavailable because no community nodes could be created.";
}
let invalidReason: string | null = null;
grouped.forEachNode((nodeId, attrs) => {
if (invalidReason) {
return;
}
const nodeAttrs = attrs as NodeAttributes;
if (!Number.isFinite(Number(nodeAttrs.x)) || !Number.isFinite(Number(nodeAttrs.y))) {
invalidReason = `Grouped node ${nodeId} has invalid coordinates.`;
}
});
if (invalidReason) {
return invalidReason;
}
grouped.forEachEdge((edgeId, _attrs, sourceId, targetId) => {
if (invalidReason) {
return;
}
if (!grouped.hasNode(sourceId) || !grouped.hasNode(targetId)) {
invalidReason = `Grouped edge ${edgeId} references a missing grouped node.`;
}
});
return invalidReason;
}
export function buildPathEdgeSet(
graphRef: GraphRef,
path: string[],
pathEdgeIds: string[] = [],
): Set<string> {
if (pathEdgeIds.length > 0) {
const requested = new Set(pathEdgeIds.map((edgeId) => String(edgeId)));
const matched = new Set<string>();
graphRef.forEachEdge((edgeId, attrs) => {
const stableEdgeId = String(edgeId);
if (requested.has(stableEdgeId)) {
matched.add(stableEdgeId);
return;
}
const rawEdgeIds = collectRawEdgeIds(attrs as EdgeAttributes, stableEdgeId);
if (rawEdgeIds.some((rawEdgeId) => requested.has(rawEdgeId))) {
matched.add(stableEdgeId);
}
});
return matched;
}
const edgeIds = new Set<string>();
for (let index = 0; index < path.length - 1; index += 1) {
collectDirectedEdgeIdsBetween(graphRef, path[index], path[index + 1]).forEach((edgeId) => edgeIds.add(edgeId));
}
return edgeIds;
}
export function buildEdgeEndpointSet(
graphRef: GraphRef,
...edgeIds: Array<string | null | undefined>
): Set<string> {
const nodeIds = new Set<string>();
edgeIds.forEach((edgeId) => {
if (!edgeId || !graphRef.hasEdge(edgeId)) {
return;
}
const [source, target] = graphRef.extremities(edgeId);
nodeIds.add(source);
nodeIds.add(target);
});
return nodeIds;
}
function collectFocusEdgeIds(
graphRef: GraphRef,
nodeIds: Set<string>,
): Set<string> {
const edgeIds = new Set<string>();
const ids = Array.from(nodeIds);
for (let sourceIndex = 0; sourceIndex < ids.length; sourceIndex += 1) {
const source = ids[sourceIndex];
for (let targetIndex = 0; targetIndex < ids.length; targetIndex += 1) {
const target = ids[targetIndex];
if (source === target) {
continue;
}
collectDirectedEdgeIdsBetween(graphRef, source, target).forEach((edgeId) => edgeIds.add(edgeId));
}
}
return edgeIds;
}
function collectImpactedNodeIds(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
interactionState: GraphInteractionState | null,
): Set<string> {
if (!interactionState) {
return new Set<string>();
}
const impacted = new Set<string>(interactionState.activePath);
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
buildFocusSetInGraph(graphRef, primaryNodeId).forEach((nodeId) => impacted.add(nodeId));
}
buildEdgeEndpointSet(graphRef, interactionState.selectedEdgeId)
.forEach((nodeId) => impacted.add(nodeId));
return impacted;
}
function collectImpactedEdgeKeys(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
interactionState: GraphInteractionState | null,
): Set<string> {
if (!interactionState) {
return new Set<string>();
}
const impacted = new Set<string>(buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds));
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
collectFocusEdgeIds(graphRef, buildFocusSetInGraph(graphRef, primaryNodeId)).forEach((edgeId) => impacted.add(edgeId));
}
if (interactionState.selectedEdgeId) {
impacted.add(interactionState.selectedEdgeId);
}
return impacted;
}
function resolveDisplayEdgeIds(
graphRef: GraphRef,
stableEdgeIds: Set<string>,
): string[] {
const displayEdgeIds = new Set<string>();
graphRef.forEachEdge((edgeId, attrs) => {
const stableEdgeId = String(edgeId);
if (stableEdgeIds.has(stableEdgeId)) {
displayEdgeIds.add(stableEdgeId);
return;
}
const rawEdgeIds = collectRawEdgeIds(attrs as EdgeAttributes, stableEdgeId);
if (rawEdgeIds.some((rawEdgeId) => stableEdgeIds.has(rawEdgeId))) {
displayEdgeIds.add(stableEdgeId);
}
});
return Array.from(displayEdgeIds);
}
export function collectInteractionRefreshTargets(
graphRef: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
previousState: GraphInteractionState | null,
nextState: GraphInteractionState,
): { nodes: string[]; edges: string[] } {
const nodeIds = new Set<string>();
const edgeKeys = new Set<string>();
collectImpactedNodeIds(graphRef, previousState).forEach((nodeId) => nodeIds.add(nodeId));
collectImpactedNodeIds(graphRef, nextState).forEach((nodeId) => nodeIds.add(nodeId));
collectImpactedEdgeKeys(graphRef, previousState).forEach((edgeId) => edgeKeys.add(edgeId));
collectImpactedEdgeKeys(graphRef, nextState).forEach((edgeId) => edgeKeys.add(edgeId));
return {
nodes: Array.from(nodeIds).filter((nodeId) => graphRef.hasNode(nodeId)),
edges: resolveDisplayEdgeIds(graphRef, edgeKeys),
};
}
function logGroupedGraphSkip(kind: "focus" | "neighbors", graphRef: GraphRef, nodeId: string) {
if (!DEBUG_GRAPH_SCENE_STATE || !nodeId.startsWith(COMMUNITY_NODE_PREFIX)) {
return;
}
console.debug("[graphSceneState]", `${kind}-skipped-missing-display-node`, {
nodeId,
order: graphRef.order,
size: graphRef.size,
});
}
export function getEdgeWeightBetweenInGraph(graphRef: GraphRef, source: string, target: string): number {
let weight = 0;
forEachDirectedEdgeBetween(graphRef, source, target, (_edgeId, attrs) => {
weight = Math.max(weight, Number(attrs?.weight ?? 0));
});
forEachDirectedEdgeBetween(graphRef, target, source, (_edgeId, attrs) => {
weight = Math.max(weight, Number(attrs?.weight ?? 0));
});
return weight;
}
export function getEdgeWeightBetween(source: string, target: string): number {
return getEdgeWeightBetweenInGraph(graph, source, target);
}
export function rankNeighborsInGraph(graphRef: GraphRef, nodeId: string): string[] {
if (!nodeId || !graphRef.hasNode(nodeId)) {
logGroupedGraphSkip("neighbors", graphRef, nodeId);
return [];
}
return graphRef
.neighbors(nodeId)
.map((neighborId) => ({
id: neighborId,
weight: getEdgeWeightBetweenInGraph(graphRef, nodeId, neighborId),
degree: graphRef.hasNode(neighborId) ? graphRef.degree(neighborId) : 0,
}))
.sort((left, right) => {
if (right.weight !== left.weight) {
return right.weight - left.weight;
}
if (right.degree !== left.degree) {
return right.degree - left.degree;
}
return left.id.localeCompare(right.id);
})
.map((item) => item.id);
}
export function rankNeighbors(nodeId: string): string[] {
return rankNeighborsInGraph(graph, nodeId);
}
export function buildFocusSetInGraph(graphRef: GraphRef, nodeId: string): Set<string> {
if (!nodeId || !graphRef.hasNode(nodeId)) {
logGroupedGraphSkip("focus", graphRef, nodeId);
return new Set<string>();
}
const ranked = rankNeighborsInGraph(graphRef, nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
return new Set<string>([nodeId, ...ranked]);
}
export function buildFocusSet(nodeId: string): Set<string> {
return buildFocusSetInGraph(graph, nodeId);
}
export function isEdgeInteractable(
graphRef: GraphRef,
interactionState: GraphInteractionState,
edgeId: string,
source: string,
target: string,
attrs: EdgeAttributes,
): boolean {
const pathEdgeIds = buildPathEdgeSet(graphRef, interactionState.activePath, interactionState.activePathEdgeIds);
if (pathEdgeIds.has(edgeId) || interactionState.selectedEdgeId === edgeId) {
return true;
}
if (attrs.isAggregated && (interactionState.viewMode === "grouped" || interactionState.zoomTier === "inspection")) {
return true;
}
const primaryNodeId = interactionState.selectedNodeId;
if (primaryNodeId && graphRef.hasNode(primaryNodeId)) {
if (source === primaryNodeId || target === primaryNodeId) {
return true;
}
const focusIds = buildFocusSetInGraph(graphRef, primaryNodeId);
return focusIds.has(source) && focusIds.has(target);
}
if (interactionState.zoomTier !== "inspection") {
return false;
}
return Number(attrs.visualPriority ?? 0) >= GRAPH_THEME.zoomTiers[interactionState.zoomTier].edgePriorityThreshold;
}
function resolveNodeColor(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphNodeVisualState,
attrs: NodeAttributes,
cameraRatio: number,
fallbackColor?: string,
) {
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
const entityShapeConfig = theme.nodes.entityShapes[resolveEntityShape(attrs)];
const overviewTint = state === "neighbor"
? theme.palette.overview.nodeTintMix + 0.09
: theme.palette.overview.nodeTintMix;
const overviewCore = blendHex(
theme.palette.overview.nodeCore,
semanticColor,
Math.min(0.74, theme.palette.overview.nodeCoreMix + overviewTint),
);
switch (theme.nodes.states[state].color) {
case "selected":
return theme.palette.accent.selected;
case "hovered":
return theme.palette.accent.hovered;
case "path":
return theme.palette.accent.path;
case "muted":
return zoomTier === "overview"
? withAlpha(theme.palette.overview.nodeMuted, 0.42)
: String(attrs.mutedColor || withAlpha(semanticColor, theme.nodes.mutedAlpha));
case "base":
default:
if (zoomTier === "overview") {
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
const boostedCore = blendHex(overviewCore, semanticColor, 0.3 + 0.26 * presenceBoost);
const overviewAlpha = Math.min(
0.98,
theme.palette.overview.nodeCoreAlpha + presenceBoost * 0.18,
);
return withAlpha(
boostedCore,
isCommunityGroup ? Math.min(overviewAlpha, theme.grouped.style.fillAlpha) : Math.min(overviewAlpha, entityShapeConfig.fillAlpha),
);
}
return withAlpha(semanticColor, isCommunityGroup ? theme.grouped.style.fillAlpha : entityShapeConfig.fillAlpha);
}
}
function resolveNodeShellColor(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphNodeVisualState,
attrs: NodeAttributes,
cameraRatio: number,
fallbackColor?: string,
) {
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
const entityShapeConfig = theme.nodes.entityShapes[resolveEntityShape(attrs)];
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
const overviewGlyph = blendHex(semanticColor, theme.ui.text.strong, 0.34 + presenceBoost * 0.12);
if (zoomTier !== "overview") {
return withAlpha(
blendHex(semanticColor, theme.ui.text.strong, state === "default" ? 0.26 : 0.42),
isCommunityGroup ? theme.grouped.style.shellAlpha : Math.min(0.72, entityShapeConfig.shellAlpha + 0.12),
);
}
if (state === "selected") {
return withAlpha(blendHex(theme.palette.accent.selected, theme.ui.text.strong, 0.24), 0.92);
}
if (state === "hovered") {
return withAlpha(blendHex(theme.palette.accent.hovered, theme.ui.text.strong, 0.22), 0.9);
}
if (state === "path") {
return withAlpha(blendHex(theme.palette.accent.path, theme.ui.text.strong, 0.22), 0.9);
}
if (state === "muted" || state === "inactive") {
return withAlpha(theme.palette.overview.nodeBorder, 0.16);
}
return withAlpha(
overviewGlyph,
isCommunityGroup ? theme.grouped.style.shellAlpha : Math.min(0.58, entityShapeConfig.shellAlpha + 0.08),
);
}
function resolveNodeCoreScale(
zoomTier: GraphZoomTier,
state: GraphNodeVisualState,
cameraRatio: number,
) {
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
if (zoomTier === "overview") {
switch (state) {
case "selected":
return 0.34 + presenceBoost * 0.08;
case "hovered":
return 0.32 + presenceBoost * 0.08;
case "path":
return 0.28 + presenceBoost * 0.07;
case "neighbor":
return 0.2 + presenceBoost * 0.06;
case "muted":
case "inactive":
return 0.08 + presenceBoost * 0.03;
case "default":
default:
return 0.16 + presenceBoost * 0.08;
}
}
switch (state) {
case "selected":
return 0.52;
case "hovered":
return 0.48;
case "path":
return 0.44;
case "neighbor":
return 0.3;
case "muted":
case "inactive":
return 0.1;
case "default":
default:
return zoomTier === "structure" ? 0.22 : 0.28;
}
}
function resolveEdgeColor(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphEdgeVisualState,
attrs: EdgeAttributes,
fallbackColor?: string,
fullEdgeClass?: GraphFullEdgeClass,
) {
const defaultInspectionColor = zoomTier === "overview"
? theme.palette.overview.edgeInspection
: theme.palette.muted.edgeInspection;
const baseColor = String(attrs.baseColor || fallbackColor || defaultInspectionColor);
if (fullEdgeClass === "bridge") {
return theme.palette.muted.edgeFocus;
}
switch (theme.edges.states[state].color) {
case "hover":
return theme.palette.accent.hovered;
case "path":
return theme.palette.accent.path;
case "backbone":
return zoomTier === "overview"
? theme.palette.overview.edgeBackbone
: theme.palette.muted.edgeFocus;
case "focus":
return theme.palette.muted.edgeFocus;
case "overview":
return theme.palette.muted.edgeOverview;
case "structure":
return zoomTier === "overview"
? theme.palette.overview.edgeStructure
: theme.palette.muted.edgeStructure;
case "inspection":
return zoomTier === "overview"
? theme.palette.overview.edgeInspection
: theme.palette.muted.edgeInspection;
case "muted":
return zoomTier === "overview"
? theme.palette.overview.edgeStructure
: theme.palette.muted.edgeOverview;
default:
return baseColor;
}
}
function resolveEdgeVisibilityPolicy(
theme: GraphTheme,
viewMode: GraphViewMode,
zoomTier: GraphZoomTier,
isCommunityBundle: boolean,
) {
const policyMode = isCommunityBundle ? "grouped" : viewMode;
return theme.edges.visibility[policyMode][zoomTier];
}
function isContextEdgeState(state: GraphEdgeVisualState): boolean {
return state === "path"
|| state === "selected"
|| state === "hovered"
|| state === "neighbor"
|| state === "backbone";
}
function isNonCriticalEdgeVariant(variant: GraphEdgeVariant): boolean {
return variant === "line"
|| variant === "directional"
|| variant === "parallelCurve"
|| variant === "bidirectionalCurve";
}
function shouldSampleOutBackgroundEdge(
sampleRate: number,
visualPriority: number,
edgeId?: string,
sourceId?: string,
targetId?: string,
): boolean {
if (sampleRate >= 1) {
return false;
}
const sampleKey = edgeId || `${sourceId ?? "?"}->${targetId ?? "?"}`;
const bucket = (hashString(sampleKey) % 10000) / 10000;
const priorityAdjustedSampleRate = Math.min(1, sampleRate + visualPriority * 0.08);
return bucket > priorityAdjustedSampleRate;
}
function resolveEdgeLodAlpha(
theme: GraphTheme,
viewMode: GraphViewMode,
zoomTier: GraphZoomTier,
state: GraphEdgeVisualState,
attrs: EdgeAttributes,
isCommunityBundle: boolean,
fullEdgeClass?: GraphFullEdgeClass,
): number | null {
const policy = resolveEdgeVisibilityPolicy(theme, viewMode, zoomTier, isCommunityBundle);
if (viewMode === "full") {
if (fullEdgeClass === "path" || state === "path") {
return theme.interaction.pathEdgeAlpha;
}
if (fullEdgeClass === "selected" || state === "selected") {
return theme.interaction.selectedEdgeAlpha;
}
if (fullEdgeClass === "local-context" || state === "hovered") {
return theme.interaction.localContextAlpha;
}
if (fullEdgeClass === "bridge") {
return theme.edges.fullGraphStructure.bridgeAlpha;
}
if (fullEdgeClass === "backbone") {
return theme.edges.fullGraphStructure.backboneAlpha;
}
if (state === "backbone") {
return theme.edges.fullGraphStructure.ambientBackboneAlpha;
}
if (state === "default" && zoomTier === "structure") {
return theme.edges.fullGraphStructure.structureEdgeAlpha;
}
if (state === "default" && zoomTier === "inspection") {
return theme.edges.fullGraphStructure.inspectionEdgeAlpha;
}
}
if (state === "path") {
return theme.interaction.pathEdgeAlpha;
}
if (state === "selected" || state === "hovered") {
return state === "selected" ? theme.interaction.selectedEdgeAlpha : theme.interaction.hoverContextAlpha;
}
if (state === "default") {
return policy.defaultAlpha;
}
if (state === "muted") {
return policy.mutedAlpha;
}
if (state === "inactive") {
return policy.inactiveAlpha;
}
if (state === "neighbor") {
if (attrs.bundleKind === "community") {
return Math.max(policy.neighborAlpha, theme.grouped.style.edgeAlpha);
}
return policy.neighborAlpha;
}
if (isCommunityBundle && !isContextEdgeState(state)) {
return theme.grouped.style.edgeAlpha;
}
return null;
}
function resolveNodeRingColor(
theme: GraphTheme,
state: GraphNodeVisualState,
attrs: NodeAttributes,
) {
if (state === "selected") {
return attrs.ringColor || theme.nodes.selectedRing.color;
}
if (state === "hovered") {
return theme.palette.accent.hovered;
}
if (state === "path") {
return theme.palette.accent.path;
}
return undefined;
}
function resolveNodeRingSize(
theme: GraphTheme,
state: GraphNodeVisualState,
zoomTier: GraphZoomTier,
) {
if (state === "selected" && zoomTierAtLeast(zoomTier, theme.nodes.selectedRing.visibleFrom)) {
return theme.nodes.selectedRing.nativeSize;
}
if (state === "hovered") {
return Math.max(1.45, theme.nodes.selectedRing.nativeSize - 0.35);
}
if (state === "path") {
return Math.max(1.2, theme.nodes.selectedRing.nativeSize - 0.55);
}
return 0;
}
export function resolveNodeVisualState(
nodeId: string,
zoomTier: GraphZoomTier,
hoveredNodeId: string | null,
selectedNodeId: string,
selectedEdgeId: string,
focusIds: Set<string>,
edgeEndpointIds: Set<string>,
pathNodeIds: Set<string>,
): GraphNodeVisualState {
if (hoveredNodeId && nodeId === hoveredNodeId) {
return "hovered";
}
if (selectedNodeId && nodeId === selectedNodeId) {
return "selected";
}
if (pathNodeIds.has(nodeId)) {
return "path";
}
if (focusIds.has(nodeId)) {
return "neighbor";
}
if (edgeEndpointIds.has(nodeId)) {
return "neighbor";
}
if (hoveredNodeId || selectedNodeId || selectedEdgeId || pathNodeIds.size > 0) {
if (zoomTier !== "inspection") {
return "default";
}
return "muted";
}
return "default";
}
export function resolveEdgeVisualState(
edgeId: string,
source: string,
target: string,
zoomTier: GraphZoomTier,
hoveredNodeId: string | null,
selectedNodeId: string,
selectedEdgeId: string,
focusIds: Set<string>,
pathEdgeIds: Set<string>,
highlightedIncidentEdgeIds: Set<string> = new Set(),
): GraphEdgeVisualState {
const primaryNodeId = hoveredNodeId || selectedNodeId;
if (pathEdgeIds.has(edgeId)) {
return "path";
}
if (selectedEdgeId && edgeId === selectedEdgeId) {
return "selected";
}
if (primaryNodeId && (source === primaryNodeId || target === primaryNodeId)) {
if (highlightedIncidentEdgeIds.has(edgeId)) {
return hoveredNodeId ? "hovered" : "selected";
}
return "muted";
}
if (zoomTier !== "overview" && focusIds.has(source) && focusIds.has(target)) {
return "neighbor";
}
if (hoveredNodeId || selectedNodeId || selectedEdgeId || pathEdgeIds.size > 0) {
return "muted";
}
if (zoomTier === "overview") {
return "inactive";
}
return "default";
}
function getNodeBridgeGroup(attrs?: NodeAttributes): string {
if (!attrs) {
return "";
}
return String(attrs.communityId || attrs.semanticGroup || attrs.nodeType || "");
}
function isCuratedBridgeEdge(sourceAttrs?: NodeAttributes, targetAttrs?: NodeAttributes): boolean {
const sourceGroup = getNodeBridgeGroup(sourceAttrs);
const targetGroup = getNodeBridgeGroup(targetAttrs);
return Boolean(sourceGroup && targetGroup && sourceGroup !== targetGroup);
}
export function classifyFullGraphEdge(
edgeId: string,
source: string,
target: string,
zoomTier: GraphZoomTier,
hoveredNodeId: string | null,
selectedNodeId: string,
selectedEdgeId: string,
focusIds: Set<string>,
pathEdgeIds: Set<string>,
highlightedIncidentEdgeIds: Set<string> = new Set(),
overviewBackboneEdgeIds: Set<string> = new Set(),
sourceAttrs?: NodeAttributes,
targetAttrs?: NodeAttributes,
): GraphFullEdgeClass {
const primaryNodeId = hoveredNodeId || selectedNodeId;
if (pathEdgeIds.has(edgeId)) {
return "path";
}
if (selectedEdgeId && edgeId === selectedEdgeId) {
return "selected";
}
if (highlightedIncidentEdgeIds.has(edgeId)) {
return "local-context";
}
if (primaryNodeId && (source === primaryNodeId || target === primaryNodeId)) {
return "muted";
}
if (overviewBackboneEdgeIds.has(edgeId)) {
return isCuratedBridgeEdge(sourceAttrs, targetAttrs) ? "bridge" : "backbone";
}
if (zoomTier === "overview") {
return "hidden";
}
if (focusIds.has(source) && focusIds.has(target)) {
return "muted";
}
return "muted";
}
export function mapFullEdgeClassToVisualState(
edgeClass: GraphFullEdgeClass,
options: { hoveredNodeId: string | null; hasActiveInteraction: boolean },
): GraphEdgeVisualState {
switch (edgeClass) {
case "path":
return "path";
case "selected":
return "selected";
case "local-context":
return options.hoveredNodeId ? "hovered" : "selected";
case "backbone":
return "backbone";
case "bridge":
return "backbone";
case "hidden":
return "inactive";
case "muted":
default:
return options.hasActiveInteraction ? "muted" : "default";
}
}
export function resolveNodeVariant(state: GraphNodeVisualState, attrs: NodeAttributes): GraphNodeShapeVariant {
if (state === "selected") {
return "selected";
}
return attrs.nodeShapeVariant || attrs.nodeVariant || "default";
}
export function resolveEntityShape(attrs: NodeAttributes): GraphEntityShapeVariant {
if (attrs.isCommunityGroup) {
return "community";
}
// Prefer the shape stamped at load time; fall back to classifyEntityShape for
// nodes created programmatically that bypassed useLoadGraph.
return attrs.entityShape
|| classifyEntityShape(
attrs.nodeType,
attrs.semanticGroup,
attrs.content,
attrs.properties as Record<string, unknown> | undefined,
);
}
export function resolveEdgeVariant(state: GraphEdgeVisualState, attrs: EdgeAttributes): GraphEdgeVariant {
if (state === "path") {
return "pathSignal";
}
if (attrs.bundleKind === "community") {
return "line";
}
if ((attrs.parallelCount ?? 1) > 1) {
return "parallelCurve";
}
if (attrs.edgeVariant) {
return attrs.edgeVariant;
}
if (attrs.isBidirectional) {
return "bidirectionalCurve";
}
if (attrs.arrowVisibilityPolicy === "contextual") {
return "directional";
}
return "line";
}
export function shouldForceNodeLabel(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphNodeVisualState,
attrs: NodeAttributes,
labelPriority: number,
): boolean {
const tierConfig = theme.zoomTiers[zoomTier];
const forceVisibleState = theme.labels.forceVisibleStates.includes(state);
const policy = attrs.labelVisibilityPolicy || "priority";
if (forceVisibleState || theme.nodes.states[state].forceLabel) {
return true;
}
switch (policy as GraphLabelVisibilityPolicy) {
case "always":
return true;
case "local":
return zoomTier !== "overview" && state !== "default" && state !== "muted" && state !== "inactive";
case "priority":
return labelPriority >= tierConfig.labelThreshold;
case "none":
default:
return false;
}
}
function resolveNodeBorderColor(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphNodeVisualState,
variant: GraphNodeShapeVariant,
attrs: NodeAttributes,
baseColor: string,
) {
if (state === "selected" || variant === "selected") {
return attrs.ringColor || theme.nodes.selectedRing.color;
}
if (state === "hovered") {
return theme.palette.accent.hovered;
}
if (state === "path") {
return theme.palette.accent.path;
}
if (state === "muted" || state === "inactive") {
return withAlpha(
attrs.strokeColor || attrs.borderColor || theme.palette.overview.nodeBorder || theme.palette.background.nodeBorder,
zoomTier === "overview" ? 0.26 : 0.7,
);
}
if (variant === "temporal") {
return theme.palette.accent.temporal;
}
if (variant === "provenance") {
return theme.palette.accent.provenance;
}
if (variant === "inferred") {
return theme.palette.accent.inferred;
}
if (zoomTier === "overview") {
return withAlpha(
blendHex(theme.palette.overview.nodeBorder, baseColor, state === "neighbor" ? 0.08 : 0.03),
state === "neighbor" ? 0.24 : 0.06,
);
}
return attrs.strokeColor || attrs.borderColor || theme.palette.background.nodeBorder || baseColor;
}
export function resolveNodeElementStyle(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphNodeVisualState,
attrs: NodeAttributes,
label: string,
cameraRatio = 1,
): ResolvedNodeStyle {
const tierConfig = theme.zoomTiers[zoomTier];
const stateConfig = theme.nodes.states[state];
const nodeVariant = resolveNodeVariant(state, attrs);
const variantConfig = theme.nodes.variants[nodeVariant];
const entityShape = resolveEntityShape(attrs);
const entityShapeConfig = theme.nodes.entityShapes[entityShape];
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
const baseSize = Number(attrs.baseSize || attrs.size || 4);
const labelPriority = Number(attrs.labelPriority ?? 0);
const color = resolveNodeColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
const shellColor = resolveNodeShellColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
const sizeMultiplier = (state === "default" ? tierConfig.nodeScale : stateConfig.sizeMultiplier)
* variantConfig.sizeMultiplier
* (isCommunityGroup ? theme.grouped.style.nodeSizeScale : 1);
const overviewPresence = zoomTier === "overview" ? 1 + getOverviewPresenceBoost(cameraRatio) * 1.2 : 1;
const forceLabel = shouldForceNodeLabel(theme, zoomTier, state, attrs, labelPriority);
const badgeKind = attrs.badgeKind || variantConfig.badgeKind;
const forceVisibleState = theme.labels.forceVisibleStates.includes(state);
const showBadge = Boolean(
badgeKind
&& (forceVisibleState || (tierConfig.showBadges && zoomTierAtLeast(zoomTier, variantConfig.badgeVisibleFrom)))
&& state !== "muted"
&& state !== "inactive",
);
const ringSize = resolveNodeRingSize(theme, state, zoomTier);
const ringColor = resolveNodeRingColor(theme, state, attrs);
const showRing = ringSize > 0;
const showHalo = state === "hovered" || state === "selected" || state === "path";
const strokeBase = state === "muted" || state === "inactive"
? theme.nodes.strokeHierarchy[zoomTier].muted
: forceVisibleState
? theme.nodes.strokeHierarchy[zoomTier].emphasis
: theme.nodes.strokeHierarchy[zoomTier].base;
const rawCoreScale = resolveNodeCoreScale(zoomTier, state, cameraRatio) * entityShapeConfig.coreScale;
const shouldShowGlyphHint = zoomTier !== "overview"
|| state === "hovered"
|| state === "selected"
|| state === "path";
return {
color,
shellColor,
coreScale: shouldShowGlyphHint ? clamp(0.06, rawCoreScale, 0.5) : 0,
size: Math.max(baseSize * sizeMultiplier * overviewPresence, stateConfig.minSize, entityShapeConfig.minSize),
forceLabel,
label: forceLabel ? label : "",
zIndex: forceLabel && stateConfig.zIndex === 0 ? 1 : stateConfig.zIndex,
hidden: false,
borderColor: resolveNodeBorderColor(theme, zoomTier, state, nodeVariant, attrs, color),
borderSize: Math.max(
0.4,
Number(attrs.borderSize ?? 0.85)
+ strokeBase
+ stateConfig.borderBoost
+ variantConfig.borderBoost
+ entityShapeConfig.borderBoost
+ (isCommunityGroup ? theme.grouped.style.nodeBorderBoost : 0)
- 0.8,
),
nodeVariant,
entityShape,
entityShapeKind: entityShapeConfig.shapeKind,
entityAspectRatio: entityShapeConfig.aspectRatio,
badgeKind,
badgeCount: attrs.badgeCount,
showBadge,
showRing,
ringColor,
ringSize,
showHalo,
haloColor: attrs.haloColor || attrs.glowColor || withAlpha(
color,
(isCommunityGroup ? theme.grouped.style.glowAlpha : theme.overlays.hoverGlowAlpha) + variantConfig.haloBoost,
),
};
}
function resolveStraightEdgeType(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphEdgeVisualState,
variant: GraphEdgeVariant,
attrs: EdgeAttributes,
viewMode: GraphViewMode,
): "line" | "arrow" {
const variantConfig = theme.edges.variants[variant];
if (theme.edges.states[state].forceArrow || variantConfig.arrowPolicy === "always") {
return "arrow";
}
if ((viewMode === "full" || viewMode === "grouped") && state === "default") {
return "line";
}
if (viewMode === "full" && state === "backbone") {
return "line";
}
if (variantConfig.arrowPolicy === "contextual" && theme.zoomTiers[zoomTier].showContextualArrows) {
return "arrow";
}
if (state !== "default") {
return (attrs.type as "line" | "arrow" | undefined) || variantConfig.baseType;
}
return Number(attrs.visualPriority ?? 0) >= theme.zoomTiers[zoomTier].arrowPriorityThreshold && theme.zoomTiers[zoomTier].showContextualArrows
? "arrow"
: "line";
}
function resolveEdgeCurvature(
theme: GraphTheme,
state: GraphEdgeVisualState,
edgeVariant: GraphEdgeVariant,
attrs: EdgeAttributes,
sourceId: string | undefined,
targetId: string | undefined,
) {
const variantConfig = theme.edges.variants[edgeVariant];
const baseCurvature = edgeVariant === "line" && state === "selected"
? Math.max(variantConfig.curveStrength, 0.14)
: variantConfig.curveStrength;
if (baseCurvature === 0) {
return 0;
}
if (typeof attrs.parallelCount === "number" && attrs.parallelCount > 1 && typeof attrs.parallelIndex === "number") {
const center = (attrs.parallelCount - 1) / 2;
return (attrs.parallelIndex - center) * baseCurvature;
}
if ((edgeVariant === "bidirectionalCurve" || edgeVariant === "parallelCurve" || attrs.isBidirectional) && sourceId && targetId) {
return sourceId.localeCompare(targetId) <= 0 ? baseCurvature : -baseCurvature;
}
return baseCurvature;
}
export function resolveEdgeElementStyle(
theme: GraphTheme,
zoomTier: GraphZoomTier,
state: GraphEdgeVisualState,
attrs: EdgeAttributes,
sourceId?: string,
targetId?: string,
viewMode: GraphViewMode = "full",
edgeId?: string,
fullEdgeClass?: GraphFullEdgeClass,
): ResolvedEdgeStyle {
const tierConfig = theme.zoomTiers[zoomTier];
const stateConfig = theme.edges.states[state];
const edgeVariant = resolveEdgeVariant(state, attrs);
const variantConfig = theme.edges.variants[edgeVariant];
const isCommunityBundle = attrs.bundleKind === "community";
const baseSize = Number(attrs.baseSize || attrs.size || 0.9);
const visualPriority = Number(attrs.visualPriority ?? 0);
const isFullBridgeEdge = viewMode === "full" && fullEdgeClass === "bridge";
const isFullBackboneEdge = viewMode === "full" && fullEdgeClass === "backbone";
const shouldCurveBridge = isFullBridgeEdge
&& visualPriority >= theme.edges.fullGraphStructure.bridgeCurvePriorityThreshold;
const visibilityPolicy = resolveEdgeVisibilityPolicy(theme, viewMode, zoomTier, isCommunityBundle);
const isContextEdge = isContextEdgeState(state);
const isNonCriticalEdge = isNonCriticalEdgeVariant(edgeVariant);
const belowPriorityThreshold = state === "default"
&& visualPriority < Math.max(tierConfig.edgePriorityThreshold, visibilityPolicy.defaultPriorityThreshold)
&& isNonCriticalEdge;
const hiddenByMutedState = (state === "muted" || state === "inactive") && visibilityPolicy.hideMuted;
const sampledOut = isNonCriticalEdge
&& (
(state === "default" && !isContextEdge && shouldSampleOutBackgroundEdge(visibilityPolicy.backgroundSampleRate, visualPriority, edgeId, sourceId, targetId))
|| (
state === "backbone"
&& zoomTier === "overview"
&& viewMode !== "full"
&& shouldSampleOutBackgroundEdge(0.15, visualPriority, edgeId, sourceId, targetId)
)
);
if (stateConfig.hide || belowPriorityThreshold || hiddenByMutedState || sampledOut) {
return {
hidden: true,
zIndex: 0,
edgeVariant,
arrowVisibilityPolicy: variantConfig.arrowPolicy,
curveStrength: variantConfig.curveStrength,
curvature: 0,
};
}
const lodSizeMultiplier = isContextEdge ? 1 : visibilityPolicy.sizeMultiplier;
const sizeMultiplier = (state === "default" ? tierConfig.edgeSizeScale : stateConfig.sizeMultiplier)
* variantConfig.sizeMultiplier
* lodSizeMultiplier;
const straightType = resolveStraightEdgeType(theme, zoomTier, state, edgeVariant, attrs, viewMode);
const useCurvedRenderer = tierConfig.showCurves
&& (
edgeVariant === "pathSignal"
|| state === "selected"
|| shouldCurveBridge
|| edgeVariant === "bidirectionalCurve"
|| edgeVariant === "parallelCurve"
);
const curvature = shouldCurveBridge
? (sourceId && targetId && sourceId.localeCompare(targetId) > 0
? -theme.edges.fullGraphStructure.bridgeCurveStrength
: theme.edges.fullGraphStructure.bridgeCurveStrength)
: useCurvedRenderer
? resolveEdgeCurvature(theme, state, edgeVariant, attrs, sourceId, targetId)
: 0;
const baseColor = resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color, fullEdgeClass);
const lodAlpha = resolveEdgeLodAlpha(theme, viewMode, zoomTier, state, attrs, isCommunityBundle, fullEdgeClass);
const color = lodAlpha === null ? baseColor : withAlpha(baseColor, lodAlpha);
const rawSize = Math.max(
baseSize * sizeMultiplier * (isCommunityBundle ? theme.grouped.style.edgeSizeScale : 1),
stateConfig.minSize,
);
const interactionMaxSize = (fullEdgeClass === "path" || state === "path")
? theme.interaction.pathEdgeMaxSize
: (fullEdgeClass === "selected" || state === "selected")
? theme.interaction.selectedEdgeMaxSize
: (fullEdgeClass === "local-context" || state === "hovered" || state === "neighbor")
? theme.interaction.localContextMaxSize
: Number.POSITIVE_INFINITY;
const size = isFullBridgeEdge
? Math.min(rawSize, theme.edges.fullGraphStructure.bridgeMaxSize)
: isFullBackboneEdge
? Math.min(rawSize, theme.edges.fullGraphStructure.backboneMaxSize)
: Math.min(rawSize, interactionMaxSize);
return {
hidden: false,
type: useCurvedRenderer
? (straightType === "arrow" ? "curvedArrow" : "curve")
: straightType,
color: color,
size,
zIndex: stateConfig.zIndex,
edgeVariant,
arrowVisibilityPolicy: variantConfig.arrowPolicy,
curveStrength: variantConfig.curveStrength,
curvature,
};
}
function addNodeIfMissing(targetGraph: GraphRef, nodeId: string, attrs: NodeAttributes) {
if (!targetGraph.hasNode(nodeId)) {
targetGraph.addNode(nodeId, attrs);
}
}
function truncateGroupedLabel(label: string | null | undefined): string {
const value = String(label || "").trim();
if (value.length <= 22) {
return value;
}
return `${value.slice(0, 21).trimEnd()}…`;
}
function estimateGroupedRingCapacity(radius: number): number {
const circumference = 2 * Math.PI * Math.max(radius, 1);
return Math.max(6, Math.floor(circumference / GRAPH_THEME.grouped.initialLayout.minNodeSpacing));
}
function getGroupedCommunityNodeSize(memberCount: number): number {
return Math.max(16, 10 + Math.log2(memberCount + 1) * 4.6);
}
function assignGroupedCommunityPositions(
communities: Array<{
communityId: number;
memberCount: number;
centralityScore: number;
connectivityWeight: number;
radius: number;
}>,
): Map<number, {
x: number;
y: number;
labelPriority: number;
visualPriority: number;
}> {
const positioned = new Map<number, {
x: number;
y: number;
labelPriority: number;
visualPriority: number;
}>();
const ranked = communities
.slice()
.sort((left, right) => {
const leftProminence = Math.log2(left.memberCount + 1) * 2.2 + Math.log2(left.connectivityWeight + 1) * 1.6 + left.centralityScore * 5;
const rightProminence = Math.log2(right.memberCount + 1) * 2.2 + Math.log2(right.connectivityWeight + 1) * 1.6 + right.centralityScore * 5;
if (rightProminence !== leftProminence) {
return rightProminence - leftProminence;
}
return left.communityId - right.communityId;
});
if (ranked.length === 0) {
return positioned;
}
const seeded = new Map<number, { x: number; y: number }>();
const primaryLabelCount = Math.min(GRAPH_THEME.grouped.initialLayout.primaryLabelCount, ranked.length);
const normalizedVisual = (index: number) => Math.max(0.24, 1.24 - index * 0.055);
const normalizedLabel = (index: number) => (index < primaryLabelCount ? Math.max(1.02, 1.42 - index * 0.05) : 0.56);
const centerPosition = {
x: 0,
y: 0,
};
seeded.set(ranked[0].communityId, centerPosition);
positioned.set(ranked[0].communityId, {
...centerPosition,
labelPriority: normalizedLabel(0),
visualPriority: normalizedVisual(0),
});
let cursor = 1;
let ring = 1;
while (cursor < ranked.length) {
const radius = GRAPH_THEME.grouped.initialLayout.innerRadius + (ring - 1) * GRAPH_THEME.grouped.initialLayout.ringSpacing;
const capacity = estimateGroupedRingCapacity(radius);
const count = Math.min(capacity, ranked.length - cursor);
const angleOffset = ((hashString(`community-ring:${ring}`) % 360) * Math.PI) / 180;
for (let index = 0; index < count; index += 1) {
const entry = ranked[cursor + index];
const angle = angleOffset + (Math.PI * 2 * index) / count;
const seedPosition = {
x: Number((radius * Math.cos(angle)).toFixed(3)),
y: Number((radius * Math.sin(angle)).toFixed(3)),
};
seeded.set(entry.communityId, seedPosition);
positioned.set(entry.communityId, {
...seedPosition,
labelPriority: normalizedLabel(cursor + index),
visualPriority: normalizedVisual(cursor + index),
});
}
cursor += count;
ring += 1;
}
for (let iteration = 0; iteration < GRAPH_THEME.grouped.initialLayout.overlapIterations; iteration += 1) {
let moved = false;
for (let leftIndex = 0; leftIndex < ranked.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < ranked.length; rightIndex += 1) {
const leftCommunity = ranked[leftIndex];
const rightCommunity = ranked[rightIndex];
const leftPosition = positioned.get(leftCommunity.communityId);
const rightPosition = positioned.get(rightCommunity.communityId);
if (!leftPosition || !rightPosition) {
continue;
}
let dx = rightPosition.x - leftPosition.x;
let dy = rightPosition.y - leftPosition.y;
let distance = Math.hypot(dx, dy);
if (distance < 0.001) {
const angle = ((hashString(`grouped-collision:${leftCommunity.communityId}:${rightCommunity.communityId}`) % 360) * Math.PI) / 180;
dx = Math.cos(angle);
dy = Math.sin(angle);
distance = 1;
}
const minimumDistance = leftCommunity.radius + rightCommunity.radius + GRAPH_THEME.grouped.initialLayout.nodePadding;
if (distance >= minimumDistance) {
continue;
}
const overlap = minimumDistance - distance;
const ux = dx / distance;
const uy = dy / distance;
const leftMobility = rightCommunity.radius / (leftCommunity.radius + rightCommunity.radius);
const rightMobility = leftCommunity.radius / (leftCommunity.radius + rightCommunity.radius);
leftPosition.x -= ux * overlap * 0.52 * leftMobility;
leftPosition.y -= uy * overlap * 0.52 * leftMobility;
rightPosition.x += ux * overlap * 0.52 * rightMobility;
rightPosition.y += uy * overlap * 0.52 * rightMobility;
moved = true;
}
}
ranked.forEach((community) => {
const position = positioned.get(community.communityId);
const seedPosition = seeded.get(community.communityId);
if (!position || !seedPosition) {
return;
}
position.x += (seedPosition.x - position.x) * 0.08;
position.y += (seedPosition.y - position.y) * 0.08;
});
if (!moved) {
break;
}
}
return positioned;
}
function buildCollapsedNeighborhoodState(
nodeId: string,
activePath: string[],
): Pick<GraphDisplayStateSnapshot, "selectedVisibleNeighborIds" | "selectedCollapsedNeighborIds"> {
if (!nodeId || !graph.hasNode(nodeId)) {
return {
selectedVisibleNeighborIds: [],
selectedCollapsedNeighborIds: [],
};
}
const rankedNeighbors = rankNeighbors(nodeId);
const forcedVisible = new Set(activePath.filter((candidateId) => candidateId !== nodeId && graph.hasNode(candidateId)));
const visible = new Set<string>();
rankedNeighbors.forEach((neighborId, index) => {
if (index < COLLAPSE_VISIBLE_NEIGHBORS || forcedVisible.has(neighborId)) {
visible.add(neighborId);
}
});
return {
selectedVisibleNeighborIds: Array.from(visible),
selectedCollapsedNeighborIds: rankedNeighbors.filter((neighborId) => !visible.has(neighborId)),
};
}
function createCollapsedNeighborhoodGraph(
nodeId: string,
activePath: string[],
): Graph<NodeAttributes, EdgeAttributes> {
const collapsedState = buildCollapsedNeighborhoodState(nodeId, activePath);
const hiddenNeighbors = new Set(collapsedState.selectedCollapsedNeighborIds);
if (hiddenNeighbors.size === 0) {
return graph.copy() as Graph<NodeAttributes, EdgeAttributes>;
}
const collapsedGraph = graph.copy() as Graph<NodeAttributes, EdgeAttributes>;
hiddenNeighbors.forEach((neighborId) => {
if (!collapsedGraph.hasNode(neighborId)) {
return;
}
const incidentEdges = collapsedGraph.edges(neighborId).map((edgeId) => String(edgeId));
incidentEdges.forEach((edgeId) => {
if (!collapsedGraph.hasEdge(edgeId)) {
return;
}
const [sourceId, targetId] = collapsedGraph.extremities(edgeId);
const touchesSelectedPair =
(sourceId === nodeId && targetId === neighborId)
|| (sourceId === neighborId && targetId === nodeId);
if (touchesSelectedPair) {
collapsedGraph.dropEdge(edgeId);
}
});
if (collapsedGraph.hasNode(neighborId) && collapsedGraph.degree(neighborId) === 0) {
collapsedGraph.dropNode(neighborId);
}
});
return collapsedGraph;
}
// Normalize an edge relationship type: empty string, null, and undefined all
// fall back to the project-wide default used consistently across every
// aggregation path. Keep this local — it exists only to guarantee that the
// three code paths (single-entry, multi-entry, community-grouped) produce the
// same semantics and do not diverge again.
function normalizeEdgeType(value: string | null | undefined): string {
return value || "related_to";
}
function aggregateDisplayGraph(graphRef: GraphRef): Graph<NodeAttributes, EdgeAttributes> {
const aggregated = new Graph<NodeAttributes, EdgeAttributes>({
type: "directed",
multi: true,
allowSelfLoops: false,
});
graphRef.forEachNode((nodeId, attrs) => {
aggregated.addNode(nodeId, { ...(attrs as NodeAttributes) });
});
const groupedEdges = new Map<string, Array<{ edgeId: string; attrs: EdgeAttributes }>>();
graphRef.forEachEdge((edgeId, attrs, sourceId, targetId) => {
const key = `${sourceId}${targetId}`;
const bucket = groupedEdges.get(key) ?? [];
bucket.push({ edgeId: String(edgeId), attrs: attrs as EdgeAttributes });
groupedEdges.set(key, bucket);
});
groupedEdges.forEach((entries, key) => {
const [sourceId, targetId] = key.split("→");
if (entries.length === 1) {
const [{ edgeId, attrs }] = entries;
aggregated.mergeDirectedEdgeWithKey(edgeId, sourceId, targetId, {
...attrs,
// #1009: normalize empty/null/undefined edgeType so Sigma's label
// renderer never receives a blank string on the single-entry path.
edgeType: normalizeEdgeType(attrs.edgeType),
dominantEdgeType: normalizeEdgeType(attrs.dominantEdgeType ?? attrs.edgeType),
rawEdgeIds: collectRawEdgeIds(attrs, edgeId),
isAggregated: isAggregatedEdgeAttributes(attrs),
aggregateCount: attrs.aggregateCount ?? collectRawEdgeIds(attrs, edgeId).length,
representativeWeight: attrs.representativeWeight ?? Number(attrs.weight ?? 1),
});
return;
}
const sorted = entries
.slice()
.sort((left, right) => {
const weightDelta = Number(right.attrs.weight ?? 0) - Number(left.attrs.weight ?? 0);
if (weightDelta !== 0) {
return weightDelta;
}
const priorityDelta = Number(right.attrs.visualPriority ?? 0) - Number(left.attrs.visualPriority ?? 0);
if (priorityDelta !== 0) {
return priorityDelta;
}
return left.edgeId.localeCompare(right.edgeId);
});
const representative = sorted[0];
const rawEdgeIds = entries.flatMap(({ edgeId, attrs }) => collectRawEdgeIds(attrs, edgeId));
const typeCounts = new Map<string, number>();
entries.forEach(({ attrs }) => {
const edgeType = normalizeEdgeType(attrs.edgeType);
typeCounts.set(edgeType, (typeCounts.get(edgeType) ?? 0) + 1);
});
const dominantEdgeType = [...typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0]
?? normalizeEdgeType(representative.attrs.edgeType);
const reverseKey = `${targetId}${sourceId}`;
const isBidirectionalBundle = groupedEdges.has(reverseKey);
const syntheticEdgeId = `${AGGREGATED_EDGE_PREFIX}${sourceId}::${targetId}`;
aggregated.mergeDirectedEdgeWithKey(syntheticEdgeId, sourceId, targetId, {
...representative.attrs,
edgeId: syntheticEdgeId,
familyId: syntheticEdgeId,
sourceId,
targetId,
rawEdgeIds,
isAggregated: true,
aggregateCount: rawEdgeIds.length,
dominantEdgeType: dominantEdgeType,
representativeWeight: Number(representative.attrs.weight ?? 1),
weight: Number(representative.attrs.weight ?? 1),
edgeType: representative.attrs.edgeType || dominantEdgeType,
parallelCount: rawEdgeIds.length,
familySize: rawEdgeIds.length,
bundleKind: isBidirectionalBundle ? "bidirectional" : "parallel",
isBidirectional: isBidirectionalBundle,
});
});
return aggregated;
}
function buildCommunityGroupedGraph(): GraphDisplayResult {
const base = computeGraphAnalyticsBase(graph, {
computeCommunities: true,
computeCentrality: true,
});
const state = createEmptyDisplayState("", true);
state.groupedViewAvailable = base.communitiesByNode.size > 0;
if (base.communitiesByNode.size === 0) {
state.groupedViewReason = "Grouped view is unavailable until communities can be detected.";
return { graph: aggregateDisplayGraph(graph), state, meta: MIRRORED_DISPLAY_META };
}
const grouped = new Graph<NodeAttributes, EdgeAttributes>({
type: "directed",
multi: true,
allowSelfLoops: false,
});
const communityMembers = new Map<number, string[]>();
graph.forEachNode((nodeId) => {
const communityId = base.communitiesByNode.get(nodeId);
if (communityId === undefined) {
return;
}
const bucket = communityMembers.get(communityId) ?? [];
bucket.push(nodeId);
communityMembers.set(communityId, bucket);
});
const communitySummaries = new Map<number, {
memberIds: string[];
rankedMembers: string[];
anchorNodeId: string | null;
anchorAttrs: NodeAttributes | null;
dominantSemanticGroup: string;
color: string;
memberCount: number;
centralityScore: number;
}>();
communityMembers.forEach((memberIds, communityId) => {
const rankedMembers = memberIds
.slice()
.sort((left, right) => {
const leftScore = base.centralityByNode.get(left)?.score ?? 0;
const rightScore = base.centralityByNode.get(right)?.score ?? 0;
if (rightScore !== leftScore) {
return rightScore - leftScore;
}
return left.localeCompare(right);
});
const anchorNodeId = rankedMembers[0] ?? null;
const anchorAttrs = anchorNodeId ? (graph.getNodeAttributes(anchorNodeId) as NodeAttributes) : null;
const semanticCounts = new Map<string, number>();
memberIds.forEach((memberId) => {
const memberAttrs = graph.getNodeAttributes(memberId) as NodeAttributes;
const semanticGroup = String(memberAttrs.semanticGroup || memberAttrs.nodeType || "entity");
semanticCounts.set(semanticGroup, (semanticCounts.get(semanticGroup) ?? 0) + 1);
});
const dominantSemanticGroup = [...semanticCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? "entity";
const color = anchorAttrs?.baseColor || anchorAttrs?.color || GRAPH_THEME.palette.semantic[communityId % GRAPH_THEME.palette.semantic.length];
communitySummaries.set(communityId, {
memberIds,
rankedMembers,
anchorNodeId,
anchorAttrs,
dominantSemanticGroup,
color,
memberCount: memberIds.length,
centralityScore: anchorNodeId ? (base.centralityByNode.get(anchorNodeId)?.score ?? 0) : 0,
});
});
const groupedEdges = new Map<string, {
sourceId: string;
targetId: string;
rawEdgeIds: string[];
weight: number;
typeCounts: Map<string, number>;
}>();
graph.forEachEdge((edgeId, attrs, sourceId, targetId) => {
const sourceCommunity = base.communitiesByNode.get(sourceId);
const targetCommunity = base.communitiesByNode.get(targetId);
if (sourceCommunity === undefined || targetCommunity === undefined || sourceCommunity === targetCommunity) {
return;
}
const groupedSourceId = `${COMMUNITY_NODE_PREFIX}${sourceCommunity}`;
const groupedTargetId = `${COMMUNITY_NODE_PREFIX}${targetCommunity}`;
const key = `${groupedSourceId}${groupedTargetId}`;
const bucket = groupedEdges.get(key) ?? {
sourceId: groupedSourceId,
targetId: groupedTargetId,
rawEdgeIds: [],
weight: 0,
typeCounts: new Map<string, number>(),
};
bucket.rawEdgeIds.push(String(edgeId));
bucket.weight = Math.max(bucket.weight, Number((attrs as EdgeAttributes).weight ?? 1));
const edgeType = normalizeEdgeType((attrs as EdgeAttributes).edgeType);
bucket.typeCounts.set(edgeType, (bucket.typeCounts.get(edgeType) ?? 0) + 1);
groupedEdges.set(key, bucket);
});
const connectivityByCommunity = new Map<number, number>();
groupedEdges.forEach((bundle) => {
const sourceCommunityId = Number(bundle.sourceId.replace(COMMUNITY_NODE_PREFIX, ""));
const targetCommunityId = Number(bundle.targetId.replace(COMMUNITY_NODE_PREFIX, ""));
const weight = bundle.rawEdgeIds.length + bundle.weight * 0.35;
connectivityByCommunity.set(sourceCommunityId, (connectivityByCommunity.get(sourceCommunityId) ?? 0) + weight);
connectivityByCommunity.set(targetCommunityId, (connectivityByCommunity.get(targetCommunityId) ?? 0) + weight);
});
const groupedEdgeStrength = new Map<string, number>();
const incidentEdgeStrengths = new Map<string, Array<{ key: string; strength: number }>>();
let strongestGroupedEdge = 0;
groupedEdges.forEach((bundle, key) => {
const strength = bundle.rawEdgeIds.length + bundle.weight * 0.35;
groupedEdgeStrength.set(key, strength);
strongestGroupedEdge = Math.max(strongestGroupedEdge, strength);
const sourceBucket = incidentEdgeStrengths.get(bundle.sourceId) ?? [];
sourceBucket.push({ key, strength });
incidentEdgeStrengths.set(bundle.sourceId, sourceBucket);
const targetBucket = incidentEdgeStrengths.get(bundle.targetId) ?? [];
targetBucket.push({ key, strength });
incidentEdgeStrengths.set(bundle.targetId, targetBucket);
});
const visibleGroupedEdgeKeys = new Set<string>();
const groupedEdgeStrengthCutoff = Math.max(1.25, strongestGroupedEdge * GRAPH_THEME.grouped.style.edgeVisibilityRatio);
groupedEdges.forEach((_bundle, key) => {
const strength = groupedEdgeStrength.get(key) ?? 0;
if (strength >= groupedEdgeStrengthCutoff) {
visibleGroupedEdgeKeys.add(key);
}
});
incidentEdgeStrengths.forEach((entries) => {
entries
.slice()
.sort((left, right) => right.strength - left.strength)
.slice(0, GRAPH_THEME.grouped.style.topIncidentEdges)
.forEach(({ key }) => visibleGroupedEdgeKeys.add(key));
});
if (visibleGroupedEdgeKeys.size === 0 && groupedEdges.size > 0) {
const strongestKey = Array.from(groupedEdges.keys()).sort(
(left, right) => (groupedEdgeStrength.get(right) ?? 0) - (groupedEdgeStrength.get(left) ?? 0),
)[0];
if (strongestKey) {
visibleGroupedEdgeKeys.add(strongestKey);
}
}
const groupedPositions = assignGroupedCommunityPositions(
Array.from(communitySummaries.entries()).map(([communityId, summary]) => ({
communityId,
memberCount: summary.memberCount,
centralityScore: summary.centralityScore,
connectivityWeight: connectivityByCommunity.get(communityId) ?? 0,
radius: getGroupedCommunityNodeSize(summary.memberCount) * GRAPH_THEME.grouped.style.nodeSizeScale * 0.92,
})),
);
communitySummaries.forEach((summary, communityId) => {
const communityNodeId = `${COMMUNITY_NODE_PREFIX}${communityId}`;
const position = groupedPositions.get(communityId) ?? {
x: 0,
y: 0,
labelPriority: 0.56,
visualPriority: 0.42,
};
const size = getGroupedCommunityNodeSize(summary.memberCount);
const anchorLabel = summary.anchorAttrs?.label || summary.anchorNodeId || `Community ${communityId}`;
addNodeIfMissing(grouped, communityNodeId, {
label: truncateGroupedLabel(anchorLabel),
content: summary.anchorAttrs?.content || anchorLabel,
x: position.x,
y: position.y,
size,
baseSize: size,
color: summary.color,
baseColor: summary.color,
mutedColor: withAlpha(summary.color, 0.22),
glowColor: withAlpha(summary.color, GRAPH_THEME.grouped.style.glowAlpha),
borderColor: withAlpha(summary.color, 0.74),
entityShape: "community",
nodeType: "community",
semanticGroup: summary.dominantSemanticGroup,
labelVisibilityPolicy: position.labelPriority > 0.9 ? "priority" : "none",
properties: {
__communityGroup: {
communityId: String(communityId),
memberCount: summary.memberCount,
memberNodeIds: summary.memberIds,
sampleNodeIds: summary.rankedMembers.slice(0, GROUP_SAMPLE_MEMBERS),
anchorNodeId: summary.anchorNodeId,
anchorLabel,
dominantSemanticGroup: summary.dominantSemanticGroup,
color: summary.color,
},
},
isCommunityGroup: true,
communityId: String(communityId),
memberCount: summary.memberCount,
anchorNodeId: summary.anchorNodeId,
labelPriority: position.labelPriority,
visualPriority: position.visualPriority,
});
});
groupedEdges.forEach((bundle, key) => {
if (!visibleGroupedEdgeKeys.has(key)) {
return;
}
const dominantEdgeType = [...bundle.typeCounts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0]
?? "related_to";
const reverseKey = `${bundle.targetId}${bundle.sourceId}`;
const syntheticEdgeId = `${AGGREGATED_EDGE_PREFIX}${key}`;
const aggregateCount = bundle.rawEdgeIds.length;
const baseSize = Math.max(0.9, 0.72 + Math.log2(aggregateCount + 1) * 0.2);
grouped.mergeDirectedEdgeWithKey(syntheticEdgeId, bundle.sourceId, bundle.targetId, {
edgeId: syntheticEdgeId,
familyId: syntheticEdgeId,
sourceId: bundle.sourceId,
targetId: bundle.targetId,
edgeType: dominantEdgeType,
dominantEdgeType,
weight: bundle.weight,
representativeWeight: bundle.weight,
properties: {},
rawEdgeIds: bundle.rawEdgeIds,
isAggregated: true,
aggregateCount,
familySize: aggregateCount,
parallelCount: aggregateCount,
isBidirectional: groupedEdges.has(reverseKey),
bundleKind: "community",
edgeVariant: "line",
arrowVisibilityPolicy: "hidden",
baseSize,
size: baseSize,
color: withAlpha("#9BB7D6", GRAPH_THEME.grouped.style.edgeAlpha),
mutedColor: withAlpha("#9BB7D6", Math.max(0.14, GRAPH_THEME.grouped.style.edgeAlpha * 0.4)),
visualPriority: Math.min(1.05, 0.38 + Math.log2(aggregateCount + 1) * 0.14),
});
});
const groupedValidationError = validateGroupedDisplayGraph(grouped);
if (groupedValidationError) {
state.groupedViewAvailable = false;
state.groupedViewReason = groupedValidationError;
return {
graph: aggregateDisplayGraph(graph),
state,
meta: MIRRORED_DISPLAY_META,
};
}
return {
graph: grouped,
state: {
aggregationEnabled: true,
groupedViewAvailable: true,
groupedViewReason: null,
selectedRootNodeId: null,
selectedVisibleNeighborIds: [],
selectedCollapsedNeighborIds: [],
selectedNodeKind: "none",
canActivateFocused: false,
resolvedFocusedNodeId: null,
focusedUnavailableReason: null,
},
meta: OWNED_DISPLAY_META,
};
}
export function resolveDisplayStateSnapshot(
selectedNodeId: string,
activePath: string[],
viewMode: GraphViewMode,
options?: {
aggregationEnabled?: boolean;
collapsedNeighborhoodNodeIds?: Iterable<string>;
groupedViewAvailable?: boolean;
groupedViewReason?: string | null;
selectedNodeKind?: GraphSelectedNodeKind;
resolvedFocusedNodeId?: string | null;
focusedUnavailableReason?: string | null;
},
): GraphDisplayStateSnapshot {
const aggregationEnabled = options?.aggregationEnabled ?? true;
const collapsedNeighborhoodNodeIds = new Set(
Array.from(options?.collapsedNeighborhoodNodeIds ?? []).filter((nodeId) => typeof nodeId === "string"),
);
const displayState = createEmptyDisplayState(selectedNodeId, aggregationEnabled);
displayState.selectedNodeKind = options?.selectedNodeKind ?? (selectedNodeId ? "unavailable" : "none");
displayState.resolvedFocusedNodeId = options?.resolvedFocusedNodeId ?? null;
displayState.canActivateFocused = Boolean(displayState.resolvedFocusedNodeId);
displayState.focusedUnavailableReason = displayState.canActivateFocused
? null
: (options?.focusedUnavailableReason ?? displayState.focusedUnavailableReason);
displayState.groupedViewAvailable = options?.groupedViewAvailable ?? computeGraphAnalyticsBase(graph, {
computeCommunities: true,
computeCentrality: false,
}).communitiesByNode.size > 0;
displayState.groupedViewReason = options?.groupedViewReason
?? (displayState.groupedViewAvailable ? null : "Grouped view is unavailable until communities can be detected.");
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
return displayState;
}
const shouldCollapseNeighborhood = Boolean(
viewMode !== "grouped"
&& collapsedNeighborhoodNodeIds.has(selectedNodeId),
);
const collapsedState = shouldCollapseNeighborhood
? buildCollapsedNeighborhoodState(selectedNodeId, activePath)
: {
selectedVisibleNeighborIds: rankNeighbors(selectedNodeId),
selectedCollapsedNeighborIds: [],
};
displayState.selectedRootNodeId = selectedNodeId;
displayState.selectedVisibleNeighborIds = collapsedState.selectedVisibleNeighborIds;
displayState.selectedCollapsedNeighborIds = collapsedState.selectedCollapsedNeighborIds;
return displayState;
}
export function createFocusedGraph(
nodeId: string,
activePath: string[],
activePathEdgeIds: string[] = [],
collapseNeighborhood = false,
): Graph<NodeAttributes, EdgeAttributes> {
const focused = new Graph<NodeAttributes, EdgeAttributes>({
type: "directed",
multi: true,
allowSelfLoops: false,
});
const rankedNeighbors = rankNeighbors(nodeId).slice(0, MAX_FOCUS_NEIGHBORS);
const collapsedState = collapseNeighborhood
? buildCollapsedNeighborhoodState(nodeId, activePath)
: {
selectedVisibleNeighborIds: rankedNeighbors,
selectedCollapsedNeighborIds: [],
};
const visibleNeighborIds = rankedNeighbors.filter((neighborId) => collapsedState.selectedVisibleNeighborIds.includes(neighborId));
const focusIds = new Set<string>([nodeId, ...visibleNeighborIds]);
const labelledNeighborIds = new Set(visibleNeighborIds.slice(0, FOCUS_PRIMARY_LABELS));
const pathNodeIds = new Set(activePath);
const pathEdgeIds = buildPathEdgeSet(graph, activePath, activePathEdgeIds);
const addNode = (id: string, attrs: NodeAttributes) => {
if (!focused.hasNode(id)) {
focused.addNode(id, attrs);
}
};
const selectedAttrs = graph.getNodeAttributes(nodeId) as NodeAttributes;
const selectedState = resolveNodeElementStyle(GRAPH_THEME, "inspection", "selected", selectedAttrs, selectedAttrs.label);
addNode(nodeId, {
...selectedAttrs,
x: Number.isFinite(selectedAttrs.x) ? selectedAttrs.x : 0,
y: Number.isFinite(selectedAttrs.y) ? selectedAttrs.y : 0,
color: selectedState.color,
size: Math.max(selectedState.size, 22),
baseColor: selectedState.color,
baseSize: Math.max(selectedState.size, 22),
label: selectedState.label,
});
visibleNeighborIds.forEach((neighborId, index) => {
const baseAttrs = graph.getNodeAttributes(neighborId) as NodeAttributes;
const ring = Math.floor(index / FOCUS_RING_CAPACITY);
const ringIndex = index % FOCUS_RING_CAPACITY;
const itemsInRing = Math.min(
FOCUS_RING_CAPACITY,
rankedNeighbors.length - ring * FOCUS_RING_CAPACITY,
);
const radius = FOCUS_RING_GAP * (ring + 1);
const angle = (Math.PI * 2 * ringIndex) / itemsInRing - Math.PI / 2;
const visualState: GraphNodeVisualState = pathNodeIds.has(neighborId)
? "path"
: labelledNeighborIds.has(neighborId)
? "neighbor"
: "default";
const style = resolveNodeElementStyle(
GRAPH_THEME,
"inspection",
visualState,
{
...baseAttrs,
labelPriority: labelledNeighborIds.has(neighborId) || pathNodeIds.has(neighborId)
? Math.max(Number(baseAttrs.labelPriority ?? 0), 1)
: 0,
},
baseAttrs.label,
);
addNode(neighborId, {
...baseAttrs,
x: Number.isFinite(baseAttrs.x) ? baseAttrs.x : Math.cos(angle) * radius,
y: Number.isFinite(baseAttrs.y) ? baseAttrs.y : Math.sin(angle) * radius,
color: style.color,
size: Math.max(style.size, 8.5),
baseColor: style.color,
baseSize: Math.max(style.size, 8.5),
label: style.label,
});
});
collectFocusEdgeIds(graph, focusIds).forEach((edgeId) => {
if (!graph.hasEdge(edgeId)) {
return;
}
const [source, target] = graph.extremities(edgeId);
if (!focusIds.has(source) || !focusIds.has(target) || source === target) {
if (DEBUG_GRAPH_SCENE_STATE) {
console.debug("[graphSceneState]", "focused-edge-skipped-invalid-endpoints", {
edgeId,
source,
target,
selectedNodeId: nodeId,
});
}
return;
}
const attrs = graph.getEdgeAttributes(edgeId) as EdgeAttributes;
const state: GraphEdgeVisualState = pathEdgeIds.has(edgeId)
? "path"
: source === nodeId || target === nodeId
? "selected"
: "neighbor";
const style = resolveEdgeElementStyle(GRAPH_THEME, "inspection", state, attrs, source, target, "focused", edgeId);
focused.mergeDirectedEdgeWithKey(edgeId, source, target, {
...attrs,
type: style.type,
size: style.size,
color: style.color,
baseSize: style.size,
baseColor: style.color,
curvature: style.curvature,
});
});
return aggregateDisplayGraph(focused);
}
export function resolveDisplayGraph(
selectedNodeId: string,
activePath: string[],
activePathEdgeIds: string[],
viewMode: GraphViewMode,
options?: {
aggregationEnabled?: boolean;
collapsedNeighborhoodNodeIds?: Iterable<string>;
groupedViewAvailable?: boolean;
},
): GraphDisplayResult {
const aggregationEnabled = options?.aggregationEnabled ?? true;
const collapsedNeighborhoodNodeIds = new Set(
Array.from(options?.collapsedNeighborhoodNodeIds ?? []).filter((nodeId) => typeof nodeId === "string"),
);
const displayState = resolveDisplayStateSnapshot(selectedNodeId, activePath, viewMode, {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
groupedViewAvailable: options?.groupedViewAvailable,
});
const isFocusedView = viewMode === "focused" && Boolean(selectedNodeId) && graph.hasNode(selectedNodeId);
const isGroupedView = viewMode === "grouped";
const shouldCollapseNeighborhood = Boolean(selectedNodeId && collapsedNeighborhoodNodeIds.has(selectedNodeId));
if (isGroupedView) {
const grouped = buildCommunityGroupedGraph();
return {
graph: grouped.graph,
state: {
...grouped.state,
selectedRootNodeId: displayState.selectedRootNodeId,
selectedVisibleNeighborIds: displayState.selectedVisibleNeighborIds,
selectedCollapsedNeighborIds: displayState.selectedCollapsedNeighborIds,
groupedViewReason: grouped.state.groupedViewReason,
selectedNodeKind: displayState.selectedNodeKind,
canActivateFocused: displayState.canActivateFocused,
resolvedFocusedNodeId: displayState.resolvedFocusedNodeId,
focusedUnavailableReason: displayState.focusedUnavailableReason,
},
meta: grouped.meta,
};
}
if (isFocusedView && selectedNodeId) {
return {
graph: createFocusedGraph(selectedNodeId, activePath, activePathEdgeIds, shouldCollapseNeighborhood),
state: displayState,
meta: MIRRORED_DISPLAY_META,
};
}
const baseGraph = shouldCollapseNeighborhood && selectedNodeId
? createCollapsedNeighborhoodGraph(selectedNodeId, activePath)
: graph;
return {
graph: aggregationEnabled ? aggregateDisplayGraph(baseGraph) : baseGraph,
state: displayState,
meta: aggregationEnabled
? MIRRORED_DISPLAY_META
: baseGraph === graph
? BASE_DISPLAY_META
: MIRRORED_DISPLAY_META,
};
}
export function createInteractionState(
hoveredNodeId: string | null,
selectedNodeId: string,
focusedNodeId: string,
selectedEdgeId: string,
activePath: string[],
activePathEdgeIds: string[],
viewMode: GraphViewMode,
zoomTier: GraphZoomTier,
isLayoutRunning: boolean,
): GraphInteractionState {
return {
hoveredNodeId,
selectedNodeId,
selectedEdgeId,
focusedNodeId,
activePath,
activePathEdgeIds,
viewMode,
zoomTier,
isLayoutRunning,
};
}