mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Merge pull request #513 from Hawksight-AI/feat/explorer-distance-ui-fix
fix(explorer): make distance intelligence visible
This commit is contained in:
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Fix: Explorer Distance Intelligence visible rendering** (PR #513 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||
- Distance Intelligence now renders as a first-class visual state through the Sigma reducer/theme pipeline instead of mutating raw graph attributes directly.
|
||||
- Ego mode fades and scales nodes by structural distance from the selected anchor; nodes outside `maxHops` are dimmed and label-suppressed.
|
||||
- Heatmap mode renders a sampled local lens capped per ring (1-hop: ≤120, 2-hop: ≤650, 3-hop: ≤900 nodes shown); true counts remain visible in the status strip. Saturation detection reduces alpha for dense outer rings automatically.
|
||||
- Structural mode highlights distance-aware context edges (colored by hop band) without breaking existing edge LOD.
|
||||
- Semantic mode surfaces loading, unavailable, and error states visibly; edges colored by cosine similarity score.
|
||||
- Trace Path inspector shows a distance band chip, hop count, and optional metric cards (confidence decay, semantic similarity, path coherence, bottleneck node) when path data is available.
|
||||
- Added `GraphDistanceVisualState`, `GraphDistanceBucketCounts`, and `GraphHeatmapRenderSnapshot` types in `types.ts`; distance state flows through `GraphCanvas` → `buildReducerSceneState` → Sigma node/edge reducers.
|
||||
- Added `buildStructuralDistanceSnapshot` (bounded BFS), `summarizeDistanceBuckets`, `buildHeatmapRenderSnapshot` (ring-capped deterministic sampling via `hashString` tiebreaker), `resolveDistanceNodeStyle`, and `resolveDistanceEdgeStyle` in `graphSceneState.ts`.
|
||||
- Distance Intelligence status strip shows active mode, anchor label, per-ring node counts, sampled status, and a color legend.
|
||||
- **Review blockers fixed** (follow-up by @KaifAhmad1 and @ZohaibHassan16): removed dead `if (anchorNodeId)` conditional in `buildHeatmapRenderSnapshot` (anchor always truthy past early-return guard); replaced O(n) `.includes()` call in the Sigma reducer hot path with a `WeakMap`-cached `Set.has()` lookup; renamed `GraphDistanceBucketCounts.threeHop → threeHopPlus` so the field accurately reflects ≥ 3 hops and updated status strip labels to "3+ hop"; restored `hasMetrics` guard in `PathDistanceIntelPanel` to suppress the empty metric grid `<div>` when a path result carries no optional metric fields.
|
||||
|
||||
- **Feature: Graph Explorer visual refresh** (PR #503 by @ZohaibHassan16, conflict resolution by @KaifAhmad1):
|
||||
- Extracted all hardcoded `rgba(...)` color literals into a structured `ui.*` design-token namespace in `graphTheme.ts` — covering `ui.text`, `ui.surface`, `ui.scene`, `ui.control`, `ui.timeline`, and `ui.interaction`. Future theming is now a one-file change.
|
||||
- Added `GraphEntityShapeVariant` type and per-shape config (`fillAlpha`, `shellAlpha`, `coreScale`, `borderBoost`, `minSize`) for biomolecule, condition, compound, process, community, and entity node kinds. Shell and fill colors now derive from per-entity-shape config rather than uniform overrides.
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
mapFullEdgeClassToVisualState,
|
||||
resolveEdgeElementStyle,
|
||||
resolveEdgeVisualState,
|
||||
resolveDistanceEdgeStyle,
|
||||
resolveDistanceNodeStyle,
|
||||
resolveNodeElementStyle,
|
||||
resolveNodeVisualState,
|
||||
} from "./graphSceneState";
|
||||
@@ -61,6 +63,7 @@ import {
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphCameraState,
|
||||
GraphDistanceVisualState,
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
@@ -101,6 +104,7 @@ export interface GraphCanvasProps {
|
||||
selectedEdgeId: string;
|
||||
activePath?: string[];
|
||||
activePathEdgeIds?: string[];
|
||||
distanceVisualState?: GraphDistanceVisualState;
|
||||
effectsState: GraphEffectsState;
|
||||
temporalState?: GraphTemporalState | null;
|
||||
isLayoutRunning: boolean;
|
||||
@@ -829,6 +833,7 @@ type ReducerSceneState = {
|
||||
pathEdgeIds: Set<string>;
|
||||
highlightedIncidentEdgeIds: Set<string>;
|
||||
overviewBackboneEdgeIds: Set<string>;
|
||||
distanceVisualState?: GraphDistanceVisualState;
|
||||
};
|
||||
|
||||
const FULL_EDGE_CLASSES: GraphFullEdgeClass[] = [
|
||||
@@ -952,6 +957,7 @@ function buildReducerSceneState(
|
||||
interactionState: GraphInteractionState,
|
||||
displayState?: GraphDisplayStateSnapshot,
|
||||
analyticsSnapshot?: GraphAnalyticsSnapshot | null,
|
||||
distanceVisualState?: GraphDistanceVisualState,
|
||||
): ReducerSceneState {
|
||||
const { viewMode, zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
|
||||
const primaryNodeId = hoveredNodeId || selectedNodeId;
|
||||
@@ -977,6 +983,7 @@ function buildReducerSceneState(
|
||||
pathNodeIds: new Set(activePath),
|
||||
pathEdgeIds,
|
||||
overviewBackboneEdgeIds: new Set(analyticsSnapshot?.overviewBackbone.edgeIds ?? []),
|
||||
distanceVisualState,
|
||||
highlightedIncidentEdgeIds: buildHighlightedIncidentEdgeIds(
|
||||
displayGraph,
|
||||
interactionState,
|
||||
@@ -1092,24 +1099,34 @@ function applySceneState(
|
||||
data.label,
|
||||
cameraRatio,
|
||||
);
|
||||
const distanceStyle = currentState.viewMode === "full"
|
||||
? resolveDistanceNodeStyle(
|
||||
GRAPH_THEME,
|
||||
currentState.zoomTier,
|
||||
style,
|
||||
currentState.distanceVisualState,
|
||||
String(node),
|
||||
)
|
||||
: {};
|
||||
const resolvedStyle = { ...style, ...distanceStyle };
|
||||
|
||||
return {
|
||||
...data,
|
||||
color: style.color,
|
||||
shellColor: style.shellColor,
|
||||
coreScale: style.coreScale,
|
||||
size: style.size,
|
||||
forceLabel: style.forceLabel,
|
||||
label: style.label,
|
||||
zIndex: style.zIndex,
|
||||
hidden: style.hidden,
|
||||
borderColor: style.borderColor,
|
||||
borderSize: style.borderSize,
|
||||
ringColor: style.showRing ? style.ringColor : style.borderColor,
|
||||
ringSize: style.ringSize,
|
||||
entityShape: style.entityShape,
|
||||
entityShapeKind: style.entityShapeKind,
|
||||
entityAspectRatio: style.entityAspectRatio,
|
||||
color: resolvedStyle.color,
|
||||
shellColor: resolvedStyle.shellColor,
|
||||
coreScale: resolvedStyle.coreScale,
|
||||
size: resolvedStyle.size,
|
||||
forceLabel: resolvedStyle.forceLabel,
|
||||
label: resolvedStyle.label,
|
||||
zIndex: resolvedStyle.zIndex,
|
||||
hidden: resolvedStyle.hidden,
|
||||
borderColor: resolvedStyle.borderColor,
|
||||
borderSize: resolvedStyle.borderSize,
|
||||
ringColor: resolvedStyle.showRing ? resolvedStyle.ringColor : resolvedStyle.borderColor,
|
||||
ringSize: resolvedStyle.ringSize,
|
||||
entityShape: resolvedStyle.entityShape,
|
||||
entityShapeKind: resolvedStyle.entityShapeKind,
|
||||
entityAspectRatio: resolvedStyle.entityAspectRatio,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1173,15 +1190,25 @@ function applySceneState(
|
||||
stableEdgeId,
|
||||
fullEdgeClass,
|
||||
);
|
||||
const distanceStyle = currentState.viewMode === "full"
|
||||
? resolveDistanceEdgeStyle(
|
||||
style,
|
||||
currentState.distanceVisualState,
|
||||
String(source),
|
||||
String(target),
|
||||
fullEdgeClass,
|
||||
)
|
||||
: {};
|
||||
const resolvedStyle = { ...style, ...distanceStyle };
|
||||
|
||||
return {
|
||||
...data,
|
||||
hidden: style.hidden,
|
||||
type: style.type,
|
||||
color: style.color,
|
||||
size: style.size,
|
||||
zIndex: style.zIndex,
|
||||
curvature: style.curvature,
|
||||
hidden: resolvedStyle.hidden,
|
||||
type: resolvedStyle.type,
|
||||
color: resolvedStyle.color,
|
||||
size: resolvedStyle.size,
|
||||
zIndex: resolvedStyle.zIndex,
|
||||
curvature: resolvedStyle.curvature,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1225,6 +1252,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
selectedEdgeId,
|
||||
activePath = [],
|
||||
activePathEdgeIds = [],
|
||||
distanceVisualState,
|
||||
effectsState,
|
||||
temporalState,
|
||||
isLayoutRunning,
|
||||
@@ -1259,6 +1287,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const graphVersionRef = useRef(graphVersion);
|
||||
const selectedNodeIdRef = useRef(selectedNodeId);
|
||||
const focusedNodeIdRef = useRef(focusedNodeId);
|
||||
const distanceVisualStateRef = useRef(distanceVisualState);
|
||||
const viewModeRef = useRef(viewMode);
|
||||
const onNodeClickRef = useRef(onNodeClick);
|
||||
const onEdgeClickRef = useRef(onEdgeClick);
|
||||
@@ -1285,6 +1314,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
graphVersionRef.current = graphVersion;
|
||||
selectedNodeIdRef.current = selectedNodeId;
|
||||
focusedNodeIdRef.current = focusedNodeId;
|
||||
distanceVisualStateRef.current = distanceVisualState;
|
||||
viewModeRef.current = viewMode;
|
||||
onNodeClickRef.current = onNodeClick;
|
||||
onEdgeClickRef.current = onEdgeClick;
|
||||
@@ -1331,6 +1361,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const interactionStateRef = useRef<GraphInteractionState>(interactionState);
|
||||
interactionStateRef.current = interactionState;
|
||||
const previousInteractionStateRef = useRef<GraphInteractionState | null>(null);
|
||||
const previousDistanceVisualStateRef = useRef<GraphDistanceVisualState | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!isLayoutRunning) {
|
||||
setLayoutSettledEpoch((epoch) => epoch + 1);
|
||||
@@ -1347,8 +1378,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
[displayGraph, shouldComputeCentrality, shouldComputeCommunities],
|
||||
);
|
||||
const reducerSceneState = useMemo(
|
||||
() => buildReducerSceneState(displayGraph, interactionState, displayState, analyticsSnapshot),
|
||||
[analyticsSnapshot, displayGraph, displayState, interactionState],
|
||||
() => buildReducerSceneState(displayGraph, interactionState, displayState, analyticsSnapshot, distanceVisualState),
|
||||
[analyticsSnapshot, displayGraph, displayState, distanceVisualState, interactionState],
|
||||
);
|
||||
const reducerSceneStateRef = useRef<ReducerSceneState>(reducerSceneState);
|
||||
reducerSceneStateRef.current = reducerSceneState;
|
||||
@@ -1975,6 +2006,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
appliedGraphVersionRef.current = graphVersion;
|
||||
fittedDisplaySignatureRef.current = null;
|
||||
previousInteractionStateRef.current = null;
|
||||
previousDistanceVisualStateRef.current = undefined;
|
||||
behaviorContextRef.current = getBehaviorContext(sigma);
|
||||
if (runtimeRef.current) {
|
||||
runtimeRef.current.displayGraph = displayGraph;
|
||||
@@ -2094,6 +2126,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
effectAvailability: availability,
|
||||
edgeClasses: edgeClassDiagnostics,
|
||||
structureLayer: structureLayerDiagnosticsRef.current ?? structureLayerDiagnostics,
|
||||
distanceVisual: distanceVisualStateRef.current,
|
||||
});
|
||||
if (import.meta.env.DEV && effectsState.diagnosticsEnabled) {
|
||||
console.debug("[Edge Truth]", edgeClassDiagnostics);
|
||||
@@ -2107,10 +2140,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
onDiagnosticsChange,
|
||||
structureLayerDiagnostics,
|
||||
temporalState,
|
||||
distanceVisualState,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
previousInteractionStateRef.current = null;
|
||||
previousDistanceVisualStateRef.current = undefined;
|
||||
}, [displayGraph]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2120,13 +2155,15 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
}
|
||||
|
||||
const previousInteractionState = previousInteractionStateRef.current;
|
||||
const refreshTargets = previousInteractionState
|
||||
const distanceVisualStateChanged = previousDistanceVisualStateRef.current !== distanceVisualState;
|
||||
const refreshTargets = !distanceVisualStateChanged && previousInteractionState
|
||||
? collectInteractionRefreshTargets(displayGraph, previousInteractionState, interactionState)
|
||||
: undefined;
|
||||
|
||||
applySceneState(sigma, reducerSceneStateRef, reducerWarningStateRef, refreshTargets);
|
||||
previousInteractionStateRef.current = interactionState;
|
||||
}, [displayGraph, interactionState, reducerSceneStateRef]);
|
||||
previousDistanceVisualStateRef.current = distanceVisualState;
|
||||
}, [displayGraph, distanceVisualState, interactionState, reducerSceneStateRef]);
|
||||
|
||||
const drawStructureLayerFrame = useCallback(() => {
|
||||
const sigma = sigmaRef.current;
|
||||
|
||||
@@ -63,15 +63,12 @@ const BAND_COLORS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function PathDistanceIntelPanel({ result }: { result: PathResponse }) {
|
||||
const bandColor = BAND_COLORS[result.distance_band] ?? "#8b949e";
|
||||
const hasMetrics =
|
||||
result.confidence_decay != null ||
|
||||
result.semantic_similarity != null ||
|
||||
result.path_coherence_score != null ||
|
||||
result.bottleneck_node ||
|
||||
result.interpretation;
|
||||
if (!hasMetrics) return null;
|
||||
|
||||
const bandColor = BAND_COLORS[result.distance_band] ?? "#8b949e";
|
||||
result.bottleneck_node != null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
|
||||
@@ -96,7 +93,7 @@ function PathDistanceIntelPanel({ result }: { result: PathResponse }) {
|
||||
</div>
|
||||
|
||||
{/* metric grid */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{hasMetrics && <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{result.confidence_decay != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Confidence Decay</div>
|
||||
@@ -157,7 +154,7 @@ function PathDistanceIntelPanel({ result }: { result: PathResponse }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* interpretation */}
|
||||
{result.interpretation && (
|
||||
|
||||
@@ -29,7 +29,7 @@ import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
|
||||
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import type { GraphEntityShapeVariant } from "./graphTheme";
|
||||
import { checkGroupedViewAvailability, resolveDisplayGraph, resolveDisplayStateSnapshot, resolveGroupedDisplayNodeId, resolveGroupedDisplayStateSnapshot } from "./graphSceneState";
|
||||
import { buildHeatmapRenderSnapshot, buildStructuralDistanceSnapshot, checkGroupedViewAvailability, getDistanceBandColor, resolveDisplayGraph, resolveDisplayStateSnapshot, resolveGroupedDisplayNodeId, resolveGroupedDisplayStateSnapshot, summarizeDistanceBuckets } from "./graphSceneState";
|
||||
import {
|
||||
type GraphPlugin,
|
||||
type GraphPluginActionRequest,
|
||||
@@ -42,6 +42,8 @@ import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
|
||||
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDistanceVisualMode,
|
||||
GraphDistanceVisualState,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectToggle,
|
||||
@@ -103,6 +105,18 @@ type GraphToolbarGroup = {
|
||||
items: GraphToolbarItem[];
|
||||
};
|
||||
|
||||
type SemanticNeighborhoodResponse = {
|
||||
anchor_node: string;
|
||||
total: number;
|
||||
neighbors: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
similarity: number;
|
||||
hop_distance?: number | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
type LazyPluginRegistryEntry = {
|
||||
id: string;
|
||||
panelId: string;
|
||||
@@ -117,6 +131,9 @@ type LazyPluginRegistryEntry = {
|
||||
};
|
||||
|
||||
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
|
||||
const EMPTY_DISTANCE_RECORD: Record<string, number> = {};
|
||||
const STRUCTURAL_DISTANCE_MAX_HOPS = 6;
|
||||
const HEATMAP_DISTANCE_MAX_HOPS = 3;
|
||||
const DEFAULT_EFFECTS_STATE: GraphEffectsState = {
|
||||
pathPulseEnabled: false,
|
||||
pathFlowEnabled: false,
|
||||
@@ -1107,6 +1124,19 @@ export function GraphWorkspace() {
|
||||
const [distanceMode, setDistanceMode] = useState<"off" | "structural" | "semantic">("off");
|
||||
// FR-5: Distance heatmap layout
|
||||
const [heatmapEnabled, setHeatmapEnabled] = useState(false);
|
||||
const [semanticDistanceState, setSemanticDistanceState] = useState<{
|
||||
anchorNodeId: string | null;
|
||||
scores: Record<string, number>;
|
||||
count: number;
|
||||
status: GraphDistanceVisualState["status"];
|
||||
error: string | null;
|
||||
}>({
|
||||
anchorNodeId: null,
|
||||
scores: EMPTY_DISTANCE_RECORD,
|
||||
count: 0,
|
||||
status: "idle",
|
||||
error: null,
|
||||
});
|
||||
|
||||
const debouncedTime = useDebounce(scrubberTime, 150);
|
||||
const prevActiveIdsRef = useRef<Set<string>>(new Set());
|
||||
@@ -1560,158 +1590,248 @@ export function GraphWorkspace() {
|
||||
setLastGroupedSelectedNodeId("");
|
||||
}, [summary?.edgeCount, summary?.nodeCount]);
|
||||
|
||||
// ── Distance Intelligence helpers ──────────────────────────────
|
||||
// BFS over the in-memory graphology graph; returns hop distance from startId.
|
||||
function bfsDistances(startId: string, maxHops: number): Map<string, number> {
|
||||
const dist = new Map<string, number>();
|
||||
if (!graph.hasNode(startId)) return dist;
|
||||
const queue: [string, number][] = [[startId, 0]];
|
||||
dist.set(startId, 0);
|
||||
while (queue.length > 0) {
|
||||
const [nodeId, hop] = queue.shift()!;
|
||||
if (hop >= maxHops) continue;
|
||||
for (const nb of graph.neighbors(nodeId)) {
|
||||
if (!dist.has(nb)) {
|
||||
dist.set(nb, hop + 1);
|
||||
queue.push([nb, hop + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
const activeDistanceMode: GraphDistanceVisualMode = egoModeEnabled
|
||||
? "ego"
|
||||
: heatmapEnabled
|
||||
? "heatmap"
|
||||
: distanceMode;
|
||||
const distanceAnchorNodeId = viewMode === "full" && selectedNodeId && graph.hasNode(selectedNodeId)
|
||||
? selectedNodeId
|
||||
: "";
|
||||
const distanceAnchorLabel = distanceAnchorNodeId
|
||||
? String((graph.getNodeAttributes(distanceAnchorNodeId) as NodeAttributes).label || distanceAnchorNodeId)
|
||||
: null;
|
||||
const distanceMaxHops = activeDistanceMode === "ego"
|
||||
? egoMaxHops
|
||||
: activeDistanceMode === "heatmap"
|
||||
? HEATMAP_DISTANCE_MAX_HOPS
|
||||
: STRUCTURAL_DISTANCE_MAX_HOPS;
|
||||
const structuralDistances = useMemo(
|
||||
() => (
|
||||
distanceAnchorNodeId && activeDistanceMode !== "off"
|
||||
? buildStructuralDistanceSnapshot(graph, distanceAnchorNodeId, distanceMaxHops)
|
||||
: EMPTY_DISTANCE_RECORD
|
||||
),
|
||||
[activeDistanceMode, distanceAnchorNodeId, distanceMaxHops, graphVersion],
|
||||
);
|
||||
const distanceCounts = useMemo(
|
||||
() => summarizeDistanceBuckets(structuralDistances, graph.order),
|
||||
[structuralDistances, graphVersion],
|
||||
);
|
||||
const heatmapRenderSnapshot = useMemo(
|
||||
() => (
|
||||
activeDistanceMode === "heatmap" && distanceAnchorNodeId
|
||||
? buildHeatmapRenderSnapshot(graph, distanceAnchorNodeId, structuralDistances, HEATMAP_DISTANCE_MAX_HOPS)
|
||||
: null
|
||||
),
|
||||
[activeDistanceMode, distanceAnchorNodeId, graphVersion, structuralDistances],
|
||||
);
|
||||
|
||||
function hopBandColor(hops: number): string {
|
||||
if (hops === 0) return "#3fb950"; // anchor – green
|
||||
if (hops === 1) return "#56d364"; // direct – light green
|
||||
if (hops <= 3) return "#e3b341"; // near – amber
|
||||
if (hops <= 6) return "#d29922"; // mid-range – orange
|
||||
return "#ff7b72"; // distant – red
|
||||
}
|
||||
|
||||
function restoreNodeColors() {
|
||||
graph.forEachNode((nodeId) => {
|
||||
const attrs = graph.getNodeAttributes(nodeId) as { baseColor?: string; color?: string; baseSize?: number; size?: number };
|
||||
if (attrs.baseColor) graph.setNodeAttribute(nodeId, "color", attrs.baseColor);
|
||||
if (attrs.baseSize) graph.setNodeAttribute(nodeId, "size", attrs.baseSize);
|
||||
});
|
||||
}
|
||||
|
||||
function restoreEdgeColors() {
|
||||
graph.forEachEdge((edgeId) => {
|
||||
const attrs = graph.getEdgeAttributes(edgeId) as { baseColor?: string };
|
||||
if (attrs.baseColor) graph.setEdgeAttribute(edgeId, "color", attrs.baseColor);
|
||||
});
|
||||
}
|
||||
|
||||
// FR-2 + FR-5: Combined node styling effect — ego mode and heatmap share a single
|
||||
// effect so restoreNodeColors() is never called from two competing effects at once.
|
||||
useEffect(() => {
|
||||
const activeMode = egoModeEnabled ? "ego" : heatmapEnabled ? "heatmap" : "off";
|
||||
|
||||
if (activeMode === "off" || !selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||
restoreNodeColors();
|
||||
requestAnimationFrame(() => {
|
||||
setGraphVersion((v) => v + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
if (viewMode === "full") {
|
||||
return;
|
||||
}
|
||||
setEgoModeEnabled(false);
|
||||
setHeatmapEnabled(false);
|
||||
setDistanceMode("off");
|
||||
}, [viewMode]);
|
||||
|
||||
if (activeMode === "ego") {
|
||||
const dist = bfsDistances(selectedNodeId, egoMaxHops);
|
||||
const maxD = Math.max(1, egoMaxHops);
|
||||
graph.forEachNode((nodeId) => {
|
||||
const attrs = graph.getNodeAttributes(nodeId) as { baseColor?: string; color?: string; baseSize?: number; size?: number };
|
||||
const baseColor = attrs.baseColor || attrs.color || "#58a6ff";
|
||||
const baseSize = attrs.baseSize || attrs.size || 8;
|
||||
const d = dist.get(nodeId);
|
||||
if (d === undefined) {
|
||||
graph.setNodeAttribute(nodeId, "color", withAlpha(baseColor, 0.06));
|
||||
graph.setNodeAttribute(nodeId, "size", Math.max(0.5, baseSize * 0.22));
|
||||
} else {
|
||||
const ratio = d / (maxD + 1);
|
||||
graph.setNodeAttribute(nodeId, "color", withAlpha(baseColor, 1 - ratio * 0.65));
|
||||
graph.setNodeAttribute(nodeId, "size", Math.max(1, baseSize * (1 - ratio * 0.38)));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// heatmap
|
||||
const dist = bfsDistances(selectedNodeId, 20);
|
||||
graph.forEachNode((nodeId) => {
|
||||
const d = dist.get(nodeId);
|
||||
graph.setNodeAttribute(nodeId, "color", d !== undefined ? hopBandColor(d) : "rgba(40,55,72,0.55)");
|
||||
});
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setGraphVersion((v) => v + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [egoModeEnabled, egoMaxHops, heatmapEnabled, selectedNodeId]);
|
||||
|
||||
// FR-3 frontend: Distance overlay — color edges by structural hop distance or semantic similarity
|
||||
useEffect(() => {
|
||||
if (distanceMode === "off" || !selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||
if (distanceMode === "off") {
|
||||
restoreEdgeColors();
|
||||
requestAnimationFrame(() => {
|
||||
setGraphVersion((v) => v + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
}
|
||||
return;
|
||||
if (activeDistanceMode === "off" || !selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||
setEgoModeEnabled(false);
|
||||
setHeatmapEnabled(false);
|
||||
setDistanceMode("off");
|
||||
}
|
||||
}, [activeDistanceMode, graphVersion, selectedNodeId]);
|
||||
|
||||
if (distanceMode === "structural") {
|
||||
const dist = bfsDistances(selectedNodeId, 20);
|
||||
graph.forEachEdge((edgeId, _attrs, source, target) => {
|
||||
const d = Math.min(dist.get(source) ?? 99, dist.get(target) ?? 99);
|
||||
const edgeColor = d <= 1 ? "rgba(86,211,100,0.55)" : d <= 3 ? "rgba(227,179,65,0.45)" : d <= 6 ? "rgba(210,153,34,0.35)" : "rgba(255,123,114,0.22)";
|
||||
graph.setEdgeAttribute(edgeId, "color", edgeColor);
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
setGraphVersion((v) => v + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
useEffect(() => {
|
||||
if (distanceMode !== "semantic" || !distanceAnchorNodeId) {
|
||||
setSemanticDistanceState({
|
||||
anchorNodeId: distanceAnchorNodeId || null,
|
||||
scores: EMPTY_DISTANCE_RECORD,
|
||||
count: 0,
|
||||
status: distanceMode === "semantic" ? "unavailable" : "idle",
|
||||
error: distanceMode === "semantic" ? "Select a Full Graph node to load semantic distance." : null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Semantic mode — fetch neighborhood and color edges by similarity score
|
||||
const fetchSemantic = async () => {
|
||||
let cancelled = false;
|
||||
setSemanticDistanceState({
|
||||
anchorNodeId: distanceAnchorNodeId,
|
||||
scores: EMPTY_DISTANCE_RECORD,
|
||||
count: 0,
|
||||
status: "loading",
|
||||
error: null,
|
||||
});
|
||||
|
||||
const loadSemanticNeighborhood = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/semantic-neighborhood?top_k=50`,
|
||||
`/api/graph/node/${encodeURIComponent(distanceAnchorNodeId)}/semantic-neighborhood?top_k=50`,
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const data: { anchor_node: string; neighbors: { id: string; type: string; content: string; similarity: number; hop_distance?: number | null }[]; total: number } = await response.json();
|
||||
const simMap = new Map(data.neighbors.map((n) => [n.id, n.similarity]));
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(response.status === 503
|
||||
? "Semantic similarity is unavailable for this graph."
|
||||
: `Semantic distance failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
graph.forEachEdge((edgeId, _attrs, source, target) => {
|
||||
const sim = simMap.get(source === selectedNodeId ? target : source);
|
||||
if (sim == null) {
|
||||
graph.setEdgeAttribute(edgeId, "color", "rgba(100,120,140,0.18)");
|
||||
return;
|
||||
const data: SemanticNeighborhoodResponse = await response.json();
|
||||
const scores = data.neighbors.reduce<Record<string, number>>((nextScores, neighbor) => {
|
||||
if (Number.isFinite(neighbor.similarity)) {
|
||||
nextScores[neighbor.id] = neighbor.similarity;
|
||||
}
|
||||
const edgeColor =
|
||||
sim > 0.7 ? `rgba(86,211,100,${0.3 + sim * 0.4})` :
|
||||
sim > 0.4 ? `rgba(227,179,65,${0.25 + sim * 0.35})` :
|
||||
`rgba(255,123,114,${0.2 + sim * 0.3})`;
|
||||
graph.setEdgeAttribute(edgeId, "color", edgeColor);
|
||||
});
|
||||
return nextScores;
|
||||
}, {});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setGraphVersion((v) => v + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
setSemanticDistanceState({
|
||||
anchorNodeId: distanceAnchorNodeId,
|
||||
scores,
|
||||
count: Object.keys(scores).length,
|
||||
status: Object.keys(scores).length > 0 ? "ready" : "unavailable",
|
||||
error: Object.keys(scores).length > 0 ? null : "No semantic neighbors were returned for this node.",
|
||||
});
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setSemanticDistanceState({
|
||||
anchorNodeId: distanceAnchorNodeId,
|
||||
scores: EMPTY_DISTANCE_RECORD,
|
||||
count: 0,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : "Semantic distance could not be loaded.",
|
||||
});
|
||||
} catch {
|
||||
// semantic mode falls back to no coloring on fetch error
|
||||
}
|
||||
};
|
||||
|
||||
void fetchSemantic();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [distanceMode, selectedNodeId]);
|
||||
void loadSemanticNeighborhood();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [distanceAnchorNodeId, distanceMode]);
|
||||
|
||||
const distanceVisualState = useMemo<GraphDistanceVisualState>(() => {
|
||||
if (activeDistanceMode === "off") {
|
||||
return {
|
||||
mode: "off",
|
||||
anchorNodeId: null,
|
||||
anchorLabel: null,
|
||||
maxHops: distanceMaxHops,
|
||||
structuralDistances: EMPTY_DISTANCE_RECORD,
|
||||
semanticScores: EMPTY_DISTANCE_RECORD,
|
||||
distanceCounts: undefined,
|
||||
outsideCount: 0,
|
||||
heatmapVisibleNodeIds: undefined,
|
||||
heatmapRingCounts: undefined,
|
||||
heatmapRenderedRingCounts: undefined,
|
||||
heatmapSaturationMode: undefined,
|
||||
semanticNeighborCount: 0,
|
||||
status: "idle",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (viewMode !== "full") {
|
||||
return {
|
||||
mode: activeDistanceMode,
|
||||
anchorNodeId: null,
|
||||
anchorLabel: null,
|
||||
maxHops: distanceMaxHops,
|
||||
structuralDistances: EMPTY_DISTANCE_RECORD,
|
||||
semanticScores: EMPTY_DISTANCE_RECORD,
|
||||
distanceCounts: undefined,
|
||||
outsideCount: graph.order,
|
||||
heatmapVisibleNodeIds: undefined,
|
||||
heatmapRingCounts: undefined,
|
||||
heatmapRenderedRingCounts: undefined,
|
||||
heatmapSaturationMode: undefined,
|
||||
semanticNeighborCount: 0,
|
||||
status: "unavailable",
|
||||
error: "Distance intelligence is available in Full Graph mode.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!distanceAnchorNodeId) {
|
||||
return {
|
||||
mode: activeDistanceMode,
|
||||
anchorNodeId: null,
|
||||
anchorLabel: null,
|
||||
maxHops: distanceMaxHops,
|
||||
structuralDistances: EMPTY_DISTANCE_RECORD,
|
||||
semanticScores: EMPTY_DISTANCE_RECORD,
|
||||
distanceCounts: undefined,
|
||||
outsideCount: graph.order,
|
||||
heatmapVisibleNodeIds: undefined,
|
||||
heatmapRingCounts: undefined,
|
||||
heatmapRenderedRingCounts: undefined,
|
||||
heatmapSaturationMode: undefined,
|
||||
semanticNeighborCount: 0,
|
||||
status: "unavailable",
|
||||
error: "Select a node to activate distance intelligence.",
|
||||
};
|
||||
}
|
||||
|
||||
if (activeDistanceMode === "semantic") {
|
||||
return {
|
||||
mode: "semantic",
|
||||
anchorNodeId: distanceAnchorNodeId,
|
||||
anchorLabel: distanceAnchorLabel,
|
||||
maxHops: distanceMaxHops,
|
||||
structuralDistances,
|
||||
semanticScores: semanticDistanceState.anchorNodeId === distanceAnchorNodeId
|
||||
? semanticDistanceState.scores
|
||||
: EMPTY_DISTANCE_RECORD,
|
||||
distanceCounts,
|
||||
outsideCount: distanceCounts.outside,
|
||||
heatmapVisibleNodeIds: undefined,
|
||||
heatmapRingCounts: undefined,
|
||||
heatmapRenderedRingCounts: undefined,
|
||||
heatmapSaturationMode: undefined,
|
||||
semanticNeighborCount: semanticDistanceState.anchorNodeId === distanceAnchorNodeId
|
||||
? semanticDistanceState.count
|
||||
: 0,
|
||||
status: semanticDistanceState.anchorNodeId === distanceAnchorNodeId
|
||||
? semanticDistanceState.status
|
||||
: "loading",
|
||||
error: semanticDistanceState.anchorNodeId === distanceAnchorNodeId
|
||||
? semanticDistanceState.error
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: activeDistanceMode,
|
||||
anchorNodeId: distanceAnchorNodeId,
|
||||
anchorLabel: distanceAnchorLabel,
|
||||
maxHops: distanceMaxHops,
|
||||
structuralDistances,
|
||||
semanticScores: EMPTY_DISTANCE_RECORD,
|
||||
distanceCounts,
|
||||
outsideCount: distanceCounts.outside,
|
||||
heatmapVisibleNodeIds: heatmapRenderSnapshot?.visibleNodeIds,
|
||||
heatmapRingCounts: heatmapRenderSnapshot?.ringCounts,
|
||||
heatmapRenderedRingCounts: heatmapRenderSnapshot?.renderedRingCounts,
|
||||
heatmapSaturationMode: heatmapRenderSnapshot?.saturationMode,
|
||||
semanticNeighborCount: 0,
|
||||
status: "ready",
|
||||
error: null,
|
||||
};
|
||||
}, [
|
||||
activeDistanceMode,
|
||||
distanceAnchorLabel,
|
||||
distanceAnchorNodeId,
|
||||
distanceCounts,
|
||||
distanceMaxHops,
|
||||
graph.order,
|
||||
heatmapRenderSnapshot,
|
||||
semanticDistanceState,
|
||||
structuralDistances,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress));
|
||||
const showSettlingStatus = graphReady && loadingProgress?.phase === "stabilizing_layout";
|
||||
@@ -2036,6 +2156,7 @@ export function GraphWorkspace() {
|
||||
.map(([panelId]) => panelId),
|
||||
effectsState,
|
||||
edgeClasses: graphDiagnosticsState.edgeClasses,
|
||||
distanceVisual: graphDiagnosticsState.distanceVisual,
|
||||
effectAvailability: graphDiagnosticsState.effectAvailability,
|
||||
};
|
||||
}, [activePlugins, effectsState, graphDiagnosticsState, pluginPanelState]);
|
||||
@@ -2331,7 +2452,7 @@ export function GraphWorkspace() {
|
||||
const searchDisabled = showLoadingOverlay || !searchQuery.trim();
|
||||
|
||||
const distanceToolbarItems = useMemo<GraphToolbarItem[]>(() => {
|
||||
if (!hasGraphContent || !selectedNodeId) {
|
||||
if (!hasGraphContent || viewMode !== "full" || !selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -2345,7 +2466,8 @@ export function GraphWorkspace() {
|
||||
active: egoModeEnabled,
|
||||
onClick: () => {
|
||||
setEgoModeEnabled((v) => !v);
|
||||
if (heatmapEnabled) setHeatmapEnabled(false);
|
||||
setHeatmapEnabled(false);
|
||||
setDistanceMode("off");
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2357,7 +2479,8 @@ export function GraphWorkspace() {
|
||||
active: heatmapEnabled,
|
||||
onClick: () => {
|
||||
setHeatmapEnabled((v) => !v);
|
||||
if (egoModeEnabled) setEgoModeEnabled(false);
|
||||
setEgoModeEnabled(false);
|
||||
setDistanceMode("off");
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2365,17 +2488,25 @@ export function GraphWorkspace() {
|
||||
label: "Structural",
|
||||
title: "Color edges by structural (hop) distance",
|
||||
active: distanceMode === "structural",
|
||||
onClick: () => setDistanceMode((m) => (m === "structural" ? "off" : "structural")),
|
||||
onClick: () => {
|
||||
setEgoModeEnabled(false);
|
||||
setHeatmapEnabled(false);
|
||||
setDistanceMode((m) => (m === "structural" ? "off" : "structural"));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dist-semantic",
|
||||
label: "Semantic",
|
||||
title: "Color edges by semantic similarity to selected node",
|
||||
active: distanceMode === "semantic",
|
||||
onClick: () => setDistanceMode((m) => (m === "semantic" ? "off" : "semantic")),
|
||||
onClick: () => {
|
||||
setEgoModeEnabled(false);
|
||||
setHeatmapEnabled(false);
|
||||
setDistanceMode((m) => (m === "semantic" ? "off" : "semantic"));
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [distanceMode, egoMaxHops, egoModeEnabled, hasGraphContent, heatmapEnabled, selectedNodeId]);
|
||||
}, [distanceMode, egoMaxHops, egoModeEnabled, hasGraphContent, heatmapEnabled, selectedNodeId, viewMode]);
|
||||
|
||||
const toolbarClusters = useMemo<GraphToolbarGroup[]>(() => [
|
||||
{
|
||||
@@ -2430,6 +2561,7 @@ export function GraphWorkspace() {
|
||||
selectedEdgeId,
|
||||
activePath,
|
||||
activePathEdgeIds,
|
||||
distanceVisualState,
|
||||
effectsState,
|
||||
temporalState,
|
||||
isLayoutRunning,
|
||||
@@ -2442,6 +2574,48 @@ export function GraphWorkspace() {
|
||||
onDiagnosticsChange: handleDiagnosticsChange,
|
||||
onAnalyticsChange: handleAnalyticsChange,
|
||||
} as const;
|
||||
const showDistanceStatus = distanceVisualState.mode !== "off";
|
||||
const distanceReachableCount = Object.keys(distanceVisualState.structuralDistances).length;
|
||||
const visibleDistanceCounts = distanceVisualState.distanceCounts;
|
||||
const renderedHeatmapCounts = distanceVisualState.heatmapRenderedRingCounts;
|
||||
const formatRenderedCount = (truth: number, rendered: number | undefined) => {
|
||||
if (rendered == null || rendered >= truth) {
|
||||
return truth.toLocaleString();
|
||||
}
|
||||
return `${truth.toLocaleString()} (${rendered.toLocaleString()} shown)`;
|
||||
};
|
||||
const heatmapDistanceSummary = visibleDistanceCounts
|
||||
? [
|
||||
`${visibleDistanceCounts.anchor.toLocaleString()} anchor`,
|
||||
`${formatRenderedCount(visibleDistanceCounts.oneHop, renderedHeatmapCounts?.oneHop)} 1-hop`,
|
||||
`${formatRenderedCount(visibleDistanceCounts.twoHop, renderedHeatmapCounts?.twoHop)} 2-hop`,
|
||||
`${formatRenderedCount(visibleDistanceCounts.threeHopPlus, renderedHeatmapCounts?.threeHopPlus)} 3+ hop`,
|
||||
`${visibleDistanceCounts.outside.toLocaleString()} outside`,
|
||||
distanceVisualState.heatmapSaturationMode === "sampled" ? "Sampled for readability" : "",
|
||||
].filter(Boolean).join(" · ")
|
||||
: `${distanceReachableCount.toLocaleString()} nodes within ${distanceVisualState.maxHops} hops`;
|
||||
const heatmapRenderedSummary = distanceVisualState.mode === "heatmap" && renderedHeatmapCounts
|
||||
? [
|
||||
`${renderedHeatmapCounts.anchor.toLocaleString()} anchor shown`,
|
||||
`${renderedHeatmapCounts.oneHop.toLocaleString()} 1-hop shown`,
|
||||
`${renderedHeatmapCounts.twoHop.toLocaleString()} 2-hop shown`,
|
||||
`${renderedHeatmapCounts.threeHopPlus.toLocaleString()} 3+ hop shown`,
|
||||
].join(" · ")
|
||||
: null;
|
||||
const distanceLegendItems = distanceVisualState.mode === "heatmap"
|
||||
? [
|
||||
{ label: "Anchor", color: getDistanceBandColor(0) },
|
||||
{ label: "1", color: getDistanceBandColor(1) },
|
||||
{ label: "2", color: getDistanceBandColor(2) },
|
||||
{ label: "3", color: getDistanceBandColor(3) },
|
||||
{ label: "Outside", color: withAlpha(GRAPH_THEME.palette.overview.nodeMuted, 0.38) },
|
||||
]
|
||||
: [
|
||||
{ label: "0h", color: getDistanceBandColor(0) },
|
||||
{ label: "1h", color: getDistanceBandColor(1) },
|
||||
{ label: "2-3h", color: getDistanceBandColor(3) },
|
||||
{ label: "4-6h", color: getDistanceBandColor(6) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="palantir-bg" style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden" }}>
|
||||
@@ -2504,6 +2678,46 @@ export function GraphWorkspace() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDistanceStatus ? (
|
||||
<div style={distanceStatusStripStyle}>
|
||||
<div style={distanceStatusTitleStyle}>
|
||||
<Activity size={14} aria-hidden />
|
||||
<span>Distance Intelligence</span>
|
||||
<span style={distanceModeBadgeStyle}>{distanceVisualState.mode}</span>
|
||||
</div>
|
||||
<div style={distanceStatusMetaStyle}>
|
||||
{distanceVisualState.anchorLabel ? (
|
||||
<span>Anchor: <strong>{distanceVisualState.anchorLabel}</strong></span>
|
||||
) : null}
|
||||
{distanceVisualState.mode === "semantic" ? (
|
||||
<span>
|
||||
{distanceVisualState.status === "loading"
|
||||
? "Loading semantic neighborhood..."
|
||||
: `${distanceVisualState.semanticNeighborCount ?? 0} semantic neighbors`}
|
||||
</span>
|
||||
) : distanceVisualState.mode === "heatmap" ? (
|
||||
<span>{heatmapDistanceSummary}</span>
|
||||
) : (
|
||||
<span>{distanceReachableCount.toLocaleString()} nodes within {distanceVisualState.maxHops} hops</span>
|
||||
)}
|
||||
{distanceVisualState.status === "unavailable" || distanceVisualState.status === "error" ? (
|
||||
<span style={{ color: GRAPH_THEME.ui.control.dangerText }}>{distanceVisualState.error}</span>
|
||||
) : null}
|
||||
{heatmapRenderedSummary ? (
|
||||
<span style={{ color: GRAPH_THEME.ui.text.muted }}>{heatmapRenderedSummary}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={distanceLegendStyle}>
|
||||
{distanceLegendItems.map((item) => (
|
||||
<span key={item.label} style={distanceLegendItemStyle}>
|
||||
<span style={{ ...distanceLegendSwatchStyle, background: item.color }} />
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{searchError ? <div style={{ color: "#ff7b72", fontSize: 12 }}>{searchError}</div> : null}
|
||||
|
||||
{searchResults.length ? (
|
||||
@@ -2748,6 +2962,68 @@ const selectedEdgeNodeChipStyle: React.CSSProperties = {
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const distanceStatusStripStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
background: "linear-gradient(135deg, rgba(98, 226, 205, 0.09), rgba(227, 179, 65, 0.045))",
|
||||
borderRadius: 16,
|
||||
padding: "9px 12px",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.045)",
|
||||
};
|
||||
|
||||
const distanceStatusTitleStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const distanceModeBadgeStyle: React.CSSProperties = {
|
||||
padding: "2px 7px",
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
color: GRAPH_THEME.palette.accent.path,
|
||||
background: "rgba(215, 144, 86, 0.1)",
|
||||
fontSize: 10,
|
||||
};
|
||||
|
||||
const distanceStatusMetaStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
flexWrap: "wrap",
|
||||
color: GRAPH_THEME.ui.text.body,
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const distanceLegendStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginLeft: "auto",
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
const distanceLegendItemStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
};
|
||||
|
||||
const distanceLegendSwatchStyle: React.CSSProperties = {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
};
|
||||
|
||||
const selectedEdgePropertyGridStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
|
||||
|
||||
@@ -22,8 +22,11 @@ import {
|
||||
import { classifyEntityShape } from "./graphEntityShape";
|
||||
import { computeGraphAnalyticsBase } from "./graphAnalytics";
|
||||
import type {
|
||||
GraphDistanceBucketCounts,
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDistanceVisualState,
|
||||
GraphHeatmapRenderSnapshot,
|
||||
GraphFullEdgeClass,
|
||||
GraphInteractionState,
|
||||
GraphSelectedNodeKind,
|
||||
@@ -39,6 +42,19 @@ 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>;
|
||||
|
||||
@@ -112,6 +128,404 @@ export type ResolvedEdgeStyle = {
|
||||
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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/gra
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphCameraState,
|
||||
GraphDistanceVisualState,
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphEffectsState,
|
||||
@@ -51,6 +52,7 @@ export interface GraphSceneProps extends GraphSceneEventMap {
|
||||
selectedEdgeId: string;
|
||||
activePath?: string[];
|
||||
activePathEdgeIds?: string[];
|
||||
distanceVisualState?: GraphDistanceVisualState;
|
||||
effectsState: GraphEffectsState;
|
||||
temporalState?: GraphTemporalState | null;
|
||||
isLayoutRunning: boolean;
|
||||
|
||||
@@ -14,6 +14,43 @@ export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "ne
|
||||
export type GraphEdgeInteractionState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphFullEdgeClass = "hidden" | "backbone" | "bridge" | "local-context" | "selected" | "path" | "muted";
|
||||
export type GraphSelectedNodeKind = "none" | "base" | "grouped" | "unavailable";
|
||||
export type GraphDistanceVisualMode = "off" | "ego" | "heatmap" | "structural" | "semantic";
|
||||
export type GraphDistanceVisualStatus = "idle" | "loading" | "ready" | "unavailable" | "error";
|
||||
|
||||
export interface GraphDistanceBucketCounts {
|
||||
anchor: number;
|
||||
oneHop: number;
|
||||
twoHop: number;
|
||||
threeHopPlus: number;
|
||||
outside: number;
|
||||
}
|
||||
|
||||
export type GraphHeatmapSaturationMode = "normal" | "sampled";
|
||||
|
||||
export interface GraphHeatmapRenderSnapshot {
|
||||
visibleNodeIds: string[];
|
||||
ringCounts: GraphDistanceBucketCounts;
|
||||
renderedRingCounts: GraphDistanceBucketCounts;
|
||||
saturationMode: GraphHeatmapSaturationMode;
|
||||
}
|
||||
|
||||
export interface GraphDistanceVisualState {
|
||||
mode: GraphDistanceVisualMode;
|
||||
anchorNodeId: string | null;
|
||||
anchorLabel?: string | null;
|
||||
maxHops: number;
|
||||
structuralDistances: Record<string, number>;
|
||||
semanticScores: Record<string, number>;
|
||||
distanceCounts?: GraphDistanceBucketCounts;
|
||||
outsideCount?: number;
|
||||
heatmapVisibleNodeIds?: string[];
|
||||
heatmapRingCounts?: GraphDistanceBucketCounts;
|
||||
heatmapRenderedRingCounts?: GraphDistanceBucketCounts;
|
||||
heatmapSaturationMode?: GraphHeatmapSaturationMode;
|
||||
semanticNeighborCount?: number;
|
||||
status: GraphDistanceVisualStatus;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface GraphCameraState {
|
||||
x: number;
|
||||
@@ -127,6 +164,7 @@ export interface GraphRuntimeDiagnosticsSnapshot {
|
||||
effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"];
|
||||
edgeClasses?: GraphFullEdgeClassDiagnostics;
|
||||
structureLayer?: GraphStructureLayerDiagnostics;
|
||||
distanceVisual?: GraphDistanceVisualState;
|
||||
}
|
||||
|
||||
export interface GraphDiagnosticsSnapshot {
|
||||
@@ -136,6 +174,7 @@ export interface GraphDiagnosticsSnapshot {
|
||||
effectsState: GraphEffectsState;
|
||||
edgeClasses?: GraphFullEdgeClassDiagnostics;
|
||||
structureLayer?: GraphStructureLayerDiagnostics;
|
||||
distanceVisual?: GraphDistanceVisualState;
|
||||
effectAvailability: {
|
||||
pathPulse: GraphEffectAvailability;
|
||||
pathFlow: GraphEffectAvailability;
|
||||
|
||||
@@ -12,21 +12,26 @@ import {
|
||||
computeGraphAnalyticsBase,
|
||||
} from "../src/workspaces/GraphWorkspace/graphAnalytics.ts";
|
||||
import {
|
||||
buildHeatmapRenderSnapshot,
|
||||
buildStructuralDistanceSnapshot,
|
||||
classifyFullGraphEdge,
|
||||
checkGroupedViewAvailability,
|
||||
mapFullEdgeClassToVisualState,
|
||||
resolveDistanceEdgeStyle,
|
||||
resolveDistanceNodeStyle,
|
||||
resolveEdgeElementStyle,
|
||||
resolveEdgeVisualState,
|
||||
resolveDisplayGraph,
|
||||
resolveGroupedDisplayNodeId,
|
||||
resolveGroupedDisplayStateSnapshot,
|
||||
summarizeDistanceBuckets,
|
||||
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
|
||||
import {
|
||||
buildGraphStructureCurveCache,
|
||||
evaluateGraphStructureLayerGate,
|
||||
} from "../src/workspaces/GraphWorkspace/graphStructureLayer.ts";
|
||||
import { GRAPH_THEME } from "../src/workspaces/GraphWorkspace/graphTheme.ts";
|
||||
import type { GraphFullEdgeClass, GraphFullEdgeClassCounts } from "../src/workspaces/GraphWorkspace/types.ts";
|
||||
import type { GraphDistanceVisualState, GraphFullEdgeClass, GraphFullEdgeClassCounts } from "../src/workspaces/GraphWorkspace/types.ts";
|
||||
|
||||
function addNode(id: string, semanticGroup = "entity") {
|
||||
batchMergeNodes([
|
||||
@@ -75,6 +80,281 @@ test.after(() => {
|
||||
clearGraph();
|
||||
});
|
||||
|
||||
const BASE_NODE_STYLE = {
|
||||
color: "#63E6FF",
|
||||
shellColor: "#63E6FF",
|
||||
coreScale: 1,
|
||||
size: 8,
|
||||
forceLabel: false,
|
||||
label: "node",
|
||||
zIndex: 1,
|
||||
hidden: false,
|
||||
borderColor: "#63E6FF",
|
||||
borderSize: 1,
|
||||
nodeVariant: "default",
|
||||
entityShape: "entity",
|
||||
entityShapeKind: 0,
|
||||
entityAspectRatio: 1,
|
||||
showBadge: false,
|
||||
showRing: false,
|
||||
ringSize: 0,
|
||||
showHalo: false,
|
||||
haloColor: "transparent",
|
||||
} as const;
|
||||
|
||||
const BASE_EDGE_STYLE = {
|
||||
hidden: true,
|
||||
color: "#334155",
|
||||
size: 0.5,
|
||||
zIndex: 0,
|
||||
edgeVariant: "line",
|
||||
arrowVisibilityPolicy: "hidden",
|
||||
curveStrength: 0,
|
||||
curvature: 0,
|
||||
} as const;
|
||||
|
||||
function makeDistanceState(overrides: Partial<GraphDistanceVisualState>): GraphDistanceVisualState {
|
||||
return {
|
||||
mode: "off",
|
||||
anchorNodeId: null,
|
||||
anchorLabel: null,
|
||||
maxHops: 2,
|
||||
structuralDistances: {},
|
||||
semanticScores: {},
|
||||
semanticNeighborCount: 0,
|
||||
status: "ready",
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildStructuralDistanceSnapshot returns bounded BFS hop distances", () => {
|
||||
addNode("anchor");
|
||||
addNode("near");
|
||||
addNode("far");
|
||||
addNode("outside");
|
||||
addNode("too-far");
|
||||
addEdge("e-anchor-near", "anchor", "near");
|
||||
addEdge("e-near-far", "near", "far");
|
||||
addEdge("e-far-outside", "far", "outside");
|
||||
addEdge("e-outside-too-far", "outside", "too-far");
|
||||
|
||||
const distances = buildStructuralDistanceSnapshot(graph, "anchor", 3);
|
||||
|
||||
assert.equal(distances.anchor, 0);
|
||||
assert.equal(distances.near, 1);
|
||||
assert.equal(distances.far, 2);
|
||||
assert.equal(distances.outside, 3);
|
||||
assert.equal(distances["too-far"], undefined);
|
||||
});
|
||||
|
||||
test("summarizeDistanceBuckets reports local rings and outside count", () => {
|
||||
const counts = summarizeDistanceBuckets({
|
||||
anchor: 0,
|
||||
one: 1,
|
||||
two: 2,
|
||||
three: 3,
|
||||
}, 6);
|
||||
|
||||
assert.deepEqual(counts, {
|
||||
anchor: 1,
|
||||
oneHop: 1,
|
||||
twoHop: 1,
|
||||
threeHop: 1,
|
||||
outside: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("buildHeatmapRenderSnapshot caps and deterministically samples large rings", () => {
|
||||
addNode("anchor");
|
||||
for (let index = 0; index < 130; index += 1) {
|
||||
const nodeId = `one-${index}`;
|
||||
addNode(nodeId);
|
||||
graph.mergeNodeAttributes(nodeId, { visualPriority: index % 7, labelPriority: index % 5 });
|
||||
addEdge(`edge-anchor-${nodeId}`, "anchor", nodeId, index % 11);
|
||||
}
|
||||
for (let index = 0; index < 700; index += 1) {
|
||||
const nodeId = `two-${index}`;
|
||||
addNode(nodeId);
|
||||
graph.mergeNodeAttributes(nodeId, { visualPriority: index % 13, labelPriority: index % 3 });
|
||||
addEdge(`edge-one-two-${index}`, `one-${index % 130}`, nodeId, index % 17);
|
||||
}
|
||||
for (let index = 0; index < 950; index += 1) {
|
||||
const nodeId = `three-${index}`;
|
||||
addNode(nodeId);
|
||||
graph.mergeNodeAttributes(nodeId, { visualPriority: index % 19, labelPriority: index % 4 });
|
||||
addEdge(`edge-two-three-${index}`, `two-${index % 700}`, nodeId, index % 23);
|
||||
}
|
||||
|
||||
const distances = buildStructuralDistanceSnapshot(graph, "anchor", 3);
|
||||
const firstSnapshot = buildHeatmapRenderSnapshot(graph, "anchor", distances, 3);
|
||||
const secondSnapshot = buildHeatmapRenderSnapshot(graph, "anchor", distances, 3);
|
||||
|
||||
assert.equal(firstSnapshot.ringCounts.anchor, 1);
|
||||
assert.equal(firstSnapshot.ringCounts.oneHop, 130);
|
||||
assert.equal(firstSnapshot.ringCounts.twoHop, 700);
|
||||
assert.equal(firstSnapshot.ringCounts.threeHop, 950);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.anchor, 1);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.oneHop, 120);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.twoHop, 650);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.threeHop, 900);
|
||||
assert.equal(firstSnapshot.saturationMode, "sampled");
|
||||
assert.deepEqual(firstSnapshot.visibleNodeIds, secondSnapshot.visibleNodeIds);
|
||||
assert.ok(firstSnapshot.visibleNodeIds.includes("anchor"));
|
||||
});
|
||||
|
||||
test("resolveDistanceNodeStyle applies ego muting without mutating graph data", () => {
|
||||
const state = makeDistanceState({
|
||||
mode: "ego",
|
||||
anchorNodeId: "anchor",
|
||||
anchorLabel: "Anchor",
|
||||
maxHops: 2,
|
||||
structuralDistances: { anchor: 0, near: 1 },
|
||||
});
|
||||
|
||||
const anchorStyle = resolveDistanceNodeStyle(GRAPH_THEME, "inspection", BASE_NODE_STYLE, state, "anchor");
|
||||
const outsideStyle = resolveDistanceNodeStyle(GRAPH_THEME, "inspection", BASE_NODE_STYLE, state, "outside");
|
||||
|
||||
assert.equal(anchorStyle.forceLabel, true);
|
||||
assert.equal(anchorStyle.label, "Anchor");
|
||||
assert.ok(Number(anchorStyle.size) > BASE_NODE_STYLE.size);
|
||||
assert.equal(outsideStyle.label, "");
|
||||
assert.ok(Number(outsideStyle.size) < BASE_NODE_STYLE.size);
|
||||
});
|
||||
|
||||
test("resolveDistanceNodeStyle applies readable heatmap rings only when ready", () => {
|
||||
const readyState = makeDistanceState({
|
||||
mode: "heatmap",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 3,
|
||||
structuralDistances: { anchor: 0, one: 1, two: 2, three: 3 },
|
||||
heatmapVisibleNodeIds: ["anchor", "one", "two", "three"],
|
||||
distanceCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 1,
|
||||
twoHop: 1,
|
||||
threeHop: 1,
|
||||
outside: 1,
|
||||
},
|
||||
});
|
||||
const loadingState = makeDistanceState({ ...readyState, status: "loading" });
|
||||
|
||||
const anchorStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "anchor");
|
||||
const oneHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "one");
|
||||
const twoHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "two");
|
||||
const threeHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "three");
|
||||
const outsideStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "outside");
|
||||
const loadingStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, loadingState, "near");
|
||||
|
||||
assert.notEqual(anchorStyle.color, oneHopStyle.color);
|
||||
assert.notEqual(oneHopStyle.color, twoHopStyle.color);
|
||||
assert.notEqual(twoHopStyle.color, threeHopStyle.color);
|
||||
assert.ok(Number(anchorStyle.size) > Number(oneHopStyle.size));
|
||||
assert.ok(Number(oneHopStyle.size) > Number(twoHopStyle.size));
|
||||
assert.ok(Number(twoHopStyle.size) > Number(threeHopStyle.size));
|
||||
assert.equal(outsideStyle.label, "");
|
||||
assert.ok(Number(outsideStyle.size) < BASE_NODE_STYLE.size);
|
||||
assert.deepEqual(loadingStyle, {});
|
||||
});
|
||||
|
||||
test("resolveDistanceNodeStyle compresses saturated heatmap far rings", () => {
|
||||
const saturatedState = makeDistanceState({
|
||||
mode: "heatmap",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 3,
|
||||
structuralDistances: { anchor: 0, one: 1, two: 2, three: 3 },
|
||||
heatmapVisibleNodeIds: ["anchor", "one", "two", "three"],
|
||||
distanceCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 32,
|
||||
twoHop: 3350,
|
||||
threeHop: 7412,
|
||||
outside: 3280,
|
||||
},
|
||||
});
|
||||
|
||||
const oneHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "one");
|
||||
const twoHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "two");
|
||||
const threeHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "three");
|
||||
const outsideStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "outside");
|
||||
|
||||
assert.ok(Number(oneHopStyle.size) > Number(twoHopStyle.size));
|
||||
assert.ok(Number(twoHopStyle.size) > Number(threeHopStyle.size));
|
||||
assert.ok(Number(threeHopStyle.size) > Number(outsideStyle.size));
|
||||
assert.equal(threeHopStyle.label, "");
|
||||
});
|
||||
|
||||
test("resolveDistanceNodeStyle mutes unsampled heatmap nodes instead of coloring them", () => {
|
||||
const sampledState = makeDistanceState({
|
||||
mode: "heatmap",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 3,
|
||||
structuralDistances: { anchor: 0, rendered: 2, unsampled: 2 },
|
||||
heatmapVisibleNodeIds: ["anchor", "rendered"],
|
||||
distanceCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 0,
|
||||
twoHop: 2,
|
||||
threeHop: 0,
|
||||
outside: 0,
|
||||
},
|
||||
heatmapRenderedRingCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 0,
|
||||
twoHop: 1,
|
||||
threeHop: 0,
|
||||
outside: 0,
|
||||
},
|
||||
heatmapSaturationMode: "sampled",
|
||||
});
|
||||
|
||||
const renderedStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, sampledState, "rendered");
|
||||
const unsampledStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, sampledState, "unsampled");
|
||||
|
||||
assert.notEqual(renderedStyle.color, unsampledStyle.color);
|
||||
assert.ok(Number(renderedStyle.size) > Number(unsampledStyle.size));
|
||||
assert.equal(unsampledStyle.label, "");
|
||||
});
|
||||
|
||||
test("resolveDistanceEdgeStyle reveals structural and semantic context edges", () => {
|
||||
const structuralState = makeDistanceState({
|
||||
mode: "structural",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 2,
|
||||
structuralDistances: { anchor: 0, near: 1 },
|
||||
});
|
||||
const semanticState = makeDistanceState({
|
||||
mode: "semantic",
|
||||
anchorNodeId: "anchor",
|
||||
semanticScores: { semantic: 0.82 },
|
||||
});
|
||||
|
||||
const structuralStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, structuralState, "anchor", "near");
|
||||
const semanticStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, semanticState, "anchor", "semantic");
|
||||
const unrelatedStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, semanticState, "near", "semantic");
|
||||
|
||||
assert.equal(structuralStyle.hidden, false);
|
||||
assert.equal(semanticStyle.hidden, false);
|
||||
assert.deepEqual(unrelatedStyle, {});
|
||||
});
|
||||
|
||||
test("resolveDistanceEdgeStyle suppresses heatmap background edges but preserves context", () => {
|
||||
const heatmapState = makeDistanceState({
|
||||
mode: "heatmap",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 3,
|
||||
structuralDistances: { anchor: 0, one: 1 },
|
||||
});
|
||||
|
||||
const backgroundStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, heatmapState, "anchor", "one", "backbone");
|
||||
const contextStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, heatmapState, "anchor", "one", "local-context");
|
||||
const pathStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, heatmapState, "anchor", "one", "path");
|
||||
|
||||
assert.equal(backgroundStyle.hidden, true);
|
||||
assert.deepEqual(contextStyle, {});
|
||||
assert.deepEqual(pathStyle, {});
|
||||
});
|
||||
|
||||
test("resolveEdgeVisualState caps selected-node incident edge promotion", () => {
|
||||
const uncappedState = resolveEdgeVisualState(
|
||||
"edge-1",
|
||||
|
||||
Reference in New Issue
Block a user