mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
feat(explorer): polish graph explorer visual language
This commit is contained in:
@@ -3,6 +3,7 @@ import type {
|
||||
GraphArrowVisibilityPolicy,
|
||||
GraphBadgeKind,
|
||||
GraphEdgeVariant,
|
||||
GraphEntityShapeVariant,
|
||||
GraphLabelVisibilityPolicy,
|
||||
GraphNodeShapeVariant,
|
||||
} from "../workspaces/GraphWorkspace/graphTheme";
|
||||
@@ -36,6 +37,7 @@ export interface NodeAttributes {
|
||||
borderSize?: number;
|
||||
nodeVariant?: GraphNodeShapeVariant;
|
||||
nodeShapeVariant?: GraphNodeShapeVariant;
|
||||
entityShape?: GraphEntityShapeVariant;
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeCount?: number;
|
||||
ringColor?: string;
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
collectInteractionRefreshTargets,
|
||||
createInteractionState,
|
||||
isEdgeInteractable,
|
||||
classifyFullGraphEdge,
|
||||
mapFullEdgeClassToVisualState,
|
||||
resolveEdgeElementStyle,
|
||||
resolveEdgeVisualState,
|
||||
resolveNodeElementStyle,
|
||||
@@ -47,6 +49,15 @@ import {
|
||||
drawSemanticaNodeHover,
|
||||
drawSemanticaNodeLabel,
|
||||
} from "./sigmaNativeRendering";
|
||||
import {
|
||||
buildGraphStructureCurveCache,
|
||||
clearGraphStructureLayer,
|
||||
createGraphStructureCacheKey,
|
||||
drawGraphStructureLayer,
|
||||
evaluateGraphStructureLayerGate,
|
||||
getGraphStructureLayerDiagnostics,
|
||||
type GraphStructureCurveCache,
|
||||
} from "./graphStructureLayer";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphCameraState,
|
||||
@@ -54,8 +65,13 @@ import type {
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphFullEdgeClass,
|
||||
GraphFullEdgeClassCounts,
|
||||
GraphFullEdgeClassDiagnostics,
|
||||
GraphInteractionState,
|
||||
GraphLayoutStatus,
|
||||
GraphRuntimeDiagnosticsSnapshot,
|
||||
GraphStructureLayerDiagnostics,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
@@ -98,7 +114,7 @@ export interface GraphCanvasProps {
|
||||
onSceneRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
|
||||
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
|
||||
onCameraStateChange?: (cameraState: GraphCameraState) => void;
|
||||
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
|
||||
onDiagnosticsChange?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => void;
|
||||
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
|
||||
}
|
||||
|
||||
@@ -800,6 +816,7 @@ function drawNodeBadge(
|
||||
}
|
||||
|
||||
type ReducerSceneState = {
|
||||
viewMode: GraphViewMode;
|
||||
zoomTier: GraphZoomTier;
|
||||
hoveredNodeId: string | null;
|
||||
selectedNodeId: string;
|
||||
@@ -810,15 +827,133 @@ type ReducerSceneState = {
|
||||
edgeEndpointIds: Set<string>;
|
||||
pathNodeIds: Set<string>;
|
||||
pathEdgeIds: Set<string>;
|
||||
highlightedIncidentEdgeIds: Set<string>;
|
||||
overviewBackboneEdgeIds: Set<string>;
|
||||
};
|
||||
|
||||
const FULL_EDGE_CLASSES: GraphFullEdgeClass[] = [
|
||||
"hidden",
|
||||
"backbone",
|
||||
"bridge",
|
||||
"local-context",
|
||||
"selected",
|
||||
"path",
|
||||
"muted",
|
||||
];
|
||||
|
||||
function createFullEdgeClassCounts(): GraphFullEdgeClassCounts {
|
||||
return FULL_EDGE_CLASSES.reduce((counts, edgeClass) => {
|
||||
counts[edgeClass] = 0;
|
||||
return counts;
|
||||
}, {} as GraphFullEdgeClassCounts);
|
||||
}
|
||||
|
||||
function getIncidentEdgeRevealCap(viewMode: GraphViewMode, zoomTier: GraphZoomTier): number {
|
||||
return GRAPH_THEME.edges.contextCaps[viewMode]?.[zoomTier] ?? 0;
|
||||
}
|
||||
|
||||
function scoreIncidentEdge(
|
||||
attrs: EdgeAttributes,
|
||||
edgeId: string,
|
||||
otherEndpointId: string,
|
||||
visibleNeighborIds: Set<string>,
|
||||
focusIds: Set<string>,
|
||||
pathEdgeIds: Set<string>,
|
||||
selectedEdgeId: string,
|
||||
): number {
|
||||
if (pathEdgeIds.has(edgeId)) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
if (selectedEdgeId && edgeId === selectedEdgeId) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
const weight = Math.max(Number(attrs.weight ?? attrs.representativeWeight ?? 1) || 1, 1);
|
||||
const normalizedWeight = Math.min(1, Math.log1p(weight) / Math.log(25));
|
||||
const visualPriority = Math.max(0, Math.min(Number(attrs.visualPriority ?? 0), 1));
|
||||
const relationshipStrength = Math.max(0, Math.min(Number(attrs.relationshipStrength ?? 0), 1));
|
||||
|
||||
return (visibleNeighborIds.has(otherEndpointId) ? 6 : 0)
|
||||
+ (focusIds.has(otherEndpointId) ? 1.25 : 0)
|
||||
+ visualPriority * 2
|
||||
+ normalizedWeight * 1.4
|
||||
+ relationshipStrength;
|
||||
}
|
||||
|
||||
function buildHighlightedIncidentEdgeIds(
|
||||
displayGraph: GraphSceneGraph,
|
||||
interactionState: GraphInteractionState,
|
||||
displayState: GraphDisplayStateSnapshot | undefined,
|
||||
focusIds: Set<string>,
|
||||
pathEdgeIds: Set<string>,
|
||||
): Set<string> {
|
||||
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
||||
if (!primaryNodeId || !displayGraph.hasNode(primaryNodeId)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const cap = getIncidentEdgeRevealCap(interactionState.viewMode, interactionState.zoomTier);
|
||||
if (cap <= 0 && !interactionState.selectedEdgeId && pathEdgeIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const visibleNeighborIds = new Set(
|
||||
(displayState?.selectedVisibleNeighborIds ?? [])
|
||||
.filter((nodeId) => displayGraph.hasNode(nodeId)),
|
||||
);
|
||||
const candidates: Array<{ edgeId: string; score: number }> = [];
|
||||
|
||||
displayGraph.edges(primaryNodeId).forEach((edge) => {
|
||||
const edgeId = String(edge);
|
||||
if (!displayGraph.hasEdge(edgeId)) {
|
||||
return;
|
||||
}
|
||||
const [source, target] = displayGraph.extremities(edgeId);
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
const otherEndpointId = sourceId === primaryNodeId ? targetId : sourceId;
|
||||
const attrs = displayGraph.getEdgeAttributes(edgeId) as EdgeAttributes;
|
||||
candidates.push({
|
||||
edgeId,
|
||||
score: scoreIncidentEdge(
|
||||
attrs,
|
||||
edgeId,
|
||||
otherEndpointId,
|
||||
visibleNeighborIds,
|
||||
focusIds,
|
||||
pathEdgeIds,
|
||||
interactionState.selectedEdgeId,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
});
|
||||
|
||||
const selected = new Set<string>();
|
||||
candidates.forEach((candidate) => {
|
||||
if (
|
||||
candidate.edgeId === interactionState.selectedEdgeId
|
||||
|| pathEdgeIds.has(candidate.edgeId)
|
||||
|| selected.size < cap
|
||||
) {
|
||||
selected.add(candidate.edgeId);
|
||||
}
|
||||
});
|
||||
return selected;
|
||||
}
|
||||
|
||||
function buildReducerSceneState(
|
||||
displayGraph: GraphSceneGraph,
|
||||
interactionState: GraphInteractionState,
|
||||
analyticsSnapshot: GraphAnalyticsSnapshot | null,
|
||||
displayState?: GraphDisplayStateSnapshot,
|
||||
analyticsSnapshot?: GraphAnalyticsSnapshot | null,
|
||||
): ReducerSceneState {
|
||||
const { zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
|
||||
const { viewMode, zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
|
||||
const primaryNodeId = hoveredNodeId || selectedNodeId;
|
||||
const focusIds = primaryNodeId
|
||||
? (
|
||||
@@ -827,8 +962,10 @@ function buildReducerSceneState(
|
||||
: new Set<string>()
|
||||
)
|
||||
: new Set<string>();
|
||||
const pathEdgeIds = buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds);
|
||||
|
||||
return {
|
||||
viewMode,
|
||||
zoomTier,
|
||||
hoveredNodeId,
|
||||
selectedNodeId,
|
||||
@@ -838,8 +975,74 @@ function buildReducerSceneState(
|
||||
focusIds,
|
||||
edgeEndpointIds: buildEdgeEndpointSet(displayGraph, selectedEdgeId),
|
||||
pathNodeIds: new Set(activePath),
|
||||
pathEdgeIds: buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds),
|
||||
pathEdgeIds,
|
||||
overviewBackboneEdgeIds: new Set(analyticsSnapshot?.overviewBackbone.edgeIds ?? []),
|
||||
highlightedIncidentEdgeIds: buildHighlightedIncidentEdgeIds(
|
||||
displayGraph,
|
||||
interactionState,
|
||||
displayState,
|
||||
focusIds,
|
||||
pathEdgeIds,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getFullGraphEdgeClass(
|
||||
displayGraph: GraphSceneGraph,
|
||||
edgeId: string,
|
||||
currentState: ReducerSceneState,
|
||||
): GraphFullEdgeClass {
|
||||
if (!displayGraph.hasEdge(edgeId)) {
|
||||
return "hidden";
|
||||
}
|
||||
|
||||
const [source, target] = displayGraph.extremities(edgeId);
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
const sourceAttrs = displayGraph.hasNode(sourceId)
|
||||
? displayGraph.getNodeAttributes(sourceId) as NodeAttributes
|
||||
: undefined;
|
||||
const targetAttrs = displayGraph.hasNode(targetId)
|
||||
? displayGraph.getNodeAttributes(targetId) as NodeAttributes
|
||||
: undefined;
|
||||
|
||||
return classifyFullGraphEdge(
|
||||
edgeId,
|
||||
sourceId,
|
||||
targetId,
|
||||
currentState.zoomTier,
|
||||
currentState.hoveredNodeId,
|
||||
currentState.selectedNodeId,
|
||||
currentState.selectedEdgeId,
|
||||
currentState.focusIds,
|
||||
currentState.pathEdgeIds,
|
||||
currentState.highlightedIncidentEdgeIds,
|
||||
currentState.overviewBackboneEdgeIds,
|
||||
sourceAttrs,
|
||||
targetAttrs,
|
||||
);
|
||||
}
|
||||
|
||||
function buildFullGraphEdgeClassDiagnostics(
|
||||
displayGraph: GraphSceneGraph,
|
||||
currentState: ReducerSceneState,
|
||||
): GraphFullEdgeClassDiagnostics {
|
||||
const counts = createFullEdgeClassCounts();
|
||||
|
||||
displayGraph.forEachEdge((edgeId) => {
|
||||
const edgeClass = currentState.viewMode === "full"
|
||||
? getFullGraphEdgeClass(displayGraph, String(edgeId), currentState)
|
||||
: "hidden";
|
||||
counts[edgeClass] += 1;
|
||||
});
|
||||
|
||||
return {
|
||||
mode: currentState.viewMode,
|
||||
zoomTier: currentState.zoomTier,
|
||||
totalEdges: displayGraph.size,
|
||||
visibleEdges: displayGraph.size - counts.hidden,
|
||||
counts,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -904,6 +1107,9 @@ function applySceneState(
|
||||
borderSize: style.borderSize,
|
||||
ringColor: style.showRing ? style.ringColor : style.borderColor,
|
||||
ringSize: style.ringSize,
|
||||
entityShape: style.entityShape,
|
||||
entityShapeKind: style.entityShapeKind,
|
||||
entityAspectRatio: style.entityAspectRatio,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -927,18 +1133,35 @@ function applySceneState(
|
||||
const attrs = data as EdgeAttributes;
|
||||
const [source, target] = currentGraph.extremities(edge);
|
||||
const stableEdgeId = String(edge);
|
||||
const state = resolveEdgeVisualState(
|
||||
stableEdgeId,
|
||||
source,
|
||||
target,
|
||||
currentState.zoomTier,
|
||||
currentState.hoveredNodeId,
|
||||
currentState.selectedNodeId,
|
||||
currentState.selectedEdgeId,
|
||||
currentState.focusIds,
|
||||
currentState.pathEdgeIds,
|
||||
currentState.overviewBackboneEdgeIds,
|
||||
const hasActiveInteraction = Boolean(
|
||||
currentState.hoveredNodeId
|
||||
|| currentState.selectedNodeId
|
||||
|| currentState.selectedEdgeId
|
||||
|| currentState.pathEdgeIds.size > 0,
|
||||
);
|
||||
const fullEdgeClass = currentState.viewMode === "full"
|
||||
? getFullGraphEdgeClass(currentGraph, stableEdgeId, currentState)
|
||||
: undefined;
|
||||
const state = currentState.viewMode === "full"
|
||||
? mapFullEdgeClassToVisualState(
|
||||
fullEdgeClass ?? "hidden",
|
||||
{
|
||||
hoveredNodeId: currentState.hoveredNodeId,
|
||||
hasActiveInteraction,
|
||||
},
|
||||
)
|
||||
: resolveEdgeVisualState(
|
||||
stableEdgeId,
|
||||
String(source),
|
||||
String(target),
|
||||
currentState.zoomTier,
|
||||
currentState.hoveredNodeId,
|
||||
currentState.selectedNodeId,
|
||||
currentState.selectedEdgeId,
|
||||
currentState.focusIds,
|
||||
currentState.pathEdgeIds,
|
||||
currentState.highlightedIncidentEdgeIds,
|
||||
);
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
currentState.zoomTier,
|
||||
@@ -946,6 +1169,9 @@ function applySceneState(
|
||||
attrs,
|
||||
source,
|
||||
target,
|
||||
currentState.viewMode,
|
||||
stableEdgeId,
|
||||
fullEdgeClass,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -1017,6 +1243,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const overlayRef = useRef<HTMLCanvasElement>(null);
|
||||
const structureLayerCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const structureLayerCacheRef = useRef<GraphStructureCurveCache | null>(null);
|
||||
const structureLayerLastDrawAtRef = useRef<number | null>(null);
|
||||
const structureLayerDiagnosticsRef = useRef<GraphStructureLayerDiagnostics | null>(null);
|
||||
const sigmaRef = useRef<Sigma | null>(null);
|
||||
const fa2Ref = useRef<FA2Layout | null>(null);
|
||||
const behaviorContextRef = useRef<GraphBehaviorContext | null>(null);
|
||||
@@ -1037,6 +1267,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||
const [zoomTier, setZoomTier] = useState<GraphZoomTier>("overview");
|
||||
const [analyticsSnapshot, setAnalyticsSnapshot] = useState<GraphAnalyticsSnapshot | null>(null);
|
||||
const [layoutSettledEpoch, setLayoutSettledEpoch] = useState(0);
|
||||
const appliedGraphVersionRef = useRef<number | null>(null);
|
||||
const fittedDisplaySignatureRef = useRef<DisplayFitSignature | null>(null);
|
||||
const layoutSyncFrameRef = useRef<number | null>(null);
|
||||
@@ -1100,6 +1331,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const interactionStateRef = useRef<GraphInteractionState>(interactionState);
|
||||
interactionStateRef.current = interactionState;
|
||||
const previousInteractionStateRef = useRef<GraphInteractionState | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isLayoutRunning) {
|
||||
setLayoutSettledEpoch((epoch) => epoch + 1);
|
||||
}
|
||||
}, [displayGraph, graphVersion, isLayoutRunning]);
|
||||
|
||||
const shouldComputeCommunities = effectsState.communitiesEnabled || effectsState.semanticRegionsEnabled;
|
||||
const shouldComputeCentrality = effectsState.centralityEnabled || effectsState.semanticRegionsEnabled || effectsState.contoursEnabled;
|
||||
const analyticsBase = useMemo(
|
||||
@@ -1110,11 +1347,55 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
[displayGraph, shouldComputeCentrality, shouldComputeCommunities],
|
||||
);
|
||||
const reducerSceneState = useMemo(
|
||||
() => buildReducerSceneState(displayGraph, interactionState, analyticsSnapshot),
|
||||
[analyticsSnapshot, displayGraph, interactionState],
|
||||
() => buildReducerSceneState(displayGraph, interactionState, displayState, analyticsSnapshot),
|
||||
[analyticsSnapshot, displayGraph, displayState, interactionState],
|
||||
);
|
||||
const reducerSceneStateRef = useRef<ReducerSceneState>(reducerSceneState);
|
||||
reducerSceneStateRef.current = reducerSceneState;
|
||||
const edgeClassDiagnostics = useMemo(
|
||||
() => buildFullGraphEdgeClassDiagnostics(displayGraph, reducerSceneState),
|
||||
[displayGraph, reducerSceneState],
|
||||
);
|
||||
const structureLayerGate = useMemo(
|
||||
() => evaluateGraphStructureLayerGate({
|
||||
mode: GRAPH_THEME.edges.fullGraphStructureLayer.mode,
|
||||
viewMode,
|
||||
isLayoutRunning,
|
||||
edgeDiagnostics: edgeClassDiagnostics,
|
||||
minimumLiteralEdges: GRAPH_THEME.edges.fullGraphStructureLayer.minimumLiteralEdges,
|
||||
}),
|
||||
[edgeClassDiagnostics, isLayoutRunning, viewMode],
|
||||
);
|
||||
const structureLayerCache = useMemo(() => {
|
||||
if (!structureLayerGate.enabled) {
|
||||
return null;
|
||||
}
|
||||
const cacheKey = createGraphStructureCacheKey({
|
||||
graphVersion,
|
||||
zoomTier,
|
||||
layoutSettledEpoch,
|
||||
overviewBackboneEdgeIds: reducerSceneState.overviewBackboneEdgeIds,
|
||||
});
|
||||
return buildGraphStructureCurveCache({
|
||||
graphRef: displayGraph,
|
||||
cacheKey,
|
||||
classifyEdge: (edgeId) => getFullGraphEdgeClass(displayGraph, edgeId, reducerSceneState),
|
||||
maxCurves: GRAPH_THEME.edges.fullGraphStructureLayer.maxCurves,
|
||||
curveStrength: GRAPH_THEME.edges.fullGraphStructureLayer.curveStrength,
|
||||
});
|
||||
}, [displayGraph, graphVersion, layoutSettledEpoch, reducerSceneState, structureLayerGate.enabled, zoomTier]);
|
||||
structureLayerCacheRef.current = structureLayerCache;
|
||||
const structureLayerDiagnostics = useMemo(
|
||||
() => getGraphStructureLayerDiagnostics({
|
||||
gate: structureLayerGate,
|
||||
cache: structureLayerCache,
|
||||
minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
|
||||
canvasAvailable: Boolean(structureLayerCanvasRef.current),
|
||||
lastDrawAt: structureLayerLastDrawAtRef.current,
|
||||
}),
|
||||
[structureLayerCache, structureLayerGate],
|
||||
);
|
||||
structureLayerDiagnosticsRef.current = structureLayerDiagnostics;
|
||||
const displayFitSignature = useMemo<DisplayFitSignature>(() => ({
|
||||
graphVersion,
|
||||
viewMode,
|
||||
@@ -1237,17 +1518,40 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
graphVersion: graphVersionRef.current,
|
||||
viewMode: viewModeRef.current,
|
||||
});
|
||||
fitDisplayGraphInView();
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = computeGraphSpaceBounds(currentDisplayGraph, selectionNodeIds);
|
||||
const selectedDisplayNodeId = selectionNodeIds[0];
|
||||
const selectedDisplayData = sigma.getNodeDisplayData(selectedDisplayNodeId);
|
||||
if (selectedDisplayData) {
|
||||
const viewportPoint = sigma.graphToViewport({
|
||||
x: selectedDisplayData.x,
|
||||
y: selectedDisplayData.y,
|
||||
});
|
||||
const dimensions = sigma.getDimensions();
|
||||
if (isPointNearViewport(viewportPoint, dimensions.width, dimensions.height, 96)) {
|
||||
debugGraphRuntime("camera-selection-visible-noop", {
|
||||
nodeId,
|
||||
selectedDisplayNodeId,
|
||||
graphVersion: graphVersionRef.current,
|
||||
viewMode: viewModeRef.current,
|
||||
viewportX: viewportPoint.x,
|
||||
viewportY: viewportPoint.y,
|
||||
});
|
||||
sigma.scheduleRefresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = computeDisplayedNodeBounds(sigma, selectionNodeIds);
|
||||
if (!bounds) {
|
||||
if (attempt < 3) {
|
||||
debugGraphRuntime("camera-selection-deferred", {
|
||||
debugGraphRuntime("camera-selection-display-bounds-deferred", {
|
||||
nodeId,
|
||||
graphVersion: graphVersionRef.current,
|
||||
attempt: attempt + 1,
|
||||
viewMode: viewModeRef.current,
|
||||
contextCount: selectionNodeIds.length,
|
||||
});
|
||||
if (deferredFocusFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(deferredFocusFrameRef.current);
|
||||
@@ -1257,45 +1561,43 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
centerSelectionInViewInternal(nodeId, attempt + 1);
|
||||
});
|
||||
} else {
|
||||
debugGraphRuntime("camera-selection-fallback-fit", {
|
||||
debugGraphRuntime("camera-selection-display-bounds-fallback-fit", {
|
||||
nodeId,
|
||||
graphVersion: graphVersionRef.current,
|
||||
viewMode: viewModeRef.current,
|
||||
contextCount: selectionNodeIds.length,
|
||||
});
|
||||
fitDisplayGraphInView();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const globalBounds = computeDisplayedGraphBounds(sigma, currentDisplayGraph);
|
||||
const selectionBBox = expandGraphSpaceBounds(bounds, globalBounds, {
|
||||
paddingRatio: 0.2,
|
||||
minSpanRatio: 0.035,
|
||||
minSpanFloor: 0.02,
|
||||
});
|
||||
if (!selectionBBox) {
|
||||
debugGraphRuntime("camera-selection-invalid-bounds", {
|
||||
nodeId,
|
||||
graphVersion: graphVersionRef.current,
|
||||
count: bounds.count,
|
||||
});
|
||||
fitDisplayGraphInView();
|
||||
return;
|
||||
}
|
||||
const camera = sigma.getCamera();
|
||||
const currentCameraState = camera.getState();
|
||||
const target = {
|
||||
x: (bounds.minX + bounds.maxX) / 2,
|
||||
y: (bounds.minY + bounds.maxY) / 2,
|
||||
ratio: currentCameraState.ratio,
|
||||
angle: currentCameraState.angle,
|
||||
};
|
||||
|
||||
animateCameraToBounds("fit-selection-context", selectionBBox, {
|
||||
debugGraphRuntime("camera-selection-gentle-center", {
|
||||
nodeId,
|
||||
contextCount: bounds.count,
|
||||
boundsSource: "displayed-selection-context",
|
||||
minX: bounds.minX,
|
||||
maxX: bounds.maxX,
|
||||
minY: bounds.minY,
|
||||
maxY: bounds.maxY,
|
||||
referenceMinX: globalBounds?.minX ?? null,
|
||||
referenceMaxX: globalBounds?.maxX ?? null,
|
||||
referenceMinY: globalBounds?.minY ?? null,
|
||||
referenceMaxY: globalBounds?.maxY ?? null,
|
||||
targetX: target.x,
|
||||
targetY: target.y,
|
||||
preservedRatio: target.ratio,
|
||||
});
|
||||
}, [animateCameraToBounds, fitDisplayGraphInView]);
|
||||
|
||||
void camera.animate(
|
||||
target,
|
||||
{ duration: GRAPH_THEME.motion.cameraMs, easing: "quadraticOut" },
|
||||
);
|
||||
}, []);
|
||||
|
||||
const centerGroupedSelectionInView = useCallback((nodeId: string) => {
|
||||
const sigma = sigmaRef.current;
|
||||
@@ -1580,6 +1882,22 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const structureCanvas = sigma.createCanvas("structure", {
|
||||
beforeLayer: "nodes",
|
||||
afterLayer: "edges",
|
||||
style: {
|
||||
pointerEvents: "none",
|
||||
},
|
||||
});
|
||||
structureLayerCanvasRef.current = structureCanvas;
|
||||
} catch (error) {
|
||||
debugGraphRuntime("structure-layer-create-failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
structureLayerCanvasRef.current = null;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
syncCameraState(sigma);
|
||||
});
|
||||
@@ -1607,6 +1925,14 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
debugGraphRuntime("sigma-killed", {
|
||||
graphVersion: graphVersionRef.current,
|
||||
});
|
||||
if (structureLayerCanvasRef.current) {
|
||||
try {
|
||||
sigma.killLayer("structure");
|
||||
} catch {
|
||||
// Sigma.kill() also cleans layers; ignore if already removed.
|
||||
}
|
||||
}
|
||||
structureLayerCanvasRef.current = null;
|
||||
sigma.kill();
|
||||
}
|
||||
if (deferredFocusFrameRef.current !== null) {
|
||||
@@ -1764,8 +2090,24 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
container?.clientWidth ?? 0,
|
||||
container?.clientHeight ?? 0,
|
||||
);
|
||||
onDiagnosticsChange(availability);
|
||||
}, [analyticsSnapshot, effectsState, interactionState, isLayoutRunning, onDiagnosticsChange, temporalState]);
|
||||
onDiagnosticsChange({
|
||||
effectAvailability: availability,
|
||||
edgeClasses: edgeClassDiagnostics,
|
||||
structureLayer: structureLayerDiagnosticsRef.current ?? structureLayerDiagnostics,
|
||||
});
|
||||
if (import.meta.env.DEV && effectsState.diagnosticsEnabled) {
|
||||
console.debug("[Edge Truth]", edgeClassDiagnostics);
|
||||
}
|
||||
}, [
|
||||
analyticsSnapshot,
|
||||
edgeClassDiagnostics,
|
||||
effectsState,
|
||||
interactionState,
|
||||
isLayoutRunning,
|
||||
onDiagnosticsChange,
|
||||
structureLayerDiagnostics,
|
||||
temporalState,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
previousInteractionStateRef.current = null;
|
||||
@@ -1786,6 +2128,59 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
previousInteractionStateRef.current = interactionState;
|
||||
}, [displayGraph, interactionState, reducerSceneStateRef]);
|
||||
|
||||
const drawStructureLayerFrame = useCallback(() => {
|
||||
const sigma = sigmaRef.current;
|
||||
const canvas = structureLayerCanvasRef.current;
|
||||
const cache = structureLayerCacheRef.current;
|
||||
const diagnostics = getGraphStructureLayerDiagnostics({
|
||||
gate: structureLayerGate,
|
||||
cache,
|
||||
minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
|
||||
canvasAvailable: Boolean(canvas),
|
||||
lastDrawAt: structureLayerLastDrawAtRef.current,
|
||||
});
|
||||
structureLayerDiagnosticsRef.current = diagnostics;
|
||||
|
||||
if (!sigma || !canvas || !diagnostics.enabled || !cache) {
|
||||
clearGraphStructureLayer(canvas);
|
||||
return;
|
||||
}
|
||||
|
||||
const drawn = drawGraphStructureLayer({
|
||||
sigma,
|
||||
canvas,
|
||||
cache,
|
||||
});
|
||||
if (drawn) {
|
||||
structureLayerLastDrawAtRef.current = Date.now();
|
||||
structureLayerDiagnosticsRef.current = {
|
||||
...diagnostics,
|
||||
lastDrawAt: structureLayerLastDrawAtRef.current,
|
||||
};
|
||||
}
|
||||
}, [structureLayerGate]);
|
||||
|
||||
useEffect(() => {
|
||||
const sigma = sigmaRef.current;
|
||||
if (!graphReady || !sigma) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draw = () => drawStructureLayerFrame();
|
||||
sigma.on("afterRender", draw);
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
sigma.off("afterRender", draw);
|
||||
};
|
||||
}, [drawStructureLayerFrame, graphReady, structureLayerCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLayoutRunning || viewMode !== "full") {
|
||||
clearGraphStructureLayer(structureLayerCanvasRef.current);
|
||||
}
|
||||
}, [isLayoutRunning, viewMode]);
|
||||
|
||||
const drawOverlayFrame = useCallback(() => {
|
||||
const sigma = sigmaRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
@@ -1847,6 +2242,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
...pathNodeIds,
|
||||
...(primaryNodeId ? [primaryNodeId] : []),
|
||||
]);
|
||||
const lensFocusIds = new Set<string>();
|
||||
if (primaryNodeId) {
|
||||
reducerSceneStateRef.current.highlightedIncidentEdgeIds.forEach((edgeId) => {
|
||||
if (!displayGraph.hasEdge(edgeId)) {
|
||||
return;
|
||||
}
|
||||
const [source, target] = displayGraph.extremities(edgeId);
|
||||
const otherEndpointId = String(source) === primaryNodeId ? String(target) : String(source);
|
||||
if (displayGraph.hasNode(otherEndpointId)) {
|
||||
lensFocusIds.add(otherEndpointId);
|
||||
}
|
||||
});
|
||||
}
|
||||
const now = performance.now() / 1000;
|
||||
|
||||
if (!isLayoutRunning) {
|
||||
@@ -1909,7 +2317,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
});
|
||||
|
||||
if (!isLayoutRunning && primaryNodeId && effectAvailability.lens.available) {
|
||||
drawLensLayer(context, sigma, primaryNodeId, focusIds);
|
||||
drawLensLayer(context, sigma, primaryNodeId, lensFocusIds);
|
||||
}
|
||||
|
||||
drawPathEffectsLayer(context, pathSegments, effectsState, effectAvailability, now);
|
||||
|
||||
@@ -172,10 +172,10 @@ export function GraphInspectorPanel({
|
||||
if (!nodeId) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", gap: 12, marginTop: 32 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: "50%", background: "rgba(74,163,255,0.08)", border: "1px solid rgba(74,163,255,0.14)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", background: "rgba(127,208,255,0.3)" }} />
|
||||
<div style={{ width: 40, height: 40, borderRadius: "50%", background: "rgba(98, 226, 205, 0.07)", border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", background: GRAPH_THEME.ui.timeline.playheadSoft }} />
|
||||
</div>
|
||||
<p style={{ color: "#8b949e", fontSize: 14, margin: 0, lineHeight: 1.6 }}>
|
||||
<p style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 14, margin: 0, lineHeight: 1.6 }}>
|
||||
Search for a node or click one in the canvas to inspect its properties.
|
||||
</p>
|
||||
</div>
|
||||
@@ -191,19 +191,19 @@ export function GraphInspectorPanel({
|
||||
if (!effectiveNodeId) {
|
||||
return (
|
||||
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.divider}`, paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ background: "#58a6ff", boxShadow: "0 0 10px rgba(88,166,255,0.45)", width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: "#58a6ff", fontSize: 12, fontWeight: 700 }}>Selection</span>
|
||||
<span style={{ background: GRAPH_THEME.ui.timeline.playhead, boxShadow: "0 0 10px rgba(98, 226, 205, 0.34)", width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 12, fontWeight: 700 }}>Selection</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
<h3 style={{ margin: 0, color: GRAPH_THEME.ui.text.strong, fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{nodeId}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||
</div>
|
||||
<div style={groupedSelectionNoticeStyle}>
|
||||
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, lineHeight: 1.6 }}>
|
||||
{canActivateFocused
|
||||
? "Activate Focused mode to resolve this grouped selection to its canonical node."
|
||||
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||
@@ -233,23 +233,23 @@ export function GraphInspectorPanel({
|
||||
return (
|
||||
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
{/* Node identity */}
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.divider}`, paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>
|
||||
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
<h3 style={{ margin: 0, color: GRAPH_THEME.ui.text.strong, fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{String(attributes?.label ?? effectiveNodeId)}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
{groupedDisplaySelection ? nodeId : effectiveNodeId}
|
||||
</div>
|
||||
{groupedDisplaySelection ? (
|
||||
<div style={groupedSelectionNoticeStyle}>
|
||||
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, lineHeight: 1.6 }}>
|
||||
{canActivateFocused
|
||||
? `Canonical node available: ${effectiveNodeId}`
|
||||
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||
@@ -267,7 +267,7 @@ export function GraphInspectorPanel({
|
||||
|
||||
{/* Temporal bounds */}
|
||||
{(attributes?.valid_from || attributes?.valid_until) ? (
|
||||
<div style={{ padding: "10px 12px", background: "rgba(88,166,255,0.08)", border: "1px solid rgba(88,166,255,0.2)", borderRadius: 8, fontSize: 12, color: "#79c0ff", fontFamily: "monospace" }}>
|
||||
<div style={{ padding: "10px 12px", background: "rgba(233, 196, 122, 0.075)", border: "1px solid rgba(233, 196, 122, 0.22)", borderRadius: 8, fontSize: 12, color: GRAPH_THEME.palette.accent.selected, fontFamily: "monospace" }}>
|
||||
{attributes?.valid_from ? <div>from: {attributes.valid_from}</div> : null}
|
||||
{attributes?.valid_until ? <div>until: {attributes.valid_until}</div> : null}
|
||||
</div>
|
||||
@@ -343,8 +343,8 @@ export function GraphInspectorPanel({
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12 }}>{prediction.type}</div>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{
|
||||
@@ -352,9 +352,9 @@ export function GraphInspectorPanel({
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: "rgba(88,166,255,0.12)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
color: "#58a6ff",
|
||||
background: GRAPH_THEME.ui.timeline.playheadSoft,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
color: GRAPH_THEME.ui.timeline.playhead,
|
||||
}}>
|
||||
{(prediction.score * 100).toFixed(1)}%
|
||||
</div>
|
||||
@@ -382,8 +382,8 @@ export function GraphInspectorPanel({
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{attribution.map(({ key, value }) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||
<div style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, wordBreak: "break-word" }}>
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,8 +403,8 @@ export function GraphInspectorPanel({
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||
<div style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, wordBreak: "break-word" }}>
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,27 +423,27 @@ export function GraphInspectorPanel({
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
background: "rgba(4, 10, 18, 0.5)",
|
||||
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
|
||||
color: "#edf5ff",
|
||||
background: GRAPH_THEME.ui.control.inputBg,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.inputBorder}`,
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
borderRadius: 12,
|
||||
padding: "11px 13px",
|
||||
fontSize: 13,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.035)",
|
||||
};
|
||||
|
||||
const groupedSelectionNoticeStyle: CSSProperties = {
|
||||
marginTop: 12,
|
||||
padding: "10px 12px",
|
||||
background: "rgba(88,166,255,0.08)",
|
||||
border: "1px solid rgba(88,166,255,0.2)",
|
||||
background: "rgba(98, 226, 205, 0.07)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
borderRadius: 12,
|
||||
};
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
|
||||
color: "#fff",
|
||||
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
|
||||
background: GRAPH_THEME.ui.control.primaryBg,
|
||||
color: GRAPH_THEME.ui.control.primaryText,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.primaryBorder}`,
|
||||
borderRadius: 12,
|
||||
padding: "9px 12px",
|
||||
cursor: "pointer",
|
||||
@@ -452,47 +452,47 @@ const actionButtonStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: `0 8px 22px ${GRAPH_THEME.palette.background.shellGlow}`,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.07), 0 10px 24px rgba(0,0,0,0.18)",
|
||||
};
|
||||
|
||||
const secondaryActionButtonStyle: CSSProperties = {
|
||||
...actionButtonStyle,
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
color: "#c6d4e3",
|
||||
background: GRAPH_THEME.ui.control.defaultBg,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.defaultBorder}`,
|
||||
color: GRAPH_THEME.ui.control.defaultText,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const predictionCardStyle: CSSProperties = {
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.12)",
|
||||
background: "rgba(255, 255, 255, 0.035)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const propertyCardStyle: CSSProperties = {
|
||||
background: "rgba(0, 0, 0, 0.2)",
|
||||
background: "rgba(255, 255, 255, 0.028)",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(255, 255, 255, 0.05)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8b949e",
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const subtleChipStyle: CSSProperties = {
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
color: "#9fb6d2",
|
||||
color: GRAPH_THEME.ui.text.body,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
@@ -500,13 +500,13 @@ const sectionStyle: CSSProperties = {
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
background: "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015))",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
background: GRAPH_THEME.ui.surface.cardSubtle,
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
borderRadius: 14,
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: "#8b949e",
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
@@ -527,9 +527,9 @@ const pathNodeChipStyle: CSSProperties = {
|
||||
gap: 6,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(88,166,255,0.1)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
color: "#e6edf3",
|
||||
background: "rgba(98, 226, 205, 0.08)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
maxWidth: 160,
|
||||
@@ -542,8 +542,8 @@ const pathNodeIndexStyle: CSSProperties = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(88,166,255,0.22)",
|
||||
color: "#79c0ff",
|
||||
background: GRAPH_THEME.ui.timeline.playheadSoft,
|
||||
color: GRAPH_THEME.ui.timeline.playhead,
|
||||
fontSize: 9,
|
||||
fontWeight: 800,
|
||||
flexShrink: 0,
|
||||
@@ -559,7 +559,7 @@ const pathEdgeConnectorStyle: CSSProperties = {
|
||||
const pathEdgeLabelStyle: CSSProperties = {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: "#6a7f97",
|
||||
color: GRAPH_THEME.ui.text.subtle,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
maxWidth: 70,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { DataSet } from "vis-data";
|
||||
import { Timeline } from "vis-timeline";
|
||||
import type { TimelineOptions } from "vis-timeline";
|
||||
import "vis-timeline/styles/vis-timeline-graph2d.css";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
|
||||
export interface TimelinePanelProps {
|
||||
onTimeChange: (time: Date) => void;
|
||||
@@ -19,35 +20,35 @@ const PLAY_STEP_MONTHS = 6;
|
||||
const VIS_OVERRIDE_CSS = `
|
||||
.sem-timeline-wrap .vis-timeline { border: none !important; background: transparent !important; overflow: visible !important; }
|
||||
.sem-timeline-wrap .vis-panel.vis-background, .sem-timeline-wrap .vis-panel.vis-center { background: transparent !important; }
|
||||
.sem-timeline-wrap .vis-panel { border-color: rgba(88, 166, 255, 0.15) !important; }
|
||||
.sem-timeline-wrap .vis-panel { border-color: ${GRAPH_THEME.ui.timeline.border} !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-text {
|
||||
color: #8b949e !important;
|
||||
color: ${GRAPH_THEME.ui.timeline.text} !important;
|
||||
font-size: 11px !important;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
|
||||
padding-top: 3px !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-time-axis .vis-text.vis-major {
|
||||
color: #c9d1d9 !important;
|
||||
color: ${GRAPH_THEME.ui.timeline.textStrong} !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-minor { border-color: rgba(88, 166, 255, 0.07) !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: rgba(88, 166, 255, 0.18) !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-minor { border-color: ${GRAPH_THEME.ui.timeline.gridMinor} !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: ${GRAPH_THEME.ui.timeline.gridMajor} !important; }
|
||||
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} {
|
||||
background: rgba(88, 166, 255, 0.15) !important;
|
||||
background: ${GRAPH_THEME.ui.timeline.playheadSoft} !important;
|
||||
width: 2px !important;
|
||||
cursor: ew-resize !important;
|
||||
z-index: 5 !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} > .vis-custom-time-marker {
|
||||
background: #58a6ff !important;
|
||||
color: #0d1117 !important;
|
||||
background: ${GRAPH_THEME.ui.timeline.playhead} !important;
|
||||
color: ${GRAPH_THEME.ui.text.inverse} !important;
|
||||
font-size: 10px !important;
|
||||
font-weight: 700 !important;
|
||||
border-radius: 3px !important;
|
||||
padding: 1px 5px !important;
|
||||
white-space: nowrap !important;
|
||||
box-shadow: 0 0 8px rgba(88, 166, 255, 0.7) !important;
|
||||
box-shadow: 0 0 8px rgba(98, 226, 205, 0.45) !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-current-time { display: none !important; }
|
||||
.sem-timeline-wrap .vis-panel.vis-left { display: none !important; }
|
||||
@@ -166,14 +167,14 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
useEffect(() => () => stopPlay(), [stopPlay]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: "1px solid rgba(88, 166, 255, 0.2)", background: "rgba(1, 4, 9, 0.88)", backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", display: "flex", alignItems: "stretch", flexShrink: 0 }}>
|
||||
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: `1px solid ${GRAPH_THEME.ui.timeline.border}`, background: GRAPH_THEME.ui.timeline.background, backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", display: "flex", alignItems: "stretch", flexShrink: 0 }}>
|
||||
<style>{VIS_OVERRIDE_CSS}</style>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: "1px solid rgba(88, 166, 255, 0.15)", minWidth: 80, flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: `1px solid ${GRAPH_THEME.ui.timeline.border}`, minWidth: 80, flexShrink: 0 }}>
|
||||
<button
|
||||
id="temporal-play-btn"
|
||||
onClick={togglePlay}
|
||||
title={isPlaying ? "Pause Evolution" : "Play Evolution"}
|
||||
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? "#58a6ff" : "rgba(88, 166, 255, 0.35)"}`, background: isPlaying ? "rgba(88, 166, 255, 0.2)" : "rgba(88, 166, 255, 0.06)", color: "#58a6ff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(88, 166, 255, 0.4)" : "none" }}
|
||||
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? GRAPH_THEME.ui.control.activeBorder : GRAPH_THEME.ui.control.defaultBorder}`, background: isPlaying ? GRAPH_THEME.ui.timeline.playheadSoft : GRAPH_THEME.ui.control.defaultBg, color: GRAPH_THEME.ui.timeline.playhead, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(98, 226, 205, 0.32)" : "none" }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
|
||||
@@ -181,12 +182,12 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
|
||||
)}
|
||||
</button>
|
||||
<span style={{ fontSize: 10, color: isPlaying ? "#58a6ff" : "#8b949e", fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
|
||||
<span style={{ fontSize: 10, color: isPlaying ? GRAPH_THEME.ui.timeline.playhead : GRAPH_THEME.ui.timeline.text, fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
|
||||
{displayDate}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: "rgba(88, 166, 255, 0.55)", textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
|
||||
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: GRAPH_THEME.ui.text.subtle, textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
|
||||
Temporal Scrubber · {minBound.getFullYear()}-{maxBound.getFullYear()}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,11 +7,19 @@ export const clickSelectionBehavior: GraphBehavior = {
|
||||
onNodeClick: (context, nodeId) => {
|
||||
context.setHoveredNodeId(nodeId);
|
||||
context.onEdgeSelectionChange("");
|
||||
context.onNodeSelectionChange(nodeId);
|
||||
if (context.getInteractionState().selectedNodeId === nodeId) {
|
||||
context.onNodeSelectionChange("");
|
||||
} else {
|
||||
context.onNodeSelectionChange(nodeId);
|
||||
}
|
||||
},
|
||||
onEdgeClick: (context, edgeId) => {
|
||||
context.setHoveredNodeId(null);
|
||||
context.onEdgeSelectionChange(edgeId);
|
||||
if (context.getInteractionState().selectedEdgeId === edgeId) {
|
||||
context.onEdgeSelectionChange("");
|
||||
} else {
|
||||
context.onEdgeSelectionChange(edgeId);
|
||||
}
|
||||
},
|
||||
onStageClick: (context) => {
|
||||
context.setHoveredNodeId(null);
|
||||
|
||||
@@ -44,8 +44,18 @@ const MAX_REGION_SUMMARIES = 6;
|
||||
const MAX_CENTRALITY_SUMMARIES = 6;
|
||||
const CENTRALITY_ITERATIONS = 24;
|
||||
const MAX_BACKBONE_ANCHORS = 4;
|
||||
const MAX_BACKBONE_CENTRAL_LINKS = 2;
|
||||
const MAX_BACKBONE_BRIDGES = 4;
|
||||
const MAX_BACKBONE_CENTRAL_LINKS = 36;
|
||||
const MAX_BACKBONE_BRIDGES = 80;
|
||||
const MAX_BACKBONE_TOTAL_EDGES = 128;
|
||||
const MAX_BACKBONE_EDGES_PER_NODE = 5;
|
||||
const MAX_BACKBONE_PARALLEL_PAIR_EDGES = 2;
|
||||
|
||||
type BackboneCandidate = {
|
||||
edgeId: string;
|
||||
source: string;
|
||||
target: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
function getNodeLabel(graphRef: GraphRef, nodeId: string): string {
|
||||
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
@@ -364,6 +374,61 @@ function scoreBackboneEdge(
|
||||
return weight * 1.4 + (sourceScore + targetScore) * 2.4 + priority * 0.6 + parallelBoost + bidirectionalBoost;
|
||||
}
|
||||
|
||||
function upsertBackboneCandidate(
|
||||
candidates: Map<string, BackboneCandidate>,
|
||||
key: string,
|
||||
candidate: BackboneCandidate,
|
||||
) {
|
||||
const current = candidates.get(key);
|
||||
if (
|
||||
!current
|
||||
|| candidate.score > current.score
|
||||
|| (candidate.score === current.score && candidate.edgeId.localeCompare(current.edgeId) < 0)
|
||||
) {
|
||||
candidates.set(key, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function addRankedBackboneCandidates(
|
||||
selected: BackboneCandidate[],
|
||||
selectedEdgeIds: Set<string>,
|
||||
nodeUseCounts: Map<string, number>,
|
||||
pairUseCounts: Map<string, number>,
|
||||
candidates: Iterable<BackboneCandidate>,
|
||||
maxToAdd: number,
|
||||
) {
|
||||
const ranked = [...candidates].sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
});
|
||||
|
||||
for (const candidate of ranked) {
|
||||
if (selected.length >= MAX_BACKBONE_TOTAL_EDGES || maxToAdd <= 0 || selectedEdgeIds.has(candidate.edgeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pairKey = [candidate.source, candidate.target].sort().join("::");
|
||||
if ((nodeUseCounts.get(candidate.source) ?? 0) >= MAX_BACKBONE_EDGES_PER_NODE) {
|
||||
continue;
|
||||
}
|
||||
if ((nodeUseCounts.get(candidate.target) ?? 0) >= MAX_BACKBONE_EDGES_PER_NODE) {
|
||||
continue;
|
||||
}
|
||||
if ((pairUseCounts.get(pairKey) ?? 0) >= MAX_BACKBONE_PARALLEL_PAIR_EDGES) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(candidate);
|
||||
selectedEdgeIds.add(candidate.edgeId);
|
||||
nodeUseCounts.set(candidate.source, (nodeUseCounts.get(candidate.source) ?? 0) + 1);
|
||||
nodeUseCounts.set(candidate.target, (nodeUseCounts.get(candidate.target) ?? 0) + 1);
|
||||
pairUseCounts.set(pairKey, (pairUseCounts.get(pairKey) ?? 0) + 1);
|
||||
maxToAdd -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function buildOverviewBackboneSnapshot(
|
||||
graphRef: GraphRef,
|
||||
visibleNodeIds: Set<string>,
|
||||
@@ -379,7 +444,10 @@ function buildOverviewBackboneSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
const selected: BackboneCandidate[] = [];
|
||||
const selectedEdgeIds = new Set<string>();
|
||||
const nodeUseCounts = new Map<string, number>();
|
||||
const pairUseCounts = new Map<string, number>();
|
||||
const regionByNode = new Map<string, string>();
|
||||
visibleNodeIds.forEach((nodeId) => {
|
||||
regionByNode.set(nodeId, getNodeSemanticGroup(graphRef, nodeId));
|
||||
@@ -397,7 +465,7 @@ function buildOverviewBackboneSnapshot(
|
||||
.map((summary) => summary.id)
|
||||
.filter((nodeId) => visibleNodeIds.has(nodeId));
|
||||
|
||||
const coreLinkCandidates = new Map<string, { edgeId: string; score: number }>();
|
||||
const coreLinkCandidates = new Map<string, BackboneCandidate>();
|
||||
anchorIds.forEach((anchorId) => {
|
||||
collectNodeIncidentEdges(graphRef, anchorId, visibleNodeIds)
|
||||
.filter((entry) => {
|
||||
@@ -415,60 +483,88 @@ function buildOverviewBackboneSnapshot(
|
||||
const targetRegion = regionByNode.get(entry.target);
|
||||
const bridgeBoost = sourceRegion && targetRegion && sourceRegion !== targetRegion ? 0.28 : 0;
|
||||
const score = scoreBackboneEdge(entry.attrs, entry.source, entry.target, base) + bridgeBoost;
|
||||
const current = coreLinkCandidates.get(pairKey);
|
||||
if (!current || score > current.score || (score === current.score && entry.edgeId.localeCompare(current.edgeId) < 0)) {
|
||||
coreLinkCandidates.set(pairKey, { edgeId: entry.edgeId, score });
|
||||
}
|
||||
upsertBackboneCandidate(coreLinkCandidates, pairKey, {
|
||||
edgeId: entry.edgeId,
|
||||
source: entry.source,
|
||||
target: entry.target,
|
||||
score,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const bridgeByPair = new Map<string, { edgeId: string; score: number }>();
|
||||
const bridgeCandidates = new Map<string, BackboneCandidate>();
|
||||
const structuralCandidates = new Map<string, BackboneCandidate>();
|
||||
graphRef.forEachEdge((edgeId, attrs, source, target) => {
|
||||
if (!visibleNodeIds.has(source) || !visibleNodeIds.has(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceRegion = regionByNode.get(source);
|
||||
const targetRegion = regionByNode.get(target);
|
||||
if (!sourceRegion || !targetRegion || sourceRegion === targetRegion) {
|
||||
return;
|
||||
const edgeKey = String(edgeId);
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
const sourceRegion = regionByNode.get(sourceId);
|
||||
const targetRegion = regionByNode.get(targetId);
|
||||
const sourceCommunity = base.communitiesByNode.get(sourceId);
|
||||
const targetCommunity = base.communitiesByNode.get(targetId);
|
||||
const crossesSemanticRegion = Boolean(sourceRegion && targetRegion && sourceRegion !== targetRegion);
|
||||
const crossesCommunity = sourceCommunity !== undefined && targetCommunity !== undefined && sourceCommunity !== targetCommunity;
|
||||
const sourceCentrality = base.centralityByNode.get(sourceId)?.score ?? 0;
|
||||
const targetCentrality = base.centralityByNode.get(targetId)?.score ?? 0;
|
||||
|
||||
const baseScore = scoreBackboneEdge(attrs as EdgeAttributes, sourceId, targetId, base);
|
||||
const semanticBoost = crossesSemanticRegion ? 0.5 : 0;
|
||||
const communityBoost = crossesCommunity ? 0.36 : 0;
|
||||
const topRegionBoost = sourceRegion && targetRegion && (topRegionIds.has(sourceRegion) || topRegionIds.has(targetRegion)) ? 0.32 : 0;
|
||||
const centralityBalance = Math.min(sourceCentrality, targetCentrality) * 1.2;
|
||||
const score = baseScore + semanticBoost + communityBoost + topRegionBoost + centralityBalance;
|
||||
const candidate = {
|
||||
edgeId: edgeKey,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
score,
|
||||
};
|
||||
|
||||
if (crossesSemanticRegion || crossesCommunity) {
|
||||
const bridgeKey = [
|
||||
sourceRegion ?? `community:${sourceCommunity ?? sourceId}`,
|
||||
targetRegion ?? `community:${targetCommunity ?? targetId}`,
|
||||
Math.min(sourceCentrality, targetCentrality).toFixed(4),
|
||||
].sort().join("::");
|
||||
upsertBackboneCandidate(bridgeCandidates, bridgeKey, candidate);
|
||||
}
|
||||
|
||||
if (!topRegionIds.has(sourceRegion) && !topRegionIds.has(targetRegion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pairKey = [sourceRegion, targetRegion].sort().join("::");
|
||||
const score = scoreBackboneEdge(attrs as EdgeAttributes, source, target, base) + 0.36;
|
||||
const current = bridgeByPair.get(pairKey);
|
||||
if (!current || score > current.score || (score === current.score && String(edgeId).localeCompare(current.edgeId) < 0)) {
|
||||
bridgeByPair.set(pairKey, { edgeId: String(edgeId), score });
|
||||
}
|
||||
const pairKey = [sourceId, targetId].sort().join("::");
|
||||
upsertBackboneCandidate(structuralCandidates, pairKey, candidate);
|
||||
});
|
||||
|
||||
[...bridgeByPair.values()]
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
})
|
||||
.slice(0, MAX_BACKBONE_BRIDGES)
|
||||
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
|
||||
addRankedBackboneCandidates(
|
||||
selected,
|
||||
selectedEdgeIds,
|
||||
nodeUseCounts,
|
||||
pairUseCounts,
|
||||
bridgeCandidates.values(),
|
||||
MAX_BACKBONE_BRIDGES,
|
||||
);
|
||||
addRankedBackboneCandidates(
|
||||
selected,
|
||||
selectedEdgeIds,
|
||||
nodeUseCounts,
|
||||
pairUseCounts,
|
||||
coreLinkCandidates.values(),
|
||||
MAX_BACKBONE_CENTRAL_LINKS,
|
||||
);
|
||||
addRankedBackboneCandidates(
|
||||
selected,
|
||||
selectedEdgeIds,
|
||||
nodeUseCounts,
|
||||
pairUseCounts,
|
||||
structuralCandidates.values(),
|
||||
MAX_BACKBONE_TOTAL_EDGES - selected.length,
|
||||
);
|
||||
|
||||
[...coreLinkCandidates.values()]
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
})
|
||||
.slice(0, MAX_BACKBONE_CENTRAL_LINKS)
|
||||
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
|
||||
|
||||
const edgeIds = [...selectedEdgeIds]
|
||||
.filter((edgeId) => graphRef.hasEdge(edgeId))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
const edgeIds = selected
|
||||
.map((entry) => entry.edgeId)
|
||||
.filter((edgeId) => graphRef.hasEdge(edgeId));
|
||||
|
||||
return {
|
||||
ready: edgeIds.length > 0,
|
||||
|
||||
@@ -266,8 +266,8 @@ function renderDensityField(
|
||||
(GRAPH_THEME.effects.semanticRegions.splatRadius + sample.size * 0.9) * scale,
|
||||
);
|
||||
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
|
||||
gradient.addColorStop(0, "rgba(255,255,255,0.22)");
|
||||
gradient.addColorStop(0.58, "rgba(255,255,255,0.08)");
|
||||
gradient.addColorStop(0, "rgba(255,255,255,0.05)");
|
||||
gradient.addColorStop(0.58, "rgba(255,255,255,0.02)");
|
||||
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
||||
context.fillStyle = gradient;
|
||||
context.beginPath();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type GraphBadgeKind,
|
||||
type GraphEdgeVariant,
|
||||
type GraphEdgeVisualState,
|
||||
type GraphEntityShapeVariant,
|
||||
type GraphLabelVisibilityPolicy,
|
||||
type GraphNodeShapeVariant,
|
||||
type GraphNodeVisualState,
|
||||
@@ -22,6 +23,7 @@ import { computeGraphAnalyticsBase } from "./graphAnalytics";
|
||||
import type {
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphFullEdgeClass,
|
||||
GraphInteractionState,
|
||||
GraphSelectedNodeKind,
|
||||
GraphViewMode,
|
||||
@@ -84,6 +86,9 @@ export type ResolvedNodeStyle = {
|
||||
borderColor: string;
|
||||
borderSize: number;
|
||||
nodeVariant: GraphNodeShapeVariant;
|
||||
entityShape: GraphEntityShapeVariant;
|
||||
entityShapeKind: number;
|
||||
entityAspectRatio: number;
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeCount?: number;
|
||||
showBadge: boolean;
|
||||
@@ -597,6 +602,7 @@ function resolveNodeColor(
|
||||
) {
|
||||
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;
|
||||
@@ -628,10 +634,10 @@ function resolveNodeColor(
|
||||
);
|
||||
return withAlpha(
|
||||
boostedCore,
|
||||
isCommunityGroup ? Math.min(overviewAlpha, theme.grouped.style.fillAlpha) : overviewAlpha,
|
||||
isCommunityGroup ? Math.min(overviewAlpha, theme.grouped.style.fillAlpha) : Math.min(overviewAlpha, entityShapeConfig.fillAlpha),
|
||||
);
|
||||
}
|
||||
return isCommunityGroup ? withAlpha(semanticColor, theme.grouped.style.fillAlpha) : semanticColor;
|
||||
return withAlpha(semanticColor, isCommunityGroup ? theme.grouped.style.fillAlpha : entityShapeConfig.fillAlpha);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,39 +651,36 @@ function resolveNodeShellColor(
|
||||
) {
|
||||
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 overviewShell = blendHex(
|
||||
theme.palette.overview.nodeBase,
|
||||
semanticColor,
|
||||
(state === "neighbor" ? 0.02 : 0.012) + presenceBoost * 0.024,
|
||||
);
|
||||
const overviewGlyph = blendHex(semanticColor, theme.ui.text.strong, 0.34 + presenceBoost * 0.12);
|
||||
|
||||
if (zoomTier !== "overview") {
|
||||
return withAlpha(
|
||||
blendHex(theme.palette.overview.nodeBase, semanticColor, 0.26),
|
||||
isCommunityGroup ? theme.grouped.style.shellAlpha : 0.95,
|
||||
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.overview.nodeBase, theme.palette.accent.selected, 0.05), 0.98);
|
||||
return withAlpha(blendHex(theme.palette.accent.selected, theme.ui.text.strong, 0.24), 0.92);
|
||||
}
|
||||
|
||||
if (state === "hovered") {
|
||||
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.hovered, 0.06), 0.98);
|
||||
return withAlpha(blendHex(theme.palette.accent.hovered, theme.ui.text.strong, 0.22), 0.9);
|
||||
}
|
||||
|
||||
if (state === "path") {
|
||||
return withAlpha(blendHex(theme.palette.overview.nodeBase, theme.palette.accent.path, 0.06), 0.97);
|
||||
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.nodeMuted, 0.22);
|
||||
return withAlpha(theme.palette.overview.nodeBorder, 0.16);
|
||||
}
|
||||
|
||||
return withAlpha(
|
||||
overviewShell,
|
||||
isCommunityGroup ? theme.grouped.style.shellAlpha : theme.palette.overview.nodeShellAlpha,
|
||||
overviewGlyph,
|
||||
isCommunityGroup ? theme.grouped.style.shellAlpha : Math.min(0.58, entityShapeConfig.shellAlpha + 0.08),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -730,12 +733,17 @@ function resolveEdgeColor(
|
||||
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;
|
||||
@@ -760,12 +768,117 @@ function resolveEdgeColor(
|
||||
case "muted":
|
||||
return zoomTier === "overview"
|
||||
? theme.palette.overview.edgeStructure
|
||||
: String(attrs.mutedColor || theme.palette.muted.edgeOverview);
|
||||
: 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,
|
||||
@@ -846,7 +959,7 @@ export function resolveEdgeVisualState(
|
||||
selectedEdgeId: string,
|
||||
focusIds: Set<string>,
|
||||
pathEdgeIds: Set<string>,
|
||||
overviewBackboneEdgeIds: Set<string>,
|
||||
highlightedIncidentEdgeIds: Set<string> = new Set(),
|
||||
): GraphEdgeVisualState {
|
||||
const primaryNodeId = hoveredNodeId || selectedNodeId;
|
||||
|
||||
@@ -859,17 +972,16 @@ export function resolveEdgeVisualState(
|
||||
}
|
||||
|
||||
if (primaryNodeId && (source === primaryNodeId || target === primaryNodeId)) {
|
||||
return hoveredNodeId ? "hovered" : "selected";
|
||||
if (highlightedIncidentEdgeIds.has(edgeId)) {
|
||||
return hoveredNodeId ? "hovered" : "selected";
|
||||
}
|
||||
return "muted";
|
||||
}
|
||||
|
||||
if (zoomTier !== "overview" && focusIds.has(source) && focusIds.has(target)) {
|
||||
return "neighbor";
|
||||
}
|
||||
|
||||
if (zoomTier === "overview" && overviewBackboneEdgeIds.has(edgeId)) {
|
||||
return "backbone";
|
||||
}
|
||||
|
||||
if (hoveredNodeId || selectedNodeId || selectedEdgeId || pathEdgeIds.size > 0) {
|
||||
return "muted";
|
||||
}
|
||||
@@ -881,6 +993,90 @@ export function resolveEdgeVisualState(
|
||||
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";
|
||||
@@ -889,6 +1085,14 @@ export function resolveNodeVariant(state: GraphNodeVisualState, attrs: NodeAttri
|
||||
return attrs.nodeShapeVariant || attrs.nodeVariant || "default";
|
||||
}
|
||||
|
||||
export function resolveEntityShape(attrs: NodeAttributes): GraphEntityShapeVariant {
|
||||
if (attrs.isCommunityGroup) {
|
||||
return "community";
|
||||
}
|
||||
|
||||
return attrs.entityShape || "entity";
|
||||
}
|
||||
|
||||
export function resolveEdgeVariant(state: GraphEdgeVisualState, attrs: EdgeAttributes): GraphEdgeVariant {
|
||||
if (state === "path") {
|
||||
return "pathSignal";
|
||||
@@ -1001,6 +1205,8 @@ export function resolveNodeElementStyle(
|
||||
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);
|
||||
@@ -1028,12 +1234,17 @@ export function resolveNodeElementStyle(
|
||||
: 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: resolveNodeCoreScale(zoomTier, state, cameraRatio),
|
||||
size: Math.max(baseSize * sizeMultiplier * overviewPresence, stateConfig.minSize),
|
||||
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,
|
||||
@@ -1045,10 +1256,14 @@ export function resolveNodeElementStyle(
|
||||
+ 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,
|
||||
@@ -1069,6 +1284,7 @@ function resolveStraightEdgeType(
|
||||
state: GraphEdgeVisualState,
|
||||
variant: GraphEdgeVariant,
|
||||
attrs: EdgeAttributes,
|
||||
viewMode: GraphViewMode,
|
||||
): "line" | "arrow" {
|
||||
const variantConfig = theme.edges.variants[variant];
|
||||
|
||||
@@ -1076,6 +1292,14 @@ function resolveStraightEdgeType(
|
||||
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";
|
||||
}
|
||||
@@ -1125,6 +1349,9 @@ export function resolveEdgeElementStyle(
|
||||
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];
|
||||
@@ -1133,11 +1360,29 @@ export function resolveEdgeElementStyle(
|
||||
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 < tierConfig.edgePriorityThreshold
|
||||
&& edgeVariant === "line";
|
||||
&& 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) {
|
||||
if (stateConfig.hide || belowPriorityThreshold || hiddenByMutedState || sampledOut) {
|
||||
return {
|
||||
hidden: true,
|
||||
zIndex: 0,
|
||||
@@ -1148,31 +1393,55 @@ export function resolveEdgeElementStyle(
|
||||
};
|
||||
}
|
||||
|
||||
const sizeMultiplier = (state === "default" ? tierConfig.edgeSizeScale : stateConfig.sizeMultiplier) * variantConfig.sizeMultiplier;
|
||||
const straightType = resolveStraightEdgeType(theme, zoomTier, state, edgeVariant, attrs);
|
||||
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
|
||||
&& zoomTier !== "overview"
|
||||
&& (
|
||||
edgeVariant === "pathSignal"
|
||||
|| state === "selected"
|
||||
|| ((state === "neighbor" || state === "hovered") && (edgeVariant === "bidirectionalCurve" || edgeVariant === "parallelCurve"))
|
||||
|| shouldCurveBridge
|
||||
|| edgeVariant === "bidirectionalCurve"
|
||||
|| edgeVariant === "parallelCurve"
|
||||
);
|
||||
const curvature = useCurvedRenderer
|
||||
? resolveEdgeCurvature(theme, state, edgeVariant, attrs, sourceId, targetId)
|
||||
: 0;
|
||||
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: isCommunityBundle
|
||||
? withAlpha(resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color), theme.grouped.style.edgeAlpha)
|
||||
: resolveEdgeColor(theme, zoomTier, state, attrs, attrs.color),
|
||||
size: Math.max(
|
||||
baseSize * sizeMultiplier * (isCommunityBundle ? theme.grouped.style.edgeSizeScale : 1),
|
||||
stateConfig.minSize,
|
||||
),
|
||||
color: color,
|
||||
size,
|
||||
zIndex: stateConfig.zIndex,
|
||||
edgeVariant,
|
||||
arrowVisibilityPolicy: variantConfig.arrowPolicy,
|
||||
@@ -1675,6 +1944,7 @@ function buildCommunityGroupedGraph(): GraphDisplayResult {
|
||||
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",
|
||||
@@ -1901,30 +2171,42 @@ export function createFocusedGraph(
|
||||
});
|
||||
});
|
||||
|
||||
for (const source of focusIds) {
|
||||
for (const target of focusIds) {
|
||||
if (source === target) {
|
||||
continue;
|
||||
}
|
||||
forEachDirectedEdgeBetween(graph, source, target, (edgeId, attrs) => {
|
||||
const state: GraphEdgeVisualState = pathEdgeIds.has(edgeId)
|
||||
? "path"
|
||||
: source === nodeId || target === nodeId
|
||||
? "selected"
|
||||
: "neighbor";
|
||||
const style = resolveEdgeElementStyle(GRAPH_THEME, "inspection", state, attrs, source, target);
|
||||
focused.mergeDirectedEdgeWithKey(edgeId, source, target, {
|
||||
...attrs,
|
||||
type: style.type,
|
||||
size: style.size,
|
||||
color: style.color,
|
||||
baseSize: style.size,
|
||||
baseColor: style.color,
|
||||
curvature: style.curvature,
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import type Graph from "graphology";
|
||||
import type Sigma from "sigma";
|
||||
|
||||
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import type {
|
||||
GraphFullEdgeClass,
|
||||
GraphFullEdgeClassDiagnostics,
|
||||
GraphInteractionState,
|
||||
GraphStructureLayerDiagnostics,
|
||||
GraphStructureLayerDisabledReason,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
|
||||
type GraphRef = Graph;
|
||||
|
||||
type StructureLayerMode = typeof GRAPH_THEME.edges.fullGraphStructureLayer.mode;
|
||||
|
||||
export type GraphStructureCurve = {
|
||||
edgeId: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
source: { x: number; y: number };
|
||||
target: { x: number; y: number };
|
||||
edgeClass: Extract<GraphFullEdgeClass, "backbone" | "bridge">;
|
||||
priority: number;
|
||||
curvature: number;
|
||||
};
|
||||
|
||||
export type GraphStructureCurveCache = {
|
||||
cacheKey: string;
|
||||
curves: GraphStructureCurve[];
|
||||
bridgeCurveCount: number;
|
||||
backboneCurveCount: number;
|
||||
};
|
||||
|
||||
export type GraphStructureLayerGateInput = {
|
||||
mode: StructureLayerMode;
|
||||
viewMode: GraphViewMode;
|
||||
isLayoutRunning: boolean;
|
||||
edgeDiagnostics?: GraphFullEdgeClassDiagnostics;
|
||||
minimumLiteralEdges: number;
|
||||
};
|
||||
|
||||
export type GraphStructureLayerGate = {
|
||||
enabled: boolean;
|
||||
disabledReason: GraphStructureLayerDisabledReason | null;
|
||||
};
|
||||
|
||||
export function evaluateGraphStructureLayerGate({
|
||||
mode,
|
||||
viewMode,
|
||||
isLayoutRunning,
|
||||
edgeDiagnostics,
|
||||
minimumLiteralEdges,
|
||||
}: GraphStructureLayerGateInput): GraphStructureLayerGate {
|
||||
if (mode === "off") {
|
||||
return { enabled: false, disabledReason: "disabled" };
|
||||
}
|
||||
|
||||
if (viewMode !== "full") {
|
||||
return { enabled: false, disabledReason: "non-full-mode" };
|
||||
}
|
||||
|
||||
if (isLayoutRunning) {
|
||||
return { enabled: false, disabledReason: "layout-running" };
|
||||
}
|
||||
|
||||
if (mode === "auto") {
|
||||
const literalEdges = (edgeDiagnostics?.counts.backbone ?? 0) + (edgeDiagnostics?.counts.bridge ?? 0);
|
||||
if (literalEdges >= minimumLiteralEdges) {
|
||||
return { enabled: false, disabledReason: "enough-literal-edges" };
|
||||
}
|
||||
}
|
||||
|
||||
return { enabled: true, disabledReason: null };
|
||||
}
|
||||
|
||||
function isFinitePoint(attrs: NodeAttributes) {
|
||||
return Number.isFinite(Number(attrs.x)) && Number.isFinite(Number(attrs.y));
|
||||
}
|
||||
|
||||
function getEdgePriority(attrs: EdgeAttributes) {
|
||||
return Math.max(0, Math.min(1, Number(attrs.visualPriority ?? attrs.weight ?? 0)));
|
||||
}
|
||||
|
||||
function getCurveSortRank(edgeClass: GraphFullEdgeClass, priority: number) {
|
||||
return (edgeClass === "bridge" ? 2 : 1) + priority;
|
||||
}
|
||||
|
||||
function getDeterministicCurveSign(sourceId: string, targetId: string, edgeId: string) {
|
||||
const seed = `${sourceId}|${targetId}|${edgeId}`;
|
||||
let hash = 0;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash = (hash * 31 + seed.charCodeAt(index)) | 0;
|
||||
}
|
||||
return hash % 2 === 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
export function createGraphStructureCacheKey({
|
||||
graphVersion,
|
||||
zoomTier,
|
||||
layoutSettledEpoch,
|
||||
overviewBackboneEdgeIds,
|
||||
}: {
|
||||
graphVersion: number;
|
||||
zoomTier: GraphInteractionState["zoomTier"];
|
||||
layoutSettledEpoch: number;
|
||||
overviewBackboneEdgeIds: Set<string>;
|
||||
}) {
|
||||
return [
|
||||
graphVersion,
|
||||
zoomTier,
|
||||
layoutSettledEpoch,
|
||||
Array.from(overviewBackboneEdgeIds).sort().join(","),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function buildGraphStructureCurveCache({
|
||||
graphRef,
|
||||
cacheKey,
|
||||
classifyEdge,
|
||||
maxCurves,
|
||||
curveStrength,
|
||||
}: {
|
||||
graphRef: GraphRef;
|
||||
cacheKey: string;
|
||||
classifyEdge: (edgeId: string) => GraphFullEdgeClass;
|
||||
maxCurves: number;
|
||||
curveStrength: number;
|
||||
}): GraphStructureCurveCache {
|
||||
const candidates: Array<GraphStructureCurve & { rank: number }> = [];
|
||||
|
||||
graphRef.forEachEdge((edgeId, attrs, source, target) => {
|
||||
const stableEdgeId = String(edgeId);
|
||||
const edgeClass = classifyEdge(stableEdgeId);
|
||||
if (edgeClass !== "bridge" && edgeClass !== "backbone") {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
if (!graphRef.hasNode(sourceId) || !graphRef.hasNode(targetId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceAttrs = graphRef.getNodeAttributes(sourceId) as NodeAttributes;
|
||||
const targetAttrs = graphRef.getNodeAttributes(targetId) as NodeAttributes;
|
||||
if (!isFinitePoint(sourceAttrs) || !isFinitePoint(targetAttrs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const priority = getEdgePriority(attrs as EdgeAttributes);
|
||||
candidates.push({
|
||||
edgeId: stableEdgeId,
|
||||
sourceId,
|
||||
targetId,
|
||||
source: { x: Number(sourceAttrs.x), y: Number(sourceAttrs.y) },
|
||||
target: { x: Number(targetAttrs.x), y: Number(targetAttrs.y) },
|
||||
edgeClass,
|
||||
priority,
|
||||
curvature: getDeterministicCurveSign(sourceId, targetId, stableEdgeId) * curveStrength,
|
||||
rank: getCurveSortRank(edgeClass, priority),
|
||||
});
|
||||
});
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
if (right.rank !== left.rank) {
|
||||
return right.rank - left.rank;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
});
|
||||
|
||||
const curves = candidates.slice(0, maxCurves).map(({ rank: _rank, ...curve }) => curve);
|
||||
return {
|
||||
cacheKey,
|
||||
curves,
|
||||
bridgeCurveCount: curves.filter((curve) => curve.edgeClass === "bridge").length,
|
||||
backboneCurveCount: curves.filter((curve) => curve.edgeClass === "backbone").length,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGraphStructureLayerDiagnostics({
|
||||
gate,
|
||||
cache,
|
||||
minimumCurves,
|
||||
canvasAvailable,
|
||||
lastDrawAt,
|
||||
}: {
|
||||
gate: GraphStructureLayerGate;
|
||||
cache: GraphStructureCurveCache | null;
|
||||
minimumCurves: number;
|
||||
canvasAvailable: boolean;
|
||||
lastDrawAt: number | null;
|
||||
}): GraphStructureLayerDiagnostics {
|
||||
if (!gate.enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: gate.disabledReason,
|
||||
curveCount: 0,
|
||||
bridgeCurveCount: 0,
|
||||
backboneCurveCount: 0,
|
||||
cacheKey: cache?.cacheKey ?? "",
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (!canvasAvailable) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: "invalid-layer",
|
||||
curveCount: 0,
|
||||
bridgeCurveCount: 0,
|
||||
backboneCurveCount: 0,
|
||||
cacheKey: cache?.cacheKey ?? "",
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (!cache || cache.curves.length === 0) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: "no-eligible-edges",
|
||||
curveCount: 0,
|
||||
bridgeCurveCount: 0,
|
||||
backboneCurveCount: 0,
|
||||
cacheKey: cache?.cacheKey ?? "",
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (cache.curves.length < minimumCurves) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: "cache-empty",
|
||||
curveCount: cache.curves.length,
|
||||
bridgeCurveCount: cache.bridgeCurveCount,
|
||||
backboneCurveCount: cache.backboneCurveCount,
|
||||
cacheKey: cache.cacheKey,
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
curveCount: cache.curves.length,
|
||||
bridgeCurveCount: cache.bridgeCurveCount,
|
||||
backboneCurveCount: cache.backboneCurveCount,
|
||||
cacheKey: cache.cacheKey,
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearGraphStructureLayer(canvas: HTMLCanvasElement | null) {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
export function drawGraphStructureLayer({
|
||||
sigma,
|
||||
canvas,
|
||||
cache,
|
||||
}: {
|
||||
sigma: Sigma;
|
||||
canvas: HTMLCanvasElement;
|
||||
cache: GraphStructureCurveCache;
|
||||
}) {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { width, height } = sigma.getDimensions();
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
|
||||
let drawn = 0;
|
||||
for (const curve of cache.curves) {
|
||||
const sourceData = sigma.getNodeDisplayData(curve.sourceId);
|
||||
const targetData = sigma.getNodeDisplayData(curve.targetId);
|
||||
if (!sourceData || !targetData || sourceData.hidden || targetData.hidden) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePoint = sigma.graphToViewport(curve.source);
|
||||
const targetPoint = sigma.graphToViewport(curve.target);
|
||||
if (
|
||||
!Number.isFinite(sourcePoint.x)
|
||||
|| !Number.isFinite(sourcePoint.y)
|
||||
|| !Number.isFinite(targetPoint.x)
|
||||
|| !Number.isFinite(targetPoint.y)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dx = targetPoint.x - sourcePoint.x;
|
||||
const dy = targetPoint.y - sourcePoint.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
if (distance <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nx = -dy / distance;
|
||||
const ny = dx / distance;
|
||||
const offset = distance * curve.curvature;
|
||||
const controlX = (sourcePoint.x + targetPoint.x) / 2 + nx * offset;
|
||||
const controlY = (sourcePoint.y + targetPoint.y) / 2 + ny * offset;
|
||||
const layerTheme = GRAPH_THEME.edges.fullGraphStructureLayer;
|
||||
|
||||
context.beginPath();
|
||||
context.strokeStyle = curve.edgeClass === "bridge"
|
||||
? withAlpha(GRAPH_THEME.palette.muted.edgeFocus, layerTheme.bridgeAlpha)
|
||||
: withAlpha(GRAPH_THEME.palette.muted.edgeStructure, layerTheme.backboneAlpha);
|
||||
context.lineWidth = curve.edgeClass === "bridge"
|
||||
? layerTheme.bridgeLineWidth
|
||||
: layerTheme.backboneLineWidth;
|
||||
context.moveTo(sourcePoint.x, sourcePoint.y);
|
||||
context.quadraticCurveTo(controlX, controlY, targetPoint.x, targetPoint.y);
|
||||
context.stroke();
|
||||
drawn += 1;
|
||||
}
|
||||
|
||||
return drawn > 0;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export type GraphZoomTier = "overview" | "structure" | "inspection";
|
||||
export type GraphNodeVisualState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphEdgeVisualState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphNodeShapeVariant = "default" | "temporal" | "inferred" | "provenance" | "selected";
|
||||
export type GraphEntityShapeVariant = "entity" | "biomolecule" | "condition" | "compound" | "process" | "community";
|
||||
export type GraphEdgeVariant = "line" | "directional" | "bidirectionalCurve" | "parallelCurve" | "pathSignal";
|
||||
export type GraphArrowVisibilityPolicy = "hidden" | "contextual" | "always";
|
||||
export type GraphLabelVisibilityPolicy = "none" | "priority" | "local" | "always";
|
||||
@@ -53,6 +54,60 @@ export interface GraphTheme {
|
||||
nodeBorder: string;
|
||||
};
|
||||
};
|
||||
ui: {
|
||||
text: {
|
||||
strong: string;
|
||||
body: string;
|
||||
muted: string;
|
||||
subtle: string;
|
||||
inverse: string;
|
||||
};
|
||||
surface: {
|
||||
app: string;
|
||||
stage: string;
|
||||
card: string;
|
||||
cardSubtle: string;
|
||||
cardStrong: string;
|
||||
panel: string;
|
||||
panelBorder: string;
|
||||
divider: string;
|
||||
shadow: string;
|
||||
};
|
||||
scene: {
|
||||
background: string;
|
||||
radialGlow: string;
|
||||
grid: string;
|
||||
gridStrong: string;
|
||||
vignette: string;
|
||||
};
|
||||
control: {
|
||||
defaultBg: string;
|
||||
defaultBorder: string;
|
||||
defaultText: string;
|
||||
hoverBg: string;
|
||||
activeBg: string;
|
||||
activeBorder: string;
|
||||
activeText: string;
|
||||
primaryBg: string;
|
||||
primaryBorder: string;
|
||||
primaryText: string;
|
||||
disabledText: string;
|
||||
inputBg: string;
|
||||
inputBorder: string;
|
||||
focusRing: string;
|
||||
dangerText: string;
|
||||
};
|
||||
timeline: {
|
||||
background: string;
|
||||
border: string;
|
||||
gridMinor: string;
|
||||
gridMajor: string;
|
||||
text: string;
|
||||
textStrong: string;
|
||||
playhead: string;
|
||||
playheadSoft: string;
|
||||
};
|
||||
};
|
||||
zoomTiers: Record<GraphZoomTier, {
|
||||
maxRatio: number;
|
||||
nodeScale: number;
|
||||
@@ -134,6 +189,16 @@ export interface GraphTheme {
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeVisibleFrom: GraphZoomTier;
|
||||
}>;
|
||||
entityShapes: Record<GraphEntityShapeVariant, {
|
||||
label: string;
|
||||
shapeKind: number;
|
||||
aspectRatio: number;
|
||||
fillAlpha: number;
|
||||
shellAlpha: number;
|
||||
coreScale: number;
|
||||
borderBoost: number;
|
||||
minSize: number;
|
||||
}>;
|
||||
selectedRing: {
|
||||
color: string;
|
||||
width: number;
|
||||
@@ -171,6 +236,49 @@ export interface GraphTheme {
|
||||
sizeMultiplier: number;
|
||||
glowAlpha: number;
|
||||
}>;
|
||||
visibility: Record<"full" | "grouped" | "focused", Record<GraphZoomTier, {
|
||||
defaultPriorityThreshold: number;
|
||||
backgroundSampleRate: number;
|
||||
defaultAlpha: number;
|
||||
mutedAlpha: number;
|
||||
inactiveAlpha: number;
|
||||
neighborAlpha: number;
|
||||
sizeMultiplier: number;
|
||||
hideMuted: boolean;
|
||||
}>>;
|
||||
contextCaps: Record<"full" | "grouped" | "focused", Record<GraphZoomTier, number>>;
|
||||
fullGraphStructure: {
|
||||
ambientBackboneAlpha: number;
|
||||
backboneAlpha: number;
|
||||
bridgeAlpha: number;
|
||||
bridgeCurvePriorityThreshold: number;
|
||||
bridgeCurveStrength: number;
|
||||
backboneMaxSize: number;
|
||||
bridgeMaxSize: number;
|
||||
structureEdgeAlpha: number;
|
||||
inspectionEdgeAlpha: number;
|
||||
};
|
||||
fullGraphStructureLayer: {
|
||||
mode: "off" | "auto" | "always";
|
||||
minimumLiteralEdges: number;
|
||||
minimumCurves: number;
|
||||
maxCurves: number;
|
||||
bridgeAlpha: number;
|
||||
backboneAlpha: number;
|
||||
bridgeLineWidth: number;
|
||||
backboneLineWidth: number;
|
||||
curveStrength: number;
|
||||
};
|
||||
};
|
||||
interaction: {
|
||||
localContextAlpha: number;
|
||||
hoverContextAlpha: number;
|
||||
selectedEdgeAlpha: number;
|
||||
pathEdgeAlpha: number;
|
||||
localContextMaxSize: number;
|
||||
selectedEdgeMaxSize: number;
|
||||
pathEdgeMaxSize: number;
|
||||
pathOverlayAlpha: number;
|
||||
};
|
||||
overlays: {
|
||||
hoverGlowAlpha: number;
|
||||
@@ -302,9 +410,9 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
nodeCoreMix: 0.72,
|
||||
nodeShellAlpha: 0.97,
|
||||
nodeCoreAlpha: 1,
|
||||
edgeBackbone: "rgba(100, 148, 210, 0.38)",
|
||||
edgeStructure: "rgba(88, 140, 200, 0.28)",
|
||||
edgeInspection: "rgba(110, 165, 230, 0.48)",
|
||||
edgeBackbone: "rgba(84, 123, 145, 0.24)",
|
||||
edgeStructure: "rgba(49, 63, 78, 0.08)",
|
||||
edgeInspection: "rgba(76, 102, 128, 0.12)",
|
||||
},
|
||||
accent: {
|
||||
selected: "#F2D288",
|
||||
@@ -317,44 +425,98 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
muted: {
|
||||
fallback: "rgba(96, 112, 136, 0.18)",
|
||||
nodeAlpha: 0.12,
|
||||
edgeOverview: "rgba(82, 100, 124, 0.12)",
|
||||
edgeStructure: "rgba(92, 112, 138, 0.18)",
|
||||
edgeInspection: "rgba(124, 148, 176, 0.26)",
|
||||
edgeFocus: "rgba(160, 186, 218, 0.42)",
|
||||
edgeOverview: "rgba(32, 45, 55, 0.035)",
|
||||
edgeStructure: "rgba(42, 58, 72, 0.055)",
|
||||
edgeInspection: "rgba(62, 84, 104, 0.075)",
|
||||
edgeFocus: "rgba(132, 178, 202, 0.26)",
|
||||
},
|
||||
background: {
|
||||
canvas: "#07101A",
|
||||
shell: "rgba(8, 15, 26, 0.8)",
|
||||
shellBorder: "rgba(118, 162, 207, 0.14)",
|
||||
shellGlow: "rgba(48, 88, 140, 0.14)",
|
||||
grid: "rgba(92, 126, 170, 0.034)",
|
||||
vignette: "rgba(2, 5, 11, 0.84)",
|
||||
nodeBorder: "#0C1522",
|
||||
canvas: "#0A0D11",
|
||||
shell: "rgba(17, 21, 27, 0.82)",
|
||||
shellBorder: "rgba(170, 184, 205, 0.14)",
|
||||
shellGlow: "rgba(0, 0, 0, 0.28)",
|
||||
grid: "rgba(170, 184, 205, 0.026)",
|
||||
vignette: "rgba(3, 4, 7, 0.76)",
|
||||
nodeBorder: "#0B0F15",
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
text: {
|
||||
strong: "#F3F0E8",
|
||||
body: "#D5D9DD",
|
||||
muted: "#9AA3AE",
|
||||
subtle: "#6F7A86",
|
||||
inverse: "#0B0D10",
|
||||
},
|
||||
surface: {
|
||||
app: "#08090B",
|
||||
stage: "#0B0E12",
|
||||
card: "linear-gradient(180deg, rgba(28, 31, 36, 0.88), rgba(16, 18, 23, 0.78))",
|
||||
cardSubtle: "linear-gradient(180deg, rgba(23, 26, 31, 0.72), rgba(13, 15, 19, 0.64))",
|
||||
cardStrong: "linear-gradient(180deg, rgba(34, 37, 43, 0.94), rgba(18, 21, 26, 0.9))",
|
||||
panel: "linear-gradient(180deg, rgba(21, 24, 30, 0.92), rgba(12, 14, 18, 0.9))",
|
||||
panelBorder: "rgba(211, 205, 190, 0.13)",
|
||||
divider: "rgba(211, 205, 190, 0.1)",
|
||||
shadow: "0 22px 60px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.045)",
|
||||
},
|
||||
scene: {
|
||||
background: "linear-gradient(180deg, #0B0E12 0%, #07080B 100%)",
|
||||
radialGlow: "radial-gradient(circle at 50% 18%, rgba(88, 224, 204, 0.07), transparent 30%), radial-gradient(circle at 78% 0%, rgba(217, 168, 92, 0.055), transparent 26%)",
|
||||
grid: "rgba(210, 206, 196, 0.024)",
|
||||
gridStrong: "rgba(210, 206, 196, 0.052)",
|
||||
vignette: "radial-gradient(ellipse at center, transparent 42%, rgba(2, 3, 5, 0.82) 100%)",
|
||||
},
|
||||
control: {
|
||||
defaultBg: "rgba(255, 255, 255, 0.035)",
|
||||
defaultBorder: "rgba(211, 205, 190, 0.11)",
|
||||
defaultText: "#D7D1C4",
|
||||
hoverBg: "rgba(255, 255, 255, 0.065)",
|
||||
activeBg: "linear-gradient(180deg, rgba(74, 181, 166, 0.24), rgba(38, 118, 116, 0.18))",
|
||||
activeBorder: "rgba(98, 226, 205, 0.42)",
|
||||
activeText: "#E8FFFA",
|
||||
primaryBg: "linear-gradient(180deg, rgba(55, 145, 132, 0.42), rgba(24, 86, 88, 0.28))",
|
||||
primaryBorder: "rgba(99, 228, 206, 0.34)",
|
||||
primaryText: "#F2FFFB",
|
||||
disabledText: "rgba(154, 163, 174, 0.42)",
|
||||
inputBg: "rgba(5, 7, 10, 0.52)",
|
||||
inputBorder: "rgba(211, 205, 190, 0.13)",
|
||||
focusRing: "rgba(98, 226, 205, 0.16)",
|
||||
dangerText: "#FF9A8D",
|
||||
},
|
||||
timeline: {
|
||||
background: "linear-gradient(180deg, rgba(14, 18, 24, 0.86), rgba(8, 11, 15, 0.92))",
|
||||
border: "rgba(170, 184, 205, 0.12)",
|
||||
gridMinor: "rgba(170, 184, 205, 0.04)",
|
||||
gridMajor: "rgba(170, 184, 205, 0.09)",
|
||||
text: "#7A92AE",
|
||||
textStrong: "#A5B7CD",
|
||||
playhead: "#8FE7FF",
|
||||
playheadSoft: "rgba(143, 231, 255, 0.12)",
|
||||
},
|
||||
},
|
||||
zoomTiers: {
|
||||
overview: {
|
||||
maxRatio: Number.POSITIVE_INFINITY,
|
||||
nodeScale: 0.72,
|
||||
labelThreshold: 0.995,
|
||||
labelBudget: 4,
|
||||
labelThreshold: 0.998,
|
||||
labelBudget: 2,
|
||||
edgePriorityThreshold: 0.72,
|
||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||
edgeSizeScale: 0.62,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showCurves: true,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
structure: {
|
||||
maxRatio: 1.2,
|
||||
nodeScale: 0.94,
|
||||
labelThreshold: 0.93,
|
||||
labelBudget: 18,
|
||||
labelThreshold: 0.95,
|
||||
labelBudget: 12,
|
||||
edgePriorityThreshold: 0.4,
|
||||
arrowPriorityThreshold: 0.75,
|
||||
edgeSizeScale: 0.92,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showCurves: true,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
inspection: {
|
||||
@@ -428,11 +590,11 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
|
||||
},
|
||||
states: {
|
||||
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
|
||||
path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
|
||||
default: { color: "base", sizeMultiplier: 0.7, minSize: 0.64, forceLabel: false, zIndex: 0, borderBoost: -0.46 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.08, minSize: 10.4, forceLabel: true, zIndex: 4, borderBoost: 0.2 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.2, forceLabel: true, zIndex: 3, borderBoost: 0.22 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.76, minSize: 4, forceLabel: false, zIndex: 2, borderBoost: -0.08 },
|
||||
path: { color: "path", sizeMultiplier: 0.96, minSize: 5.6, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
},
|
||||
@@ -443,6 +605,68 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "inspection" },
|
||||
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
|
||||
},
|
||||
entityShapes: {
|
||||
entity: {
|
||||
label: "Entity",
|
||||
shapeKind: 0,
|
||||
aspectRatio: 1,
|
||||
fillAlpha: 0.9,
|
||||
shellAlpha: 0.14,
|
||||
coreScale: 0,
|
||||
borderBoost: 0.08,
|
||||
minSize: 0,
|
||||
},
|
||||
biomolecule: {
|
||||
label: "Biomolecule",
|
||||
shapeKind: 1,
|
||||
aspectRatio: 1,
|
||||
fillAlpha: 0.9,
|
||||
shellAlpha: 0.16,
|
||||
coreScale: 0.18,
|
||||
borderBoost: 0.16,
|
||||
minSize: 1.2,
|
||||
},
|
||||
condition: {
|
||||
label: "Condition",
|
||||
shapeKind: 2,
|
||||
aspectRatio: 1.04,
|
||||
fillAlpha: 0.88,
|
||||
shellAlpha: 0.15,
|
||||
coreScale: 0.16,
|
||||
borderBoost: 0.18,
|
||||
minSize: 1.6,
|
||||
},
|
||||
compound: {
|
||||
label: "Compound",
|
||||
shapeKind: 3,
|
||||
aspectRatio: 1.48,
|
||||
fillAlpha: 0.88,
|
||||
shellAlpha: 0.15,
|
||||
coreScale: 0.14,
|
||||
borderBoost: 0.14,
|
||||
minSize: 1.4,
|
||||
},
|
||||
process: {
|
||||
label: "Process",
|
||||
shapeKind: 4,
|
||||
aspectRatio: 1.1,
|
||||
fillAlpha: 0.87,
|
||||
shellAlpha: 0.14,
|
||||
coreScale: 0.14,
|
||||
borderBoost: 0.16,
|
||||
minSize: 1.4,
|
||||
},
|
||||
community: {
|
||||
label: "Community",
|
||||
shapeKind: 5,
|
||||
aspectRatio: 1,
|
||||
fillAlpha: 0.68,
|
||||
shellAlpha: 0.28,
|
||||
coreScale: 0.78,
|
||||
borderBoost: 0.34,
|
||||
minSize: 2,
|
||||
},
|
||||
},
|
||||
selectedRing: {
|
||||
color: "#E7C57C",
|
||||
width: 1.9,
|
||||
@@ -467,14 +691,14 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
edges: {
|
||||
states: {
|
||||
default: { color: "structure", sizeMultiplier: 0.96, minSize: 0.9, zIndex: 0, forceArrow: false, hide: false },
|
||||
backbone: { color: "backbone", sizeMultiplier: 1.0, minSize: 1.0, zIndex: 1, forceArrow: false, hide: false },
|
||||
hovered: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
|
||||
selected: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
|
||||
neighbor: { color: "focus", sizeMultiplier: 1.1, minSize: 1.2, zIndex: 1, forceArrow: false, hide: false },
|
||||
path: { color: "path", sizeMultiplier: 1.7, minSize: 2.4, zIndex: 4, forceArrow: true, hide: false },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
|
||||
muted: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
|
||||
default: { color: "structure", sizeMultiplier: 0.48, minSize: 0.2, zIndex: 0, forceArrow: false, hide: false },
|
||||
backbone: { color: "backbone", sizeMultiplier: 0.62, minSize: 0.36, zIndex: 1, forceArrow: false, hide: false },
|
||||
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.85, zIndex: 5, forceArrow: true, hide: false },
|
||||
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.85, zIndex: 5, forceArrow: true, hide: false },
|
||||
neighbor: { color: "focus", sizeMultiplier: 0.96, minSize: 0.86, zIndex: 1, forceArrow: false, hide: false },
|
||||
path: { color: "path", sizeMultiplier: 1.82, minSize: 2.55, zIndex: 6, forceArrow: true, hide: false },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.24, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
|
||||
muted: { color: "muted", sizeMultiplier: 0.24, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
|
||||
},
|
||||
variants: {
|
||||
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
|
||||
@@ -483,8 +707,155 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
parallelCurve: { baseType: "line", arrowPolicy: "contextual", curveStrength: 0.24, sizeMultiplier: 1.1, glowAlpha: 0.12 },
|
||||
pathSignal: { baseType: "arrow", arrowPolicy: "always", curveStrength: 0.16, sizeMultiplier: 1.18, glowAlpha: 0.2 },
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
visibility: {
|
||||
full: {
|
||||
overview: {
|
||||
defaultPriorityThreshold: 0.96,
|
||||
backgroundSampleRate: 0.035,
|
||||
defaultAlpha: 0.026,
|
||||
mutedAlpha: 0.012,
|
||||
inactiveAlpha: 0.01,
|
||||
neighborAlpha: 0.26,
|
||||
sizeMultiplier: 0.5,
|
||||
hideMuted: true,
|
||||
},
|
||||
structure: {
|
||||
defaultPriorityThreshold: 0.82,
|
||||
backgroundSampleRate: 0.16,
|
||||
defaultAlpha: 0.04,
|
||||
mutedAlpha: 0.014,
|
||||
inactiveAlpha: 0.012,
|
||||
neighborAlpha: 0.32,
|
||||
sizeMultiplier: 0.62,
|
||||
hideMuted: true,
|
||||
},
|
||||
inspection: {
|
||||
defaultPriorityThreshold: 0.72,
|
||||
backgroundSampleRate: 0.28,
|
||||
defaultAlpha: 0.052,
|
||||
mutedAlpha: 0.012,
|
||||
inactiveAlpha: 0.01,
|
||||
neighborAlpha: 0.38,
|
||||
sizeMultiplier: 0.64,
|
||||
hideMuted: true,
|
||||
},
|
||||
},
|
||||
grouped: {
|
||||
overview: {
|
||||
defaultPriorityThreshold: 0.42,
|
||||
backgroundSampleRate: 1,
|
||||
defaultAlpha: 0.18,
|
||||
mutedAlpha: 0.06,
|
||||
inactiveAlpha: 0.04,
|
||||
neighborAlpha: 0.36,
|
||||
sizeMultiplier: 0.72,
|
||||
hideMuted: false,
|
||||
},
|
||||
structure: {
|
||||
defaultPriorityThreshold: 0.34,
|
||||
backgroundSampleRate: 1,
|
||||
defaultAlpha: 0.2,
|
||||
mutedAlpha: 0.07,
|
||||
inactiveAlpha: 0.05,
|
||||
neighborAlpha: 0.42,
|
||||
sizeMultiplier: 0.78,
|
||||
hideMuted: false,
|
||||
},
|
||||
inspection: {
|
||||
defaultPriorityThreshold: 0.28,
|
||||
backgroundSampleRate: 1,
|
||||
defaultAlpha: 0.22,
|
||||
mutedAlpha: 0.08,
|
||||
inactiveAlpha: 0.06,
|
||||
neighborAlpha: 0.46,
|
||||
sizeMultiplier: 0.82,
|
||||
hideMuted: false,
|
||||
},
|
||||
},
|
||||
focused: {
|
||||
overview: {
|
||||
defaultPriorityThreshold: 0.72,
|
||||
backgroundSampleRate: 0.7,
|
||||
defaultAlpha: 0.1,
|
||||
mutedAlpha: 0.03,
|
||||
inactiveAlpha: 0.02,
|
||||
neighborAlpha: 0.06,
|
||||
sizeMultiplier: 0.72,
|
||||
hideMuted: true,
|
||||
},
|
||||
structure: {
|
||||
defaultPriorityThreshold: 0.6,
|
||||
backgroundSampleRate: 0.8,
|
||||
defaultAlpha: 0.12,
|
||||
mutedAlpha: 0.035,
|
||||
inactiveAlpha: 0.025,
|
||||
neighborAlpha: 0.08,
|
||||
sizeMultiplier: 0.8,
|
||||
hideMuted: true,
|
||||
},
|
||||
inspection: {
|
||||
defaultPriorityThreshold: 0.52,
|
||||
backgroundSampleRate: 0.9,
|
||||
defaultAlpha: 0.14,
|
||||
mutedAlpha: 0.04,
|
||||
inactiveAlpha: 0.03,
|
||||
neighborAlpha: 0.1,
|
||||
sizeMultiplier: 0.88,
|
||||
hideMuted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
contextCaps: {
|
||||
full: {
|
||||
overview: 0,
|
||||
structure: 12,
|
||||
inspection: 24,
|
||||
},
|
||||
grouped: {
|
||||
overview: 6,
|
||||
structure: 8,
|
||||
inspection: 10,
|
||||
},
|
||||
focused: {
|
||||
overview: 24,
|
||||
structure: 36,
|
||||
inspection: 48,
|
||||
},
|
||||
},
|
||||
fullGraphStructure: {
|
||||
ambientBackboneAlpha: 0.12,
|
||||
backboneAlpha: 0.08,
|
||||
bridgeAlpha: 0.14,
|
||||
bridgeCurvePriorityThreshold: 0.78,
|
||||
bridgeCurveStrength: 0.1,
|
||||
backboneMaxSize: 0.5,
|
||||
bridgeMaxSize: 0.7,
|
||||
structureEdgeAlpha: 0.12,
|
||||
inspectionEdgeAlpha: 0.1,
|
||||
},
|
||||
fullGraphStructureLayer: {
|
||||
mode: "off",
|
||||
minimumLiteralEdges: 24,
|
||||
minimumCurves: 8,
|
||||
maxCurves: 64,
|
||||
bridgeAlpha: 0.16,
|
||||
backboneAlpha: 0.1,
|
||||
bridgeLineWidth: 0.9,
|
||||
backboneLineWidth: 0.62,
|
||||
curveStrength: 0.12,
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
localContextAlpha: 0.32,
|
||||
hoverContextAlpha: 0.32,
|
||||
selectedEdgeAlpha: 0.6,
|
||||
pathEdgeAlpha: 0.76,
|
||||
localContextMaxSize: 0.6,
|
||||
selectedEdgeMaxSize: 1.0,
|
||||
pathEdgeMaxSize: 1.4,
|
||||
pathOverlayAlpha: 0.16,
|
||||
},
|
||||
overlays: {
|
||||
hoverGlowAlpha: 0.18,
|
||||
pathGlowAlpha: 0.16,
|
||||
glowRadiusMultiplier: 4.8,
|
||||
@@ -629,7 +1000,7 @@ export function withAlpha(color: string | undefined, alpha: number): string {
|
||||
}
|
||||
|
||||
if (color.startsWith("rgba(")) {
|
||||
return color.replace(/rgba\(([^)]+),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
|
||||
return color.replace(/rgba\((.*?),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
|
||||
}
|
||||
|
||||
if (color.startsWith("rgb(")) {
|
||||
|
||||
@@ -7,11 +7,11 @@ import type {
|
||||
GraphCameraState,
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphInteractionState,
|
||||
GraphLayoutSource,
|
||||
GraphLayoutStatus,
|
||||
GraphRuntimeDiagnosticsSnapshot,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
@@ -35,7 +35,7 @@ export interface GraphSceneEventMap {
|
||||
onEdgeSelect?: (edgeId: string) => void;
|
||||
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
|
||||
onCameraStateChange?: (cameraState: GraphCameraState) => void;
|
||||
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
|
||||
onDiagnosticsChange?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => void;
|
||||
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
|
||||
onRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { NodeDisplayData, RenderParams } from "sigma/types";
|
||||
import { floatColor } from "sigma/utils";
|
||||
import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
|
||||
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import { GRAPH_THEME, type GraphEntityShapeVariant, withAlpha } from "./graphTheme";
|
||||
|
||||
type SemanticaNodeDrawData = {
|
||||
x: number;
|
||||
@@ -16,39 +16,106 @@ type SemanticaNodeDrawData = {
|
||||
shellColor?: string;
|
||||
coreScale?: number;
|
||||
borderColor?: string;
|
||||
borderSize?: number;
|
||||
ringColor?: string;
|
||||
ringSize?: number;
|
||||
entityShape?: GraphEntityShapeVariant;
|
||||
entityShapeKind?: number;
|
||||
entityAspectRatio?: number;
|
||||
nodeType?: string;
|
||||
};
|
||||
|
||||
const MINERAL_DISC_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
|
||||
const ENTITY_TOKEN_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
|
||||
|
||||
const MINERAL_DISC_FRAGMENT_SHADER = /* glsl */ `
|
||||
const ENTITY_TOKEN_FRAGMENT_SHADER = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
varying vec4 v_coreColor;
|
||||
varying vec4 v_shellColor;
|
||||
varying vec4 v_ringColor;
|
||||
varying vec4 v_bodyColor;
|
||||
varying vec4 v_glyphColor;
|
||||
varying vec4 v_outlineColor;
|
||||
varying vec4 v_color;
|
||||
varying vec2 v_diffVector;
|
||||
varying float v_radius;
|
||||
varying float v_ringSize;
|
||||
varying float v_coreScale;
|
||||
varying float v_outlineSize;
|
||||
varying float v_glyphScale;
|
||||
varying float v_shapeKind;
|
||||
varying float v_aspectRatio;
|
||||
|
||||
uniform float u_correctionRatio;
|
||||
|
||||
const float bias = 255.0 / 254.0;
|
||||
const vec4 transparent = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
float discMetric(vec2 point) {
|
||||
return length(point);
|
||||
float hexMetric(vec2 point) {
|
||||
vec2 q = abs(point);
|
||||
return max(q.y, q.x * 0.8660254 + q.y * 0.5);
|
||||
}
|
||||
|
||||
vec2 rotate45(vec2 point) {
|
||||
const float invSqrt2 = 0.70710678;
|
||||
return vec2(
|
||||
(point.x - point.y) * invSqrt2,
|
||||
(point.x + point.y) * invSqrt2
|
||||
);
|
||||
}
|
||||
|
||||
float roundedBoxDistance(vec2 point, vec2 halfSize, float radius) {
|
||||
vec2 q = abs(point) - halfSize + vec2(radius);
|
||||
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
|
||||
}
|
||||
|
||||
float capsuleDistance(vec2 point) {
|
||||
vec2 q = vec2(max(abs(point.x) - 0.44, 0.0), point.y);
|
||||
return length(q) - 0.56;
|
||||
}
|
||||
|
||||
float shapeDistance(vec2 point, float shapeKind) {
|
||||
if (shapeKind < 0.5) {
|
||||
return length(point) - 1.0;
|
||||
}
|
||||
if (shapeKind < 1.5) {
|
||||
return hexMetric(point) - 0.92;
|
||||
}
|
||||
if (shapeKind < 2.5) {
|
||||
return roundedBoxDistance(rotate45(point), vec2(0.58, 0.58), 0.18);
|
||||
}
|
||||
if (shapeKind < 3.5) {
|
||||
return capsuleDistance(point);
|
||||
}
|
||||
if (shapeKind < 4.5) {
|
||||
return roundedBoxDistance(point, vec2(0.78, 0.78), 0.24);
|
||||
}
|
||||
return length(point) - 1.0;
|
||||
}
|
||||
|
||||
float glyphDistance(vec2 point, float shapeKind, float scale) {
|
||||
vec2 scaled = point / max(scale, 0.08);
|
||||
if (shapeKind < 0.5) {
|
||||
return 1.0;
|
||||
}
|
||||
if (shapeKind < 1.5) {
|
||||
return abs(hexMetric(scaled) - 0.74) - 0.055;
|
||||
}
|
||||
if (shapeKind < 2.5) {
|
||||
return abs(abs(scaled.x) + abs(scaled.y) - 0.78) - 0.045;
|
||||
}
|
||||
if (shapeKind < 3.5) {
|
||||
return roundedBoxDistance(scaled, vec2(0.56, 0.07), 0.07);
|
||||
}
|
||||
if (shapeKind < 4.5) {
|
||||
return abs(roundedBoxDistance(scaled, vec2(0.48, 0.48), 0.18)) - 0.045;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec2 unit = v_diffVector / max(v_radius, 0.0001);
|
||||
float metric = discMetric(unit);
|
||||
vec2 unit = vec2(
|
||||
v_diffVector.x / max(v_radius * v_aspectRatio, 0.0001),
|
||||
v_diffVector.y / max(v_radius, 0.0001)
|
||||
);
|
||||
float aa = (2.4 * u_correctionRatio) / max(v_radius, 1.0);
|
||||
float alpha = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, metric);
|
||||
float distance = shapeDistance(unit, v_shapeKind);
|
||||
float alpha = 1.0 - smoothstep(-aa, aa, distance);
|
||||
|
||||
#ifdef PICKING_MODE
|
||||
if (alpha <= 0.0) {
|
||||
@@ -63,52 +130,63 @@ void main(void) {
|
||||
return;
|
||||
}
|
||||
|
||||
float ringNorm = clamp(v_ringSize / max(v_radius, 1.0), 0.0, 0.45);
|
||||
float ringStart = max(0.0, 1.0 - ringNorm);
|
||||
float coreEdge = clamp(v_coreScale, 0.06, 0.78);
|
||||
float coreBlend = 1.0 - smoothstep(max(coreEdge - 0.14, 0.0), coreEdge, metric);
|
||||
float bodyLight = 1.0 - smoothstep(0.0, 0.82, metric);
|
||||
vec4 color = mix(v_shellColor, v_coreColor, coreBlend);
|
||||
color.rgb += vec3(0.022) * pow(bodyLight, 1.45);
|
||||
float outlineNorm = clamp(v_outlineSize / max(v_radius, 1.0), 0.035, 0.28);
|
||||
float outlineBlend = 1.0 - smoothstep(-outlineNorm - aa, -outlineNorm + aa, distance);
|
||||
float isOutline = 1.0 - outlineBlend;
|
||||
float topLight = clamp((-unit.y + 0.85) * 0.5, 0.0, 1.0);
|
||||
vec4 color = v_bodyColor;
|
||||
color.rgb += vec3(0.014) * pow(topLight, 2.2);
|
||||
|
||||
if (ringNorm > 0.0 && metric >= ringStart) {
|
||||
color = v_ringColor;
|
||||
if (isOutline > 0.0) {
|
||||
color = mix(color, v_outlineColor, isOutline);
|
||||
}
|
||||
|
||||
float glyphVisible = step(7.25, v_radius) * step(0.13, v_glyphScale) * step(0.5, v_shapeKind) * (1.0 - step(4.5, v_shapeKind));
|
||||
float glyph = (1.0 - smoothstep(-aa * 1.4, aa * 1.4, glyphDistance(unit, v_shapeKind, clamp(v_glyphScale, 0.16, 0.52)))) * glyphVisible;
|
||||
if (glyph > 0.0 && distance < -outlineNorm) {
|
||||
color = mix(color, v_glyphColor, glyph * 0.38);
|
||||
}
|
||||
|
||||
color.a *= alpha;
|
||||
gl_FragColor = color;
|
||||
#endif
|
||||
}
|
||||
`;
|
||||
|
||||
const MINERAL_DISC_VERTEX_SHADER = /* glsl */ `
|
||||
const ENTITY_TOKEN_VERTEX_SHADER = /* glsl */ `
|
||||
attribute vec4 a_id;
|
||||
attribute vec2 a_position;
|
||||
attribute float a_size;
|
||||
attribute float a_angle;
|
||||
attribute vec4 a_coreColor;
|
||||
attribute vec4 a_shellColor;
|
||||
attribute vec4 a_ringColor;
|
||||
attribute float a_ringSize;
|
||||
attribute float a_coreScale;
|
||||
attribute vec4 a_bodyColor;
|
||||
attribute vec4 a_glyphColor;
|
||||
attribute vec4 a_outlineColor;
|
||||
attribute float a_outlineSize;
|
||||
attribute float a_glyphScale;
|
||||
attribute float a_shapeKind;
|
||||
attribute float a_aspectRatio;
|
||||
|
||||
uniform mat3 u_matrix;
|
||||
uniform float u_sizeRatio;
|
||||
uniform float u_correctionRatio;
|
||||
|
||||
varying vec4 v_coreColor;
|
||||
varying vec4 v_shellColor;
|
||||
varying vec4 v_ringColor;
|
||||
varying vec4 v_bodyColor;
|
||||
varying vec4 v_glyphColor;
|
||||
varying vec4 v_outlineColor;
|
||||
varying vec4 v_color;
|
||||
varying vec2 v_diffVector;
|
||||
varying float v_radius;
|
||||
varying float v_ringSize;
|
||||
varying float v_coreScale;
|
||||
varying float v_outlineSize;
|
||||
varying float v_glyphScale;
|
||||
varying float v_shapeKind;
|
||||
varying float v_aspectRatio;
|
||||
|
||||
const float bias = 255.0 / 254.0;
|
||||
|
||||
void main() {
|
||||
float size = a_size * u_correctionRatio / u_sizeRatio * 4.0;
|
||||
vec2 diffVector = size * vec2(cos(a_angle), sin(a_angle));
|
||||
float aspect = max(a_aspectRatio, 1.0);
|
||||
vec2 diffVector = size * vec2(cos(a_angle) * aspect, sin(a_angle));
|
||||
vec2 position = a_position + diffVector;
|
||||
|
||||
gl_Position = vec4(
|
||||
@@ -119,22 +197,24 @@ void main() {
|
||||
|
||||
v_diffVector = diffVector;
|
||||
v_radius = size / 2.0;
|
||||
v_ringSize = a_ringSize;
|
||||
v_coreScale = a_coreScale;
|
||||
v_outlineSize = a_outlineSize;
|
||||
v_glyphScale = a_glyphScale;
|
||||
v_shapeKind = a_shapeKind;
|
||||
v_aspectRatio = aspect;
|
||||
|
||||
#ifdef PICKING_MODE
|
||||
v_color = a_id;
|
||||
#else
|
||||
v_coreColor = a_coreColor;
|
||||
v_shellColor = a_shellColor;
|
||||
v_ringColor = a_ringColor;
|
||||
v_bodyColor = a_bodyColor;
|
||||
v_glyphColor = a_glyphColor;
|
||||
v_outlineColor = a_outlineColor;
|
||||
#endif
|
||||
|
||||
v_color.a *= bias;
|
||||
}
|
||||
`;
|
||||
|
||||
class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[number]> {
|
||||
class EntityTokenNodeProgram extends NodeProgram<(typeof ENTITY_TOKEN_UNIFORMS)[number]> {
|
||||
static readonly ANGLE_1 = 0;
|
||||
static readonly ANGLE_2 = (2 * Math.PI) / 3;
|
||||
static readonly ANGLE_3 = (4 * Math.PI) / 3;
|
||||
@@ -146,47 +226,52 @@ class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[
|
||||
getDefinition() {
|
||||
return {
|
||||
VERTICES: 3,
|
||||
VERTEX_SHADER_SOURCE: MINERAL_DISC_VERTEX_SHADER,
|
||||
FRAGMENT_SHADER_SOURCE: MINERAL_DISC_FRAGMENT_SHADER,
|
||||
VERTEX_SHADER_SOURCE: ENTITY_TOKEN_VERTEX_SHADER,
|
||||
FRAGMENT_SHADER_SOURCE: ENTITY_TOKEN_FRAGMENT_SHADER,
|
||||
METHOD: WebGLRenderingContext.TRIANGLES,
|
||||
UNIFORMS: MINERAL_DISC_UNIFORMS,
|
||||
UNIFORMS: ENTITY_TOKEN_UNIFORMS,
|
||||
ATTRIBUTES: [
|
||||
{ name: "a_position", size: 2, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_size", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_coreColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_shellColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_ringColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_ringSize", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_coreScale", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_bodyColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_glyphColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_outlineColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_outlineSize", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_glyphScale", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_shapeKind", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_aspectRatio", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_id", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
],
|
||||
CONSTANT_ATTRIBUTES: [
|
||||
{ name: "a_angle", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
],
|
||||
CONSTANT_DATA: [
|
||||
[MineralDiscNodeProgram.ANGLE_1],
|
||||
[MineralDiscNodeProgram.ANGLE_2],
|
||||
[MineralDiscNodeProgram.ANGLE_3],
|
||||
[EntityTokenNodeProgram.ANGLE_1],
|
||||
[EntityTokenNodeProgram.ANGLE_2],
|
||||
[EntityTokenNodeProgram.ANGLE_3],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
processVisibleItem(nodeIndex: number, startIndex: number, data: NodeDisplayData & SemanticaNodeDrawData): void {
|
||||
const array = this.array;
|
||||
const ringColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, GRAPH_THEME.nodes.selectedRing.color);
|
||||
const outlineColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, GRAPH_THEME.nodes.selectedRing.color);
|
||||
const outlineSize = Math.max(data.ringSize || 0, data.borderSize || 0.7);
|
||||
|
||||
array[startIndex++] = data.x;
|
||||
array[startIndex++] = data.y;
|
||||
array[startIndex++] = data.size;
|
||||
array[startIndex++] = floatColor(data.color || GRAPH_THEME.palette.overview.nodeCore);
|
||||
array[startIndex++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, GRAPH_THEME.palette.overview.nodeShellAlpha));
|
||||
array[startIndex++] = floatColor(ringColor);
|
||||
array[startIndex++] = data.ringSize || 0;
|
||||
array[startIndex++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, 0.58));
|
||||
array[startIndex++] = floatColor(outlineColor);
|
||||
array[startIndex++] = outlineSize;
|
||||
array[startIndex++] = data.coreScale ?? 0.22;
|
||||
array[startIndex++] = data.entityShapeKind ?? GRAPH_THEME.nodes.entityShapes[data.entityShape || "entity"].shapeKind;
|
||||
array[startIndex++] = data.entityAspectRatio ?? GRAPH_THEME.nodes.entityShapes[data.entityShape || "entity"].aspectRatio;
|
||||
array[startIndex++] = nodeIndex;
|
||||
}
|
||||
|
||||
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof MINERAL_DISC_UNIFORMS)[number]>): void {
|
||||
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof ENTITY_TOKEN_UNIFORMS)[number]>): void {
|
||||
gl.uniform1f(uniformLocations.u_correctionRatio, params.correctionRatio);
|
||||
gl.uniform1f(uniformLocations.u_sizeRatio, params.sizeRatio);
|
||||
gl.uniformMatrix3fv(uniformLocations.u_matrix, false, params.matrix);
|
||||
@@ -326,7 +411,7 @@ export const drawSemanticaNodeHover: NodeHoverDrawingFunction = (context, rawDat
|
||||
|
||||
export const SEMANTICA_NODE_PROGRAM_CLASSES = {
|
||||
...DEFAULT_NODE_PROGRAM_CLASSES,
|
||||
circle: MineralDiscNodeProgram,
|
||||
circle: EntityTokenNodeProgram,
|
||||
};
|
||||
|
||||
export const SEMANTICA_EDGE_PROGRAM_CLASSES = {
|
||||
|
||||
@@ -12,6 +12,7 @@ export type GraphLoadPhase =
|
||||
export type GraphLoadProgressKind = "determinate" | "indeterminate";
|
||||
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
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 interface GraphCameraState {
|
||||
@@ -92,11 +93,49 @@ export interface GraphEffectAvailability {
|
||||
segmentCap?: number;
|
||||
}
|
||||
|
||||
export type GraphFullEdgeClassCounts = Record<GraphFullEdgeClass, number>;
|
||||
|
||||
export interface GraphFullEdgeClassDiagnostics {
|
||||
mode: GraphViewMode;
|
||||
zoomTier: GraphInteractionState["zoomTier"];
|
||||
totalEdges: number;
|
||||
visibleEdges: number;
|
||||
counts: GraphFullEdgeClassCounts;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type GraphStructureLayerDisabledReason =
|
||||
| "non-full-mode"
|
||||
| "layout-running"
|
||||
| "enough-literal-edges"
|
||||
| "no-eligible-edges"
|
||||
| "invalid-layer"
|
||||
| "cache-empty"
|
||||
| "disabled";
|
||||
|
||||
export interface GraphStructureLayerDiagnostics {
|
||||
enabled: boolean;
|
||||
disabledReason: GraphStructureLayerDisabledReason | null;
|
||||
curveCount: number;
|
||||
bridgeCurveCount: number;
|
||||
backboneCurveCount: number;
|
||||
cacheKey: string;
|
||||
lastDrawAt: number | null;
|
||||
}
|
||||
|
||||
export interface GraphRuntimeDiagnosticsSnapshot {
|
||||
effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"];
|
||||
edgeClasses?: GraphFullEdgeClassDiagnostics;
|
||||
structureLayer?: GraphStructureLayerDiagnostics;
|
||||
}
|
||||
|
||||
export interface GraphDiagnosticsSnapshot {
|
||||
interactionState: GraphInteractionState;
|
||||
activePluginIds: string[];
|
||||
openPanelIds: string[];
|
||||
effectsState: GraphEffectsState;
|
||||
edgeClasses?: GraphFullEdgeClassDiagnostics;
|
||||
structureLayer?: GraphStructureLayerDiagnostics;
|
||||
effectAvailability: {
|
||||
pathPulse: GraphEffectAvailability;
|
||||
pathFlow: GraphEffectAvailability;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
withAlpha,
|
||||
type GraphBadgeKind,
|
||||
type GraphEdgeVariant,
|
||||
type GraphEntityShapeVariant,
|
||||
type GraphLabelVisibilityPolicy,
|
||||
type GraphNodeShapeVariant,
|
||||
} from "./graphTheme";
|
||||
@@ -29,6 +30,12 @@ const SEMANTIC_COLOR_FIELDS = [
|
||||
] as const;
|
||||
|
||||
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
|
||||
const ENTITY_SHAPE_ALIASES: Array<[GraphEntityShapeVariant, RegExp]> = [
|
||||
["biomolecule", /\b(gene|protein|enzyme|receptor|target|transcript|rna|dna|mirna|biomolecule|peptide)\b/i],
|
||||
["condition", /\b(disease|condition|phenotype|symptom|disorder|syndrome|diagnosis|pathology|trait)\b/i],
|
||||
["compound", /\b(drug|chemical|compound|metabolite|molecule|small[_\s-]?molecule|ligand|therapeutic|medication|substance)\b/i],
|
||||
["process", /\b(pathway|process|mechanism|function|ontology|biological[_\s-]?process|cellular[_\s-]?process|program|module)\b/i],
|
||||
];
|
||||
|
||||
function getSemanticFieldValue(attributes: NodeAttributes, field: (typeof SEMANTIC_COLOR_FIELDS)[number]): string | null {
|
||||
if (field === "nodeType") {
|
||||
@@ -196,6 +203,27 @@ function getProvenanceCount(properties: Record<string, unknown>): number {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEntityShape(attributes: NodeAttributes, semanticGroup: string): GraphEntityShapeVariant {
|
||||
const values = [
|
||||
attributes.nodeType,
|
||||
semanticGroup,
|
||||
attributes.content,
|
||||
String(attributes.properties?.type ?? ""),
|
||||
String(attributes.properties?.category ?? ""),
|
||||
String(attributes.properties?.label ?? ""),
|
||||
]
|
||||
.filter((value) => typeof value === "string" && value.trim().length > 0)
|
||||
.join(" ");
|
||||
|
||||
for (const [shape, pattern] of ENTITY_SHAPE_ALIASES) {
|
||||
if (pattern.test(values)) {
|
||||
return shape;
|
||||
}
|
||||
}
|
||||
|
||||
return "entity";
|
||||
}
|
||||
|
||||
function resolveNodeVariantMetadata(
|
||||
baseColor: string,
|
||||
sizeRatio: number,
|
||||
@@ -548,6 +576,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
const hasTemporalBounds = Boolean(attributes.valid_from || attributes.valid_until);
|
||||
const provenanceCount = getProvenanceCount(attributes.properties ?? {});
|
||||
const properties = attributes.properties as Record<string, unknown>;
|
||||
const entityShape = resolveEntityShape(attributes, semanticGroup);
|
||||
const providedX = readFiniteCoordinate(properties?.x);
|
||||
const providedY = readFiniteCoordinate(properties?.y);
|
||||
const seededPosition = seededPositions?.get(id);
|
||||
@@ -575,6 +604,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
strokeColor: darkenHex(baseColor, 112),
|
||||
borderColor: darkenHex(baseColor, 112),
|
||||
borderSize: 0.72,
|
||||
entityShape,
|
||||
...resolveNodeVariantMetadata(baseColor, sizeRatio, hasTemporalBounds, provenanceCount),
|
||||
} as NodeAttributes,
|
||||
};
|
||||
@@ -598,6 +628,12 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
const parallelIndex = parallelOffsets.get(pairKey) ?? 0;
|
||||
parallelOffsets.set(pairKey, parallelIndex + 1);
|
||||
const parallelCount = parallelCounts.get(pairKey) ?? 1;
|
||||
const normalizedWeight = clamp(0, Math.log1p(Math.max(Number(edge.weight) || 1, 1)) / 6, 1);
|
||||
const edgeVisualPriority = clamp(
|
||||
0,
|
||||
Math.sqrt(Math.max(sourcePriority, 0) * Math.max(targetPriority, 0)) * 0.72 + normalizedWeight * 0.28,
|
||||
1,
|
||||
);
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
@@ -617,7 +653,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
color: GRAPH_THEME.palette.muted.edgeStructure,
|
||||
baseColor: GRAPH_THEME.palette.muted.edgeStructure,
|
||||
mutedColor: GRAPH_THEME.palette.muted.edgeOverview,
|
||||
visualPriority: Math.max(sourcePriority, targetPriority),
|
||||
visualPriority: edgeVisualPriority,
|
||||
isBidirectional,
|
||||
edgeFamily: isBidirectional ? "bidirectional" : "line",
|
||||
curveGroup: curveGroupForPair(edge.source, edge.target),
|
||||
|
||||
@@ -5,13 +5,28 @@ import {
|
||||
batchMergeEdges,
|
||||
batchMergeNodes,
|
||||
clearGraph,
|
||||
graph,
|
||||
} from "../src/store/graphStore.ts";
|
||||
import {
|
||||
buildGraphAnalyticsSnapshot,
|
||||
computeGraphAnalyticsBase,
|
||||
} from "../src/workspaces/GraphWorkspace/graphAnalytics.ts";
|
||||
import {
|
||||
classifyFullGraphEdge,
|
||||
checkGroupedViewAvailability,
|
||||
mapFullEdgeClassToVisualState,
|
||||
resolveEdgeElementStyle,
|
||||
resolveEdgeVisualState,
|
||||
resolveDisplayGraph,
|
||||
resolveGroupedDisplayNodeId,
|
||||
resolveGroupedDisplayStateSnapshot,
|
||||
} 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";
|
||||
|
||||
function addNode(id: string, semanticGroup = "entity") {
|
||||
batchMergeNodes([
|
||||
@@ -33,6 +48,10 @@ function addNode(id: string, semanticGroup = "entity") {
|
||||
]);
|
||||
}
|
||||
|
||||
function setNodePosition(id: string, x: number, y: number) {
|
||||
graph.mergeNodeAttributes(id, { x, y });
|
||||
}
|
||||
|
||||
function addEdge(id: string, source: string, target: string, weight = 1) {
|
||||
batchMergeEdges([
|
||||
{
|
||||
@@ -56,6 +75,510 @@ test.after(() => {
|
||||
clearGraph();
|
||||
});
|
||||
|
||||
test("resolveEdgeVisualState caps selected-node incident edge promotion", () => {
|
||||
const uncappedState = resolveEdgeVisualState(
|
||||
"edge-1",
|
||||
"hub",
|
||||
"leaf",
|
||||
"inspection",
|
||||
null,
|
||||
"hub",
|
||||
"",
|
||||
new Set(["hub", "leaf"]),
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
assert.equal(uncappedState, "muted");
|
||||
|
||||
const cappedState = resolveEdgeVisualState(
|
||||
"edge-1",
|
||||
"hub",
|
||||
"leaf",
|
||||
"inspection",
|
||||
null,
|
||||
"hub",
|
||||
"",
|
||||
new Set(["hub", "leaf"]),
|
||||
new Set(),
|
||||
new Set(["edge-1"]),
|
||||
);
|
||||
assert.equal(cappedState, "selected");
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle applies full-graph LOD to directional background edges", () => {
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"overview",
|
||||
"default",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 1,
|
||||
properties: {},
|
||||
edgeVariant: "directional",
|
||||
visualPriority: 0.1,
|
||||
baseSize: 0.5,
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"directional-low-priority",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, true);
|
||||
});
|
||||
|
||||
test("classifyFullGraphEdge applies deterministic priority order", () => {
|
||||
const edgeClass = classifyFullGraphEdge(
|
||||
"edge-priority",
|
||||
"source",
|
||||
"target",
|
||||
"inspection",
|
||||
"source",
|
||||
"source",
|
||||
"edge-priority",
|
||||
new Set(["source", "target"]),
|
||||
new Set(["edge-priority"]),
|
||||
new Set(["edge-priority"]),
|
||||
new Set(["edge-priority"]),
|
||||
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
|
||||
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "disease", content: "target", semanticGroup: "disease", properties: {} },
|
||||
);
|
||||
|
||||
assert.equal(edgeClass, "path");
|
||||
|
||||
const selectedClass = classifyFullGraphEdge(
|
||||
"edge-priority",
|
||||
"source",
|
||||
"target",
|
||||
"inspection",
|
||||
"source",
|
||||
"source",
|
||||
"edge-priority",
|
||||
new Set(["source", "target"]),
|
||||
new Set(),
|
||||
new Set(["edge-priority"]),
|
||||
);
|
||||
|
||||
assert.equal(selectedClass, "selected");
|
||||
});
|
||||
|
||||
test("classifyFullGraphEdge separates capped local context from muted hub edges", () => {
|
||||
const mutedClass = classifyFullGraphEdge(
|
||||
"hub-edge",
|
||||
"hub",
|
||||
"leaf",
|
||||
"inspection",
|
||||
null,
|
||||
"hub",
|
||||
"",
|
||||
new Set(["hub", "leaf"]),
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.equal(mutedClass, "muted");
|
||||
|
||||
const localContextClass = classifyFullGraphEdge(
|
||||
"hub-edge",
|
||||
"hub",
|
||||
"leaf",
|
||||
"inspection",
|
||||
null,
|
||||
"hub",
|
||||
"",
|
||||
new Set(["hub", "leaf"]),
|
||||
new Set(),
|
||||
new Set(["hub-edge"]),
|
||||
);
|
||||
|
||||
assert.equal(localContextClass, "local-context");
|
||||
});
|
||||
|
||||
test("classifyFullGraphEdge marks curated bridge and backbone candidates", () => {
|
||||
const bridgeClass = classifyFullGraphEdge(
|
||||
"curated-bridge",
|
||||
"source",
|
||||
"target",
|
||||
"overview",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(["curated-bridge"]),
|
||||
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
|
||||
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "disease", content: "target", semanticGroup: "disease", properties: {} },
|
||||
);
|
||||
|
||||
assert.equal(bridgeClass, "bridge");
|
||||
|
||||
const backboneClass = classifyFullGraphEdge(
|
||||
"curated-backbone",
|
||||
"source",
|
||||
"target",
|
||||
"overview",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(["curated-backbone"]),
|
||||
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
|
||||
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "target", semanticGroup: "gene", properties: {} },
|
||||
);
|
||||
|
||||
assert.equal(backboneClass, "backbone");
|
||||
});
|
||||
|
||||
test("classifyFullGraphEdge hides ordinary full-graph overview edges", () => {
|
||||
const edgeClass = classifyFullGraphEdge(
|
||||
"ordinary-edge",
|
||||
"source",
|
||||
"target",
|
||||
"overview",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.equal(edgeClass, "hidden");
|
||||
});
|
||||
|
||||
test("mapFullEdgeClassToVisualState renders curated backbone and bridge as backbone", () => {
|
||||
assert.equal(
|
||||
mapFullEdgeClassToVisualState("backbone", { hoveredNodeId: null, hasActiveInteraction: false }),
|
||||
"backbone",
|
||||
);
|
||||
assert.equal(
|
||||
mapFullEdgeClassToVisualState("bridge", { hoveredNodeId: null, hasActiveInteraction: false }),
|
||||
"backbone",
|
||||
);
|
||||
assert.equal(
|
||||
mapFullEdgeClassToVisualState("hidden", { hoveredNodeId: null, hasActiveInteraction: false }),
|
||||
"inactive",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle renders curated full-graph backbone quietly", () => {
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"overview",
|
||||
"backbone",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 2,
|
||||
properties: {},
|
||||
visualPriority: 0.9,
|
||||
baseSize: 0.8,
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"curated-backbone-edge",
|
||||
"backbone",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, false);
|
||||
assert.equal(style.type, "line");
|
||||
assert.match(style.color ?? "", /rgba\(.+,\s*0\.08\)/);
|
||||
assert.ok(Number(style.size ?? 0) <= GRAPH_THEME.edges.fullGraphStructure.backboneMaxSize);
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle renders high-value bridge as a calm curved teal edge", () => {
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"overview",
|
||||
"backbone",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 4,
|
||||
properties: {},
|
||||
visualPriority: 0.95,
|
||||
baseSize: 0.9,
|
||||
edgeVariant: "line",
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"curated-bridge-edge",
|
||||
"bridge",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, false);
|
||||
assert.equal(style.type, "curve");
|
||||
assert.notEqual(style.type, "arrow");
|
||||
assert.match(style.color ?? "", /rgba\(.+,\s*0\.14\)/);
|
||||
assert.equal(style.curvature, GRAPH_THEME.edges.fullGraphStructure.bridgeCurveStrength);
|
||||
assert.ok(Number(style.size ?? 0) <= GRAPH_THEME.edges.fullGraphStructure.bridgeMaxSize);
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle keeps low-priority bridge straight", () => {
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"overview",
|
||||
"backbone",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 1,
|
||||
properties: {},
|
||||
visualPriority: 0.2,
|
||||
baseSize: 0.9,
|
||||
edgeVariant: "line",
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"low-value-bridge-edge",
|
||||
"bridge",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, false);
|
||||
assert.equal(style.type, "line");
|
||||
assert.equal(style.curvature, 0);
|
||||
});
|
||||
|
||||
test("evaluateGraphStructureLayerGate enables only sparse settled full-graph structure", () => {
|
||||
const counts: GraphFullEdgeClassCounts = {
|
||||
hidden: 20,
|
||||
backbone: 6,
|
||||
bridge: 4,
|
||||
"local-context": 0,
|
||||
selected: 0,
|
||||
path: 0,
|
||||
muted: 0,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateGraphStructureLayerGate({
|
||||
mode: "auto",
|
||||
viewMode: "grouped",
|
||||
isLayoutRunning: false,
|
||||
edgeDiagnostics: {
|
||||
mode: "grouped",
|
||||
zoomTier: "overview",
|
||||
totalEdges: 30,
|
||||
visibleEdges: 10,
|
||||
counts,
|
||||
updatedAt: 1,
|
||||
},
|
||||
minimumLiteralEdges: 24,
|
||||
}),
|
||||
{ enabled: false, disabledReason: "non-full-mode" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateGraphStructureLayerGate({
|
||||
mode: "auto",
|
||||
viewMode: "full",
|
||||
isLayoutRunning: true,
|
||||
edgeDiagnostics: {
|
||||
mode: "full",
|
||||
zoomTier: "overview",
|
||||
totalEdges: 30,
|
||||
visibleEdges: 10,
|
||||
counts,
|
||||
updatedAt: 1,
|
||||
},
|
||||
minimumLiteralEdges: 24,
|
||||
}),
|
||||
{ enabled: false, disabledReason: "layout-running" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateGraphStructureLayerGate({
|
||||
mode: "auto",
|
||||
viewMode: "full",
|
||||
isLayoutRunning: false,
|
||||
edgeDiagnostics: {
|
||||
mode: "full",
|
||||
zoomTier: "overview",
|
||||
totalEdges: 30,
|
||||
visibleEdges: 24,
|
||||
counts: { ...counts, backbone: 18, bridge: 6 },
|
||||
updatedAt: 1,
|
||||
},
|
||||
minimumLiteralEdges: 24,
|
||||
}),
|
||||
{ enabled: false, disabledReason: "enough-literal-edges" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateGraphStructureLayerGate({
|
||||
mode: "auto",
|
||||
viewMode: "full",
|
||||
isLayoutRunning: false,
|
||||
edgeDiagnostics: {
|
||||
mode: "full",
|
||||
zoomTier: "overview",
|
||||
totalEdges: 30,
|
||||
visibleEdges: 10,
|
||||
counts,
|
||||
updatedAt: 1,
|
||||
},
|
||||
minimumLiteralEdges: 24,
|
||||
}),
|
||||
{ enabled: true, disabledReason: null },
|
||||
);
|
||||
});
|
||||
|
||||
test("buildGraphStructureCurveCache prefers bridges, caps curves, and skips invalid endpoints", () => {
|
||||
addNode("a", "gene");
|
||||
addNode("b", "disease");
|
||||
addNode("c", "gene");
|
||||
addNode("d", "compound");
|
||||
setNodePosition("a", 0, 0);
|
||||
setNodePosition("b", 100, 0);
|
||||
setNodePosition("c", 0, 100);
|
||||
setNodePosition("d", Number.NaN, 100);
|
||||
|
||||
addEdge("backbone-1", "a", "c", 1);
|
||||
graph.mergeEdgeAttributes("backbone-1", { visualPriority: 1 });
|
||||
addEdge("bridge-1", "a", "b", 0.2);
|
||||
graph.mergeEdgeAttributes("bridge-1", { visualPriority: 0.1 });
|
||||
addEdge("selected-1", "b", "c", 1);
|
||||
graph.mergeEdgeAttributes("selected-1", { visualPriority: 1 });
|
||||
addEdge("invalid-bridge", "a", "d", 1);
|
||||
graph.mergeEdgeAttributes("invalid-bridge", { visualPriority: 1 });
|
||||
|
||||
const edgeClasses = new Map<string, GraphFullEdgeClass>([
|
||||
["backbone-1", "backbone"],
|
||||
["bridge-1", "bridge"],
|
||||
["selected-1", "selected"],
|
||||
["invalid-bridge", "bridge"],
|
||||
]);
|
||||
|
||||
const capped = buildGraphStructureCurveCache({
|
||||
graphRef: graph,
|
||||
cacheKey: "test-cache",
|
||||
classifyEdge: (edgeId) => edgeClasses.get(edgeId) ?? "hidden",
|
||||
maxCurves: 1,
|
||||
curveStrength: 0.12,
|
||||
});
|
||||
|
||||
assert.equal(capped.curves.length, 1);
|
||||
assert.equal(capped.curves[0].edgeId, "bridge-1");
|
||||
assert.equal(capped.bridgeCurveCount, 1);
|
||||
assert.equal(capped.backboneCurveCount, 0);
|
||||
|
||||
const uncapped = buildGraphStructureCurveCache({
|
||||
graphRef: graph,
|
||||
cacheKey: "test-cache-all",
|
||||
classifyEdge: (edgeId) => edgeClasses.get(edgeId) ?? "hidden",
|
||||
maxCurves: 10,
|
||||
curveStrength: 0.12,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
uncapped.curves.map((curve) => curve.edgeId).sort(),
|
||||
["backbone-1", "bridge-1"],
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveEdgeVisualState suppresses automatic overview backbone in clean baseline", () => {
|
||||
const state = resolveEdgeVisualState(
|
||||
"overview-backbone-high-priority",
|
||||
"source",
|
||||
"target",
|
||||
"overview",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.equal(state, "inactive");
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle keeps full-graph selected and path edges controlled", () => {
|
||||
const selectedStyle = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"inspection",
|
||||
"selected",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 2,
|
||||
properties: {},
|
||||
visualPriority: 0.9,
|
||||
baseSize: 0.8,
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"selected-context-edge",
|
||||
);
|
||||
const pathStyle = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"inspection",
|
||||
"path",
|
||||
{
|
||||
edgeType: "causes",
|
||||
weight: 2,
|
||||
properties: {},
|
||||
visualPriority: 0.9,
|
||||
baseSize: 0.8,
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"path-context-edge",
|
||||
);
|
||||
|
||||
assert.equal(selectedStyle.hidden, false);
|
||||
assert.match(selectedStyle.color ?? "", /rgba\(.+,\s*0\.6\)/);
|
||||
assert.equal(pathStyle.hidden, false);
|
||||
assert.match(pathStyle.color ?? "", /rgba\(.+,\s*0\.76\)/);
|
||||
});
|
||||
|
||||
test("buildGraphAnalyticsSnapshot emits a readable capped overview backbone", () => {
|
||||
const semanticGroups = ["gene/protein", "disease", "drug", "pathway"];
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
addNode(`n${index}`, semanticGroups[index % semanticGroups.length]);
|
||||
}
|
||||
|
||||
let edgeIndex = 0;
|
||||
for (let sourceIndex = 0; sourceIndex < 16; sourceIndex += 1) {
|
||||
for (let offset = 1; offset <= 3; offset += 1) {
|
||||
const targetIndex = (sourceIndex + offset * 3) % 16;
|
||||
if (sourceIndex === targetIndex) {
|
||||
continue;
|
||||
}
|
||||
addEdge(`ambient-edge-${edgeIndex}`, `n${sourceIndex}`, `n${targetIndex}`, 1 + (edgeIndex % 5));
|
||||
edgeIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const base = computeGraphAnalyticsBase(graph, {
|
||||
computeCommunities: false,
|
||||
computeCentrality: true,
|
||||
});
|
||||
const analytics = buildGraphAnalyticsSnapshot({
|
||||
graphRef: graph,
|
||||
interactionState: {
|
||||
hoveredNodeId: null,
|
||||
selectedNodeId: "",
|
||||
selectedEdgeId: "",
|
||||
focusedNodeId: "",
|
||||
activePath: [],
|
||||
activePathEdgeIds: [],
|
||||
viewMode: "full",
|
||||
zoomTier: "overview",
|
||||
isLayoutRunning: false,
|
||||
},
|
||||
base,
|
||||
visibleNodeIds: graph.nodes(),
|
||||
});
|
||||
|
||||
assert.equal(analytics.overviewBackbone.ready, true);
|
||||
assert.ok(analytics.overviewBackbone.edgeIds.length > 6);
|
||||
assert.ok(analytics.overviewBackbone.edgeIds.length <= 128);
|
||||
});
|
||||
|
||||
test("resolveDisplayGraph bundles parallel edges in full view", () => {
|
||||
addNode("a");
|
||||
addNode("b");
|
||||
@@ -257,3 +780,4 @@ test("checkGroupedViewAvailability returns available when communities exist", ()
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(result.reason, null);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user