feat(graph): add plugin host for graph tools

This commit is contained in:
Zohaib Hassnain
2026-04-09 02:21:35 +05:00
parent 102274c668
commit 8829aa5ce2
21 changed files with 1488 additions and 540 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useCallback, forwardRef, useImperativeHandle, useState } from "react";
import { useEffect, useMemo, useRef, useCallback, forwardRef, useImperativeHandle, useState, type ReactNode } from "react";
import Graph from "graphology";
import Sigma from "sigma";
import FA2Layout from "graphology-layout-forceatlas2/worker";
@@ -21,6 +21,7 @@ import {
withAlpha,
} from "./graphTheme";
import type { GraphCameraState, GraphInteractionState, GraphLayoutStatus, GraphViewMode } from "./types";
import type { GraphPluginRuntime } from "./plugins";
export type { GraphViewMode } from "./types";
@@ -40,6 +41,9 @@ export interface GraphCanvasProps {
onLayoutStatusChange?: (status: GraphLayoutStatus) => void;
viewMode: GraphViewMode;
className?: string;
pluginOverlays?: ReactNode[];
onPluginRuntimeChange?: (runtime: GraphPluginRuntime | null) => void;
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
}
const FA2_SETTINGS = {
@@ -467,7 +471,20 @@ function dispatchBehaviorAction(
}
export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
function GraphCanvas({ onNodeClick, selectedNodeId, activePath = [], isLayoutRunning, viewMode, className }, ref) {
function GraphCanvas(
{
onNodeClick,
selectedNodeId,
activePath = [],
isLayoutRunning,
viewMode,
className,
pluginOverlays = [],
onPluginRuntimeChange,
onInteractionStateChange,
},
ref,
) {
const containerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLCanvasElement>(null);
const sigmaRef = useRef<Sigma | null>(null);
@@ -600,6 +617,11 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const sigma = new Sigma(displayGraph, containerRef.current, SIGMA_SETTINGS);
sigmaRef.current = sigma;
onPluginRuntimeChange?.({
sigma,
graph,
displayGraph,
});
const camera = sigma.getCamera();
const context = getBehaviorContext(sigma);
@@ -649,8 +671,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
sigma.kill();
behaviorContextRef.current = null;
sigmaRef.current = null;
onPluginRuntimeChange?.(null);
};
}, [behaviors, dispatchAction, dispatchToBehaviors, displayGraph, getBehaviorContext]);
}, [behaviors, dispatchAction, dispatchToBehaviors, displayGraph, getBehaviorContext, onPluginRuntimeChange]);
useEffect(() => {
const context = getBehaviorContext();
@@ -665,7 +688,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
for (const behavior of behaviors) {
behavior.apply?.(context, interactionState);
}
}, [behaviors, getBehaviorContext, interactionState]);
onInteractionStateChange?.(interactionState);
}, [behaviors, getBehaviorContext, interactionState, onInteractionStateChange]);
useEffect(() => {
const sigma = sigmaRef.current;
@@ -813,6 +837,22 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
zIndex: 4,
}}
/>
{pluginOverlays.length ? (
<div
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
zIndex: 6,
}}
>
{pluginOverlays.map((overlay, index) => (
<div key={`graph-plugin-overlay-${index}`} style={{ position: "absolute", inset: 0 }}>
{overlay}
</div>
))}
</div>
) : null}
<button
id="graph-fit-view-btn"
onClick={handleFitView}
@@ -7,6 +7,20 @@ import { TimelinePanel } from "./TimelinePanel";
import { useLoadGraph, useReloadGraph } from "./useLoadGraph";
import type { GraphLoadProgress } from "./useLoadGraph";
import { GRAPH_THEME, withAlpha } from "./graphTheme";
import {
legendPlugin,
neighborhoodPanelPlugin,
temporalOverlayPlugin,
type GraphPlugin,
type GraphPluginActionRequest,
type GraphPluginContext,
type GraphPluginOverlayDescriptor,
type GraphPluginPanelDescriptor,
type GraphPluginRegistryEntry,
type GraphPluginRuntime,
type GraphPluginToolbarItem,
} from "./plugins";
import type { GraphInteractionState, GraphLoadSummary, GraphSelectedNodeState } from "./types";
type SearchResult = {
node: {
@@ -271,6 +285,107 @@ function sourceAttribution(properties: Record<string, unknown>) {
.map((key) => ({ key, value: properties[key] }));
}
function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
if (!nodeId || !graph.hasNode(nodeId)) {
return null;
}
const attributes = graph.getNodeAttributes(nodeId) as {
label?: string;
content?: string;
nodeType?: string;
color?: string;
valid_from?: string | null;
valid_until?: string | null;
properties?: Record<string, unknown>;
};
return {
id: nodeId,
label: String(attributes.label ?? nodeId),
content: String(attributes.content ?? attributes.label ?? nodeId),
nodeType: String(attributes.nodeType ?? "Entity"),
color: typeof attributes.color === "string" ? attributes.color : undefined,
valid_from: attributes.valid_from ?? null,
valid_until: attributes.valid_until ?? null,
properties: attributes.properties ?? {},
neighborCount: graph.neighbors(nodeId).length,
};
}
function collectPluginToolbarItems(
plugins: GraphPlugin[],
context: GraphPluginContext,
): GraphPluginToolbarItem[] {
const items: GraphPluginToolbarItem[] = [];
for (const plugin of plugins) {
try {
const nextItems = plugin.toolbarItems?.(context) ?? [];
items.push(...nextItems);
} catch (error) {
console.error(`[GraphPlugin:${plugin.id}] toolbar collection failed`, error);
}
}
return items.sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
}
function collectPluginPanels(
plugins: GraphPlugin[],
context: GraphPluginContext,
): GraphPluginPanelDescriptor[] {
const panels: GraphPluginPanelDescriptor[] = [];
for (const plugin of plugins) {
try {
const result = plugin.renderPanel?.(context);
if (!result) {
continue;
}
if (Array.isArray(result)) {
panels.push(...result);
} else {
panels.push(result);
}
} catch (error) {
console.error(`[GraphPlugin:${plugin.id}] panel render failed`, error);
}
}
return panels.sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
}
function collectPluginOverlays(
plugins: GraphPlugin[],
context: GraphPluginContext,
): GraphPluginOverlayDescriptor[] {
const overlays: GraphPluginOverlayDescriptor[] = [];
for (const plugin of plugins) {
try {
const result = plugin.renderOverlay?.(context);
if (!result) {
continue;
}
if (Array.isArray(result)) {
overlays.push(...result);
} else {
overlays.push(result);
}
} catch (error) {
console.error(`[GraphPlugin:${plugin.id}] overlay render failed`, error);
}
}
return overlays.sort((left, right) => {
if ((left.layer ?? 0) !== (right.layer ?? 0)) {
return (left.layer ?? 0) - (right.layer ?? 0);
}
return (left.order ?? 0) - (right.order ?? 0);
});
}
function NodePanel({
nodeId,
predictions,
@@ -508,10 +623,26 @@ export function GraphWorkspace() {
const [temporalBounds, setTemporalBounds] = useState<TemporalBounds | null>(null);
const [scrubberTime, setScrubberTime] = useState<Date | null>(null);
const [loadingProgress, setLoadingProgress] = useState<GraphLoadProgress | null>(null);
const [pluginPanelState, setPluginPanelState] = useState<Record<string, boolean>>({
"legend-panel": false,
"neighborhood-panel": false,
"temporal-panel": false,
});
const [pluginRuntimeVersion, setPluginRuntimeVersion] = useState(0);
const debouncedTime = useDebounce(scrubberTime, 150);
const prevActiveIdsRef = useRef<Set<string>>(new Set());
const canvasRef = useRef<GraphCanvasHandle>(null);
const pluginRuntimeRef = useRef<GraphPluginRuntime | null>(null);
const pluginInteractionStateRef = useRef<GraphInteractionState>({
hoveredNodeId: null,
selectedNodeId: "",
focusedNodeId: "",
activePath: [],
viewMode: "focused",
zoomTier: "overview",
isLayoutRunning: false,
});
const reload = useReloadGraph();
const { data: summary, isLoading, isFetching, isError, error } = useLoadGraph({
@@ -794,6 +925,144 @@ export function GraphWorkspace() {
const showLoadingOverlay = isLoading || isFetching;
const hasGraphContent = Boolean(summary?.nodeCount);
const activePath = pathResult?.path ?? [];
const graphSummary = summary as GraphLoadSummary | null;
const selectedNodeState = useMemo(
() => buildSelectedNodeState(selectedNodeId),
[selectedNodeId, summary?.nodeCount, summary?.edgeCount],
);
const temporalState = useMemo(
() => ({
currentTime: scrubberTime,
activeNodeCount,
minDate: temporalBounds?.min ?? undefined,
maxDate: temporalBounds?.max ?? undefined,
}),
[activeNodeCount, scrubberTime, temporalBounds?.max, temporalBounds?.min],
);
const pluginRegistry = useMemo<GraphPluginRegistryEntry[]>(
() => [
{ plugin: legendPlugin, enabled: true },
{ plugin: neighborhoodPanelPlugin, enabled: true },
{ plugin: temporalOverlayPlugin, enabled: true },
],
[],
);
const activePlugins = useMemo(
() => pluginRegistry.filter((entry) => entry.enabled !== false).map((entry) => entry.plugin),
[pluginRegistry],
);
const handlePluginAction = useCallback((action: GraphPluginActionRequest) => {
switch (action.type) {
case "fitView":
canvasRef.current?.fitView();
return;
case "focusNode":
canvasRef.current?.focusNode(action.nodeId);
return;
case "selectNode":
focusNode(action.nodeId);
return;
case "setViewMode":
setViewMode(action.viewMode);
return;
case "togglePanel":
setPluginPanelState((current) => ({
...current,
[action.panelId]: !current[action.panelId],
}));
return;
case "openPanel":
setPluginPanelState((current) => ({
...current,
[action.panelId]: true,
}));
return;
case "closePanel":
setPluginPanelState((current) => ({
...current,
[action.panelId]: false,
}));
return;
}
}, [focusNode]);
const pluginContext = useMemo<GraphPluginContext>(() => ({
get sigma() {
return pluginRuntimeRef.current?.sigma ?? null;
},
get graph() {
return graph;
},
get displayGraph() {
return pluginRuntimeRef.current?.displayGraph ?? graph;
},
theme: GRAPH_THEME,
getInteractionState: () => pluginInteractionStateRef.current,
getSelectedNodeState: () => selectedNodeState,
getGraphSummary: () => graphSummary,
getTemporalState: () => temporalState,
isPanelOpen: (panelId: string) => Boolean(pluginPanelState[panelId]),
dispatchAction: handlePluginAction,
}), [graphSummary, handlePluginAction, pluginPanelState, selectedNodeState, temporalState]);
const handlePluginRuntimeChange = useCallback((runtime: GraphPluginRuntime | null) => {
pluginRuntimeRef.current = runtime;
setPluginRuntimeVersion((version) => version + 1);
}, []);
const handleInteractionStateChange = useCallback((interactionState: GraphInteractionState) => {
pluginInteractionStateRef.current = interactionState;
for (const plugin of activePlugins) {
try {
plugin.onStateChange(pluginContext, interactionState);
} catch (error) {
console.error(`[GraphPlugin:${plugin.id}] state update failed`, error);
}
}
}, [activePlugins, pluginContext]);
useEffect(() => {
if (!pluginRuntimeRef.current) {
return;
}
const mountedPlugins: GraphPlugin[] = [];
for (const plugin of activePlugins) {
try {
plugin.mount(pluginContext);
mountedPlugins.push(plugin);
} catch (error) {
console.error(`[GraphPlugin:${plugin.id}] mount failed`, error);
}
}
return () => {
for (const plugin of mountedPlugins.reverse()) {
try {
plugin.unmount(pluginContext);
} catch (error) {
console.error(`[GraphPlugin:${plugin.id}] unmount failed`, error);
}
}
};
}, [activePlugins, pluginContext, pluginRuntimeVersion]);
const pluginToolbarItems = useMemo(
() => collectPluginToolbarItems(activePlugins, pluginContext),
[activePlugins, pluginContext],
);
const pluginPanels = useMemo(
() => collectPluginPanels(activePlugins, pluginContext),
[activePlugins, pluginContext],
);
const pluginOverlays = useMemo(
() => collectPluginOverlays(activePlugins, pluginContext),
[activePlugins, pluginContext],
);
const sidePluginPanels = pluginPanels.filter((panel) => panel.placement === "side");
const bottomPluginPanels = pluginPanels.filter((panel) => panel.placement === "bottom");
return (
<div className="palantir-bg" style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden", display: "flex", flexDirection: "column" }}>
@@ -809,6 +1078,9 @@ export function GraphWorkspace() {
activePath={activePath}
isLayoutRunning={isLayoutRunning}
viewMode={viewMode}
pluginOverlays={pluginOverlays.map((overlay) => overlay.element)}
onPluginRuntimeChange={handlePluginRuntimeChange}
onInteractionStateChange={handleInteractionStateChange}
/>
{showLoadingOverlay ? (
<LoadingOverlay progress={loadingProgress} showGraphBehind={hasGraphContent} />
@@ -888,6 +1160,25 @@ export function GraphWorkspace() {
{isLayoutRunning ? "Pause Layout" : "Run Layout"}
</button>
<button onClick={reload} style={actionButtonStyle} disabled={showLoadingOverlay}>Reload</button>
{pluginToolbarItems.length ? (
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
{pluginToolbarItems.map((item) => (
<button
key={item.id}
onClick={item.onClick}
title={item.title}
style={{
...secondaryActionButtonStyle,
background: item.active ? "rgba(31, 111, 235, 0.28)" : secondaryActionButtonStyle.background,
borderColor: item.active ? "rgba(127, 208, 255, 0.35)" : "rgba(255, 255, 255, 0.08)",
color: item.active ? "#e6f2ff" : secondaryActionButtonStyle.color,
}}
>
{item.label}
</button>
))}
</div>
) : null}
</div>
</header>
@@ -915,6 +1206,56 @@ export function GraphWorkspace() {
</div>
) : null}
{sidePluginPanels.length ? (
<div
className="glass-hud hud-scrollbar"
style={{
pointerEvents: "auto",
position: "absolute",
left: 24,
top: searchResults.length ? 370 : 72,
width: 320,
maxHeight: selectedNodeId ? 280 : 340,
overflowY: "auto",
borderRadius: 14,
border: "1px solid rgba(88, 166, 255, 0.14)",
padding: 12,
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
{sidePluginPanels.map((panel) => (
<div key={panel.id} style={pluginPanelCardStyle}>
<div style={pluginPanelTitleStyle}>{panel.title}</div>
{panel.content}
</div>
))}
</div>
) : null}
{bottomPluginPanels.length ? (
<div
style={{
position: "absolute",
left: 24,
bottom: 104,
width: 340,
display: "flex",
flexDirection: "column",
gap: 12,
pointerEvents: "auto",
}}
>
{bottomPluginPanels.map((panel) => (
<div key={panel.id} className="glass-hud" style={{ ...pluginPanelCardStyle, padding: 14 }}>
<div style={pluginPanelTitleStyle}>{panel.title}</div>
{panel.content}
</div>
))}
</div>
) : null}
<div
className="glass-hud hud-scrollbar"
style={{
@@ -1048,6 +1389,25 @@ const subtleChipStyle: React.CSSProperties = {
border: "1px solid rgba(255, 255, 255, 0.06)",
};
const pluginPanelCardStyle: React.CSSProperties = {
borderRadius: 14,
border: "1px solid rgba(88, 166, 255, 0.14)",
background: "linear-gradient(180deg, rgba(7, 14, 25, 0.76), rgba(10, 18, 31, 0.66))",
boxShadow: "0 14px 38px rgba(0, 0, 0, 0.24)",
padding: 14,
display: "flex",
flexDirection: "column",
gap: 12,
};
const pluginPanelTitleStyle: React.CSSProperties = {
color: "#f3f7fd",
fontSize: 12,
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
};
const loadingMetricStyle: React.CSSProperties = {
background: "rgba(255, 255, 255, 0.04)",
color: "#cfe3ff",
@@ -462,9 +462,9 @@ export function GraphWorkspaceShell() {
setActiveNodeCount(null);
setLayoutStatus({
state: snapshot.summary.layoutReady ? "interactive" : "idle",
source: snapshot.summary.layoutSource,
hasCoordinates: snapshot.summary.hasCoordinates,
layoutReady: snapshot.summary.layoutReady,
source: snapshot.summary.layoutSource ?? "runtime",
hasCoordinates: snapshot.summary.hasCoordinates ?? false,
layoutReady: snapshot.summary.layoutReady ?? false,
displacement: null,
elapsedMs: 0,
stableSamples: 0,
@@ -0,0 +1,15 @@
export { legendPlugin } from "./legendPlugin";
export { neighborhoodPanelPlugin } from "./neighborhoodPanelPlugin";
export { temporalOverlayPlugin } from "./temporalOverlayPlugin";
export type {
GraphPlugin,
GraphPluginActionRequest,
GraphPluginContext,
GraphPluginId,
GraphPluginOverlayDescriptor,
GraphPluginPanelDescriptor,
GraphPluginRegistryEntry,
GraphPluginRuntime,
GraphPluginToolbarItem,
GraphTemporalState,
} from "./types";
@@ -0,0 +1,128 @@
import type { CSSProperties } from "react";
import type { GraphPlugin } from "./types";
const LEGEND_PANEL_ID = "legend-panel";
const MAX_GROUPS = 8;
export const legendPlugin: GraphPlugin = {
id: "legend",
mount: () => {},
unmount: () => {},
onStateChange: () => {},
toolbarItems: (context) => [
{
id: "legend-toggle",
label: "Legend",
title: "Toggle semantic legend",
active: context.isPanelOpen(LEGEND_PANEL_ID),
order: 20,
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: LEGEND_PANEL_ID }),
},
],
renderPanel: (context) => {
if (!context.isPanelOpen(LEGEND_PANEL_ID)) {
return null;
}
const groups = new Map<string, { count: number; color: string }>();
context.graph.forEachNode((_nodeId, attrs) => {
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
const current = groups.get(semanticGroup);
groups.set(semanticGroup, {
count: (current?.count ?? 0) + 1,
color,
});
});
const items = [...groups.entries()]
.map(([group, data]) => ({ group, ...data }))
.sort((left, right) => right.count - left.count)
.slice(0, MAX_GROUPS);
return {
id: LEGEND_PANEL_ID,
title: "Legend",
placement: "bottom",
order: 10,
content: (
<div style={panelBodyStyle}>
<div style={panelEyebrowStyle}>Semantic groups</div>
{items.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{items.map((item) => (
<div key={item.group} style={legendRowStyle}>
<span
style={{
...swatchStyle,
background: item.color,
boxShadow: `0 0 0 1px rgba(255,255,255,0.06), 0 0 18px ${item.color}44`,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={rowTitleStyle}>{item.group}</div>
<div style={rowMetaStyle}>{item.count.toLocaleString()} nodes</div>
</div>
</div>
))}
</div>
) : (
<div style={emptyTextStyle}>Legend will populate when the graph metadata is available.</div>
)}
</div>
),
};
},
};
const panelBodyStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 12,
};
const panelEyebrowStyle: CSSProperties = {
color: "#8ea4be",
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
};
const legendRowStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 10px",
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.06)",
background: "rgba(255,255,255,0.025)",
};
const swatchStyle: CSSProperties = {
width: 10,
height: 10,
borderRadius: 999,
flexShrink: 0,
};
const rowTitleStyle: CSSProperties = {
color: "#f3f7fd",
fontSize: 13,
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const rowMetaStyle: CSSProperties = {
color: "#8ea4be",
fontSize: 12,
};
const emptyTextStyle: CSSProperties = {
color: "#8ea4be",
fontSize: 12,
lineHeight: 1.5,
};
@@ -0,0 +1,173 @@
import type { CSSProperties } from "react";
import type { GraphPlugin } from "./types";
const NEIGHBORHOOD_PANEL_ID = "neighborhood-panel";
const MAX_NEIGHBORS = 10;
export const neighborhoodPanelPlugin: GraphPlugin = {
id: "neighborhood-panel",
mount: () => {},
unmount: () => {},
onStateChange: () => {},
toolbarItems: (context) => [
{
id: "neighborhood-toggle",
label: "Neighbors",
title: "Toggle neighborhood panel",
active: context.isPanelOpen(NEIGHBORHOOD_PANEL_ID),
order: 30,
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: NEIGHBORHOOD_PANEL_ID }),
},
],
renderPanel: (context) => {
if (!context.isPanelOpen(NEIGHBORHOOD_PANEL_ID)) {
return null;
}
const selected = context.getSelectedNodeState();
if (!selected) {
return {
id: NEIGHBORHOOD_PANEL_ID,
title: "Neighborhood",
placement: "bottom",
order: 20,
content: <div style={emptyTextStyle}>Select a node to inspect its local neighborhood.</div>,
};
}
const neighbors = context.graph
.neighbors(selected.id)
.map((neighborId) => {
const attrs = context.graph.getNodeAttributes(neighborId);
let weight = 0;
if (context.graph.hasDirectedEdge(selected.id, neighborId)) {
const edge = context.graph.getDirectedEdgeAttributes(selected.id, neighborId) as { weight?: number };
weight = Math.max(weight, Number(edge.weight ?? 0));
}
if (context.graph.hasDirectedEdge(neighborId, selected.id)) {
const edge = context.graph.getDirectedEdgeAttributes(neighborId, selected.id) as { weight?: number };
weight = Math.max(weight, Number(edge.weight ?? 0));
}
return {
id: neighborId,
label: String(attrs.label || neighborId),
nodeType: String(attrs.nodeType || "Entity"),
color: String(attrs.baseColor || attrs.color || context.theme.palette.semantic[0]),
weight,
degree: context.graph.degree(neighborId),
};
})
.sort((left, right) => {
if (right.weight !== left.weight) {
return right.weight - left.weight;
}
if (right.degree !== left.degree) {
return right.degree - left.degree;
}
return left.label.localeCompare(right.label);
})
.slice(0, MAX_NEIGHBORS);
return {
id: NEIGHBORHOOD_PANEL_ID,
title: "Neighborhood",
placement: "bottom",
order: 20,
content: (
<div style={panelBodyStyle}>
<div style={panelEyebrowStyle}>{selected.label}</div>
<div style={summaryStyle}>
{selected.neighborCount.toLocaleString()} direct neighbors in the full graph
</div>
{neighbors.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{neighbors.map((neighbor) => (
<button
key={neighbor.id}
type="button"
onClick={() => context.dispatchAction({ type: "selectNode", nodeId: neighbor.id })}
style={neighborButtonStyle}
>
<span
style={{
...swatchStyle,
background: neighbor.color,
boxShadow: `0 0 16px ${neighbor.color}40`,
}}
/>
<div style={{ minWidth: 0, flex: 1, textAlign: "left" }}>
<div style={rowTitleStyle}>{neighbor.label}</div>
<div style={rowMetaStyle}>
{neighbor.nodeType} · degree {neighbor.degree}
{neighbor.weight > 0 ? ` · weight ${neighbor.weight.toFixed(2)}` : ""}
</div>
</div>
</button>
))}
</div>
) : (
<div style={emptyTextStyle}>No direct neighbors are available for this node.</div>
)}
</div>
),
};
},
};
const panelBodyStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 12,
};
const panelEyebrowStyle: CSSProperties = {
color: "#f3f7fd",
fontSize: 14,
fontWeight: 700,
};
const summaryStyle: CSSProperties = {
color: "#8ea4be",
fontSize: 12,
lineHeight: 1.5,
};
const neighborButtonStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "8px 10px",
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.06)",
borderRadius: 12,
cursor: "pointer",
};
const swatchStyle: CSSProperties = {
width: 10,
height: 10,
borderRadius: 999,
flexShrink: 0,
};
const rowTitleStyle: CSSProperties = {
color: "#f3f7fd",
fontSize: 13,
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const rowMetaStyle: CSSProperties = {
color: "#8ea4be",
fontSize: 12,
};
const emptyTextStyle: CSSProperties = {
color: "#8ea4be",
fontSize: 12,
lineHeight: 1.5,
};
@@ -0,0 +1,139 @@
import type { CSSProperties } from "react";
import type { GraphPlugin } from "./types";
const TEMPORAL_PANEL_ID = "temporal-panel";
function formatTemporalLabel(value: Date | null) {
if (!value) {
return "No time selected";
}
return `${value.getFullYear()}/${String(value.getMonth() + 1).padStart(2, "0")}`;
}
export const temporalOverlayPlugin: GraphPlugin = {
id: "temporal-overlay",
mount: () => {},
unmount: () => {},
onStateChange: () => {},
toolbarItems: (context) => [
{
id: "temporal-toggle",
label: "Temporal",
title: "Toggle temporal context panel",
active: context.isPanelOpen(TEMPORAL_PANEL_ID),
order: 40,
onClick: () => context.dispatchAction({ type: "togglePanel", panelId: TEMPORAL_PANEL_ID }),
},
],
renderOverlay: (context) => {
const temporal = context.getTemporalState();
if (!temporal?.currentTime) {
return null;
}
const label = formatTemporalLabel(temporal.currentTime);
return {
id: "temporal-overlay-chip",
layer: 1,
order: 10,
element: (
<div
style={{
position: "absolute",
left: 140,
bottom: 26,
display: "inline-flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
borderRadius: 999,
border: "1px solid rgba(127, 208, 255, 0.18)",
background: "linear-gradient(135deg, rgba(6, 15, 27, 0.88), rgba(11, 22, 39, 0.76))",
boxShadow: "0 12px 30px rgba(0, 0, 0, 0.28)",
color: "#dce9f8",
fontSize: 11,
letterSpacing: "0.05em",
textTransform: "uppercase",
pointerEvents: "none",
}}
>
<span style={{ color: "#7fc6ff", fontWeight: 700 }}>Temporal</span>
<span>{label}</span>
{typeof temporal.activeNodeCount === "number" ? (
<span style={{ color: "#8ea4be" }}>{temporal.activeNodeCount.toLocaleString()} active</span>
) : null}
</div>
),
};
},
renderPanel: (context) => {
if (!context.isPanelOpen(TEMPORAL_PANEL_ID)) {
return null;
}
const temporal = context.getTemporalState();
return {
id: TEMPORAL_PANEL_ID,
title: "Temporal Context",
placement: "bottom",
order: 30,
content: (
<div style={panelBodyStyle}>
<div style={panelEyebrowStyle}>Current scrubber state</div>
<div style={detailRowStyle}>
<span style={detailLabelStyle}>Current</span>
<span style={detailValueStyle}>{formatTemporalLabel(temporal?.currentTime ?? null)}</span>
</div>
<div style={detailRowStyle}>
<span style={detailLabelStyle}>Bounds</span>
<span style={detailValueStyle}>
{(temporal?.minDate ?? "1970")} {(temporal?.maxDate ?? "2030")}
</span>
</div>
<div style={detailRowStyle}>
<span style={detailLabelStyle}>Active nodes</span>
<span style={detailValueStyle}>
{typeof temporal?.activeNodeCount === "number" ? temporal.activeNodeCount.toLocaleString() : "All"}
</span>
</div>
</div>
),
};
},
};
const panelBodyStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 10,
};
const panelEyebrowStyle: CSSProperties = {
color: "#8ea4be",
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
};
const detailRowStyle: CSSProperties = {
display: "flex",
justifyContent: "space-between",
gap: 16,
padding: "8px 10px",
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.06)",
background: "rgba(255,255,255,0.025)",
};
const detailLabelStyle: CSSProperties = {
color: "#8ea4be",
fontSize: 12,
};
const detailValueStyle: CSSProperties = {
color: "#f3f7fd",
fontSize: 12,
fontWeight: 600,
};
@@ -0,0 +1,93 @@
import type { ReactNode } from "react";
import type Graph from "graphology";
import type Sigma from "sigma";
import { graph, type EdgeAttributes, type NodeAttributes } from "../../../store/graphStore";
import type { GraphTheme } from "../graphTheme";
import type {
GraphInteractionState,
GraphLoadSummary,
GraphSelectedNodeState,
GraphViewMode,
} from "../types";
export type GraphPluginId = string;
export type GraphPluginPanelPlacement = "side" | "bottom";
export interface GraphTemporalState {
currentTime: Date | null;
activeNodeCount: number | null;
minDate?: string;
maxDate?: string;
}
export type GraphPluginActionRequest =
| { type: "fitView" }
| { type: "focusNode"; nodeId: string }
| { type: "selectNode"; nodeId: string }
| { type: "setViewMode"; viewMode: GraphViewMode }
| { type: "togglePanel"; panelId: string }
| { type: "openPanel"; panelId: string }
| { type: "closePanel"; panelId: string };
export interface GraphPluginToolbarItem {
id: string;
label: string;
title?: string;
active?: boolean;
order?: number;
onClick: () => void;
}
export interface GraphPluginPanelDescriptor {
id: string;
title: string;
placement: GraphPluginPanelPlacement;
order?: number;
content: ReactNode;
}
export interface GraphPluginOverlayDescriptor {
id: string;
layer?: number;
order?: number;
element: ReactNode;
}
export interface GraphPluginRuntime {
sigma: Sigma;
graph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
}
export interface GraphPluginContext {
readonly sigma: Sigma | null;
readonly graph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
readonly displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>;
readonly theme: GraphTheme;
getInteractionState: () => GraphInteractionState;
getSelectedNodeState: () => GraphSelectedNodeState | null;
getGraphSummary: () => GraphLoadSummary | null;
getTemporalState: () => GraphTemporalState | null;
isPanelOpen: (panelId: string) => boolean;
dispatchAction: (action: GraphPluginActionRequest) => void;
}
export interface GraphPlugin {
id: GraphPluginId;
mount: (context: GraphPluginContext) => void;
unmount: (context: GraphPluginContext) => void;
onStateChange: (context: GraphPluginContext, interactionState: GraphInteractionState) => void;
renderOverlay?: (
context: GraphPluginContext,
) => GraphPluginOverlayDescriptor | GraphPluginOverlayDescriptor[] | null;
renderPanel?: (
context: GraphPluginContext,
) => GraphPluginPanelDescriptor | GraphPluginPanelDescriptor[] | null;
toolbarItems?: (context: GraphPluginContext) => GraphPluginToolbarItem[];
}
export interface GraphPluginRegistryEntry {
plugin: GraphPlugin;
enabled?: boolean;
}
@@ -43,9 +43,9 @@ export interface GraphLoadSummary {
nodeCount: number;
edgeCount: number;
loadTimeMs: number;
hasCoordinates: boolean;
layoutSource: GraphLayoutSource;
layoutReady: boolean;
hasCoordinates?: boolean;
layoutSource?: GraphLayoutSource;
layoutReady?: boolean;
}
export interface GraphLoadProgress {
@@ -1,4 +1,4 @@
import{n as e,o as t,t as n}from"./jsx-runtime-B3dmMxJS.js";import{i as r}from"./index-2A2Xu6zz.js";var i=r(),a=t(e(),1),o=n(),s=`
import{n as e,o as t,t as n}from"./jsx-runtime-B3dmMxJS.js";import{i as r}from"./index-BaPyswgU.js";var i=r(),a=t(e(),1),o=n(),s=`
.glass-panel {
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
backdrop-filter: blur(16px) saturate(1.2);
@@ -1,4 +1,4 @@
import{n as e,o as t,t as n}from"./jsx-runtime-B3dmMxJS.js";import{i as r}from"./index-2A2Xu6zz.js";var i=r(),a=t(e(),1),o=n(),s=`
import{n as e,o as t,t as n}from"./jsx-runtime-B3dmMxJS.js";import{i as r}from"./index-BaPyswgU.js";var i=r(),a=t(e(),1),o=n(),s=`
.glass-panel {
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
backdrop-filter: blur(16px) saturate(1.2);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{n as e,o as t,t as n}from"./jsx-runtime-B3dmMxJS.js";import{a as r,i,n as a,r as o,t as s}from"./es-BKiKt2i-.js";import{t as c}from"./index-2A2Xu6zz.js";var l=c(`circle-alert`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]]),u=c(`download`,[[`path`,{d:`M12 15V3`,key:`m9g1x1`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`path`,{d:`m7 10 5 5 5-5`,key:`brsn70`}]]),d=c(`file-braces`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`1oajmo`}],[`path`,{d:`M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`mpwhp6`}]]),f=t(e(),1),p=n(),m=`
import{n as e,o as t,t as n}from"./jsx-runtime-B3dmMxJS.js";import{a as r,i,n as a,r as o,t as s}from"./es-BlaQ22nu.js";import{t as c}from"./index-BaPyswgU.js";var l=c(`circle-alert`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]]),u=c(`download`,[[`path`,{d:`M12 15V3`,key:`m9g1x1`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`path`,{d:`m7 10 5 5 5-5`,key:`brsn70`}]]),d=c(`file-braces`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`1oajmo`}],[`path`,{d:`M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`mpwhp6`}]]),f=t(e(),1),p=n(),m=`
.glass-panel {
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
backdrop-filter: blur(16px) saturate(1.2);
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{n as e,o as t,t as n}from"./jsx-runtime-B3dmMxJS.js";import{n as r}from"./index-2A2Xu6zz.js";var i=t(e(),1),a=n(),o=`inhibits(Metformin, mTOR)
import{n as e,o as t,t as n}from"./jsx-runtime-B3dmMxJS.js";import{n as r}from"./index-BaPyswgU.js";var i=t(e(),1),a=n(),o=`inhibits(Metformin, mTOR)
causes(mTOR, Neurodegeneration)`,s=`IF inhibits(Metformin, mTOR) AND causes(mTOR, Neurodegeneration) THEN candidate(Metformin, Alzheimer's)`;function c(){let e=r(),[t,n]=(0,i.useState)(o),[c,g]=(0,i.useState)(s),[_,v]=(0,i.useState)(!0),[y,b]=(0,i.useState)(null),[x,S]=(0,i.useState)(!1),[C,w]=(0,i.useState)(``);async function T(){S(!0),w(``),b(null);try{let n=await fetch(`/api/reason`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({facts:t.split(/\r?\n/).map(e=>e.trim()).filter(Boolean),rules:c.split(/\r?\n/).map(e=>e.trim()).filter(Boolean),mode:`forward`,apply_to_graph:_})}),r=await n.json();if(!n.ok)throw Error(r.detail||`Reasoning failed with status ${n.status}`);b(r),r.mutated&&e.invalidateQueries({queryKey:[`graph`,`full-load`]})}catch(e){w(e instanceof Error?e.message:`Reasoning failed`)}finally{S(!1)}}return(0,a.jsxs)(`div`,{style:{display:`grid`,gridTemplateColumns:`1.2fr 1fr`,gap:24,height:`100%`,padding:24,boxSizing:`border-box`,background:`#0d1117`},children:[(0,a.jsxs)(`div`,{style:l,children:[(0,a.jsx)(`h3`,{style:u,children:`Facts`}),(0,a.jsx)(`p`,{style:d,children:"Enter one fact per line using `predicate(subject, object)` form."}),(0,a.jsx)(`textarea`,{value:t,onChange:e=>n(e.target.value),style:f}),(0,a.jsx)(`h3`,{style:{...u,marginTop:18},children:`Rules`}),(0,a.jsx)(`p`,{style:d,children:"Write rules in `IF ... AND ... THEN ...` format. If the advanced reasoner is unavailable, the explorer falls back to an internal rule matcher for this format."}),(0,a.jsx)(`textarea`,{value:c,onChange:e=>g(e.target.value),style:{...f,minHeight:160}}),(0,a.jsxs)(`label`,{style:{display:`flex`,alignItems:`center`,gap:10,color:`#c9d1d9`,fontSize:13,marginTop:16},children:[(0,a.jsx)(`input`,{type:`checkbox`,checked:_,onChange:e=>v(e.target.checked)}),`Write inferred binary facts back into the graph as inferred edges`]}),(0,a.jsx)(`button`,{onClick:T,disabled:x,style:p,children:x?`Running...`:`Run Reasoning`})]}),(0,a.jsxs)(`div`,{style:l,children:[(0,a.jsx)(`h3`,{style:u,children:`Inference Results`}),C?(0,a.jsx)(`div`,{style:{color:`#ff7b72`,marginBottom:12},children:C}):null,y?(0,a.jsxs)(a.Fragment,{children:[(0,a.jsxs)(`div`,{style:{display:`flex`,gap:10,flexWrap:`wrap`,marginBottom:14},children:[(0,a.jsxs)(`span`,{style:m,children:[`rules fired: `,y.rules_fired??0]}),(0,a.jsxs)(`span`,{style:m,children:[`edges added: `,y.added_edges??0]}),(0,a.jsx)(`span`,{style:m,children:y.mutated?`graph updated`:`preview only`})]}),(0,a.jsx)(`div`,{style:{display:`flex`,flexDirection:`column`,gap:8},children:(y.inferred_facts||[]).length?y.inferred_facts?.map(e=>(0,a.jsx)(`div`,{style:h,children:e},e)):(0,a.jsx)(`div`,{style:{color:`#8b949e`,fontSize:13},children:`No inferred facts were produced.`})})]}):(0,a.jsx)(`div`,{style:{color:`#8b949e`,fontSize:13},children:`Run a rule set to inspect inferred statements here.`})]})]})}var l={background:`linear-gradient(135deg, rgba(13, 17, 23, 0.78), rgba(22, 27, 34, 0.64))`,border:`1px solid rgba(88, 166, 255, 0.18)`,borderRadius:16,padding:20,display:`flex`,flexDirection:`column`},u={color:`#fff`,margin:0,fontSize:18,fontWeight:700},d={color:`#8b949e`,fontSize:13,lineHeight:1.5,margin:`8px 0 14px`},f={width:`100%`,minHeight:120,resize:`vertical`,borderRadius:12,border:`1px solid rgba(88, 166, 255, 0.18)`,background:`rgba(0, 0, 0, 0.25)`,color:`#e6edf3`,padding:12,fontFamily:`Consolas, monospace`,fontSize:13,boxSizing:`border-box`},p={marginTop:18,border:`1px solid rgba(88, 166, 255, 0.3)`,background:`rgba(31, 111, 235, 0.2)`,color:`#fff`,borderRadius:12,padding:`11px 14px`,fontWeight:700,cursor:`pointer`},m={color:`#79c0ff`,border:`1px solid rgba(88, 166, 255, 0.2)`,background:`rgba(88, 166, 255, 0.08)`,borderRadius:999,padding:`5px 10px`,fontSize:12},h={color:`#e6edf3`,background:`rgba(255, 255, 255, 0.04)`,border:`1px solid rgba(255, 255, 255, 0.06)`,borderRadius:10,padding:`10px 12px`,fontFamily:`Consolas, monospace`,fontSize:13};export{c as ReasoningWorkspace};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>semantica-explorer</title>
<script type="module" crossorigin src="/assets/index-2A2Xu6zz.js"></script>
<script type="module" crossorigin src="/assets/index-BaPyswgU.js"></script>
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-B3dmMxJS.js">
<link rel="modulepreload" crossorigin href="/assets/query-vnlTpYnf.js">
<link rel="stylesheet" crossorigin href="/assets/index-Bw-pAf6p.css">