mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
feat(graph): add plugin host for graph tools
This commit is contained in:
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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);
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user