mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-30 04:40:16 +00:00
## Folder & Project
- Renamed `semantica-explorer/` → `explorer/` (cleaner path)
- Browser tab title: `Semantica Knowledge Explorer`
- Brand pill: `SEM` → `SKE` (tooltip: Semantica Knowledge Explorer)
- Nav rail label: `Explore` → `Knowledge Explorer`
- package.json name: `semantica-knowledge-explorer`
- Downgraded Vite 8 → Vite 5 for Node v20.17.0 compatibility
## App Shell
- Dynamic per-workspace kicker labels replacing static "Workspace" pill:
Graph Studio · Vocabulary Browser · Reasoning Engine · SPARQL Query ·
Decision Intelligence · Knowledge Audit · Graph Governance
## Enrich Workspace — 2 new tabs
### Entity Resolution tab
- Similarity threshold slider (0.50–0.99)
- Run Dedup Scan → POST /api/enrich/dedup
- Flagged pairs list with colour-coded score bars (red/amber/green)
- Expandable inline diff: primary vs duplicate side-by-side
- One-click Merge → POST /api/enrich/merge with logEvent dispatch
- Dismiss per pair; Clear all button
- Merge history sidebar pulled live from Registry store
### Registry tab (Document Registry)
- Live chronological audit log of all KG mutations in-session
- Colour-coded op-type badges: IMPORT · MERGE · ADD NODE · ADD EDGE ·
INFER · DELETE · EXPORT · VOCAB
- Filter pills to narrow by operation type
- Expandable JSON detail rows per entry
- Clear log button
- Entirely client-side via registryStore (no backend needed)
## Manage Workspace — 2 new tabs
### KG Overview tab
- Stats chips: total nodes, edges, graph density
- Node type breakdown bar chart (up to 8 types, colour-coded)
- Edge type breakdown bar chart from /api/graph/stats
- Top-10 most connected nodes ranked by degree
- Skeleton loading states + Refresh button
### Ontology Summary tab
- Read-only SKOS scheme tree (scheme → top concepts → narrower)
- Concept detail panel: labels, notation, description, narrower nav
- "Open Full Browser" button deep-links to Vocabulary Browser tab
## Decision Workspace polish
- CausalFlowDiagram: vertical node cards connected by relationship pills
- Outcome badges: colour-coded (green=approved, red=rejected, amber=deferred)
- Live filter input across decision ID, category, and outcome
- Animated skeleton loading while list fetches
## Graph Inspector polish
- PathFlowViz: clickable node chips connected by edge-type labels;
clicking a chip focuses that node in the canvas
- Link Prediction button shows spinner while computing
- Empty states for path trace and candidate links sections
## Registry dispatch — WebSocket
- ADD_NODE events → logEvent("add-node", …) in GraphWorkspace WS handler
- ADD_EDGE events → logEvent("add-edge", …) in GraphWorkspace WS handler
- Import, Export, Merge already dispatched logEvent on API response
## Graph visibility overhaul
### Edge colours (were nearly transparent, now clearly visible)
- edgeBackbone: rgba(…, 0.04) → rgba(…, 0.38)
- edgeStructure: rgba(…, 0.009) → rgba(…, 0.28)
- edgeInspection: rgba(…, 0.026) → rgba(…, 0.48)
- Muted edges: 0.009–0.02 → 0.12–0.26
- Focus edges: 0.16 → 0.42
### Edge sizes
- default minSize: 0.18 → 0.9 (always at least 1 pixel wide)
- path minSize: 1.8 → 2.4
- inactive/muted: hide:true → hide:false (dimmed not hidden)
### Node sizes
- default sizeMultiplier: 0.72 → 0.92
- default minSize: 0.68 → 3.5 (visible at all zoom levels)
- overview nodeScale: 0.66 → 0.88
- nodeTintMix (colour): 0.03 → 0.14
- nodeCoreMix (brightness): 0.52 → 0.72
### Label budget
- overview: 10 → 28 labels
- structure: 36 → 60 labels
- inspection: 80 → 120 labels
### Sigma settings
- renderEdgeLabels: false → true (relationship type on every edge)
- edgeLabelSize: — → 10
- labelRenderedSizeThreshold: 4 → 2
- labelDensity: 0.86 → 1.1
- hideLabelsOnMove: true → false (labels stay visible while panning)
- hideEdgesOnMove: true → false (edges stay visible while panning)
- minCameraRatio: — → 0.04 (prevents zooming inside a node)
- maxCameraRatio: — → 8 (graph stays visible when zoomed out)
### Zoom controls
- Added Zoom In (+) and Zoom Out (−) buttons to graph toolbar
- Smooth animated zoom via camera.animatedZoom / animatedUnzoom (200ms)
- Mouse scroll wheel clamped between minCameraRatio and maxCameraRatio
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
224 lines
6.6 KiB
TypeScript
224 lines
6.6 KiB
TypeScript
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { createGraphLoadProgress } from "./graphLoading";
|
|
import type { ApiEdge, ApiNode, GraphDataSnapshot, GraphLoadProgress, GraphLayoutSource } from "./types";
|
|
|
|
interface NodeListResponse {
|
|
nodes: ApiNode[];
|
|
total: number;
|
|
skip: number;
|
|
limit: number;
|
|
next_cursor?: string | null;
|
|
}
|
|
|
|
interface EdgeListResponse {
|
|
edges: ApiEdge[];
|
|
total: number;
|
|
skip: number;
|
|
limit: number;
|
|
next_cursor?: string | null;
|
|
}
|
|
|
|
const PAGE_LIMIT = 1000;
|
|
|
|
async function fetchAllNodes(
|
|
signal: AbortSignal,
|
|
onProgress?: (progress: GraphLoadProgress) => void,
|
|
): Promise<ApiNode[]> {
|
|
let cursor: string | null = null;
|
|
const collected: ApiNode[] = [];
|
|
let total: number | null = null;
|
|
|
|
while (true) {
|
|
const url = new URL("/api/graph/nodes", window.location.origin);
|
|
url.searchParams.set("limit", String(PAGE_LIMIT));
|
|
if (cursor) {
|
|
url.searchParams.set("cursor", cursor);
|
|
}
|
|
|
|
const response = await fetch(url.toString(), { signal });
|
|
if (!response.ok) {
|
|
throw new Error(`Fetch failed: ${response.status}`);
|
|
}
|
|
|
|
const data: NodeListResponse = await response.json();
|
|
if (!data.nodes?.length) {
|
|
break;
|
|
}
|
|
|
|
total = data.total ?? total;
|
|
collected.push(...data.nodes);
|
|
onProgress?.(createGraphLoadProgress({
|
|
phase: "fetching_nodes",
|
|
progressKind: total ? "determinate" : "indeterminate",
|
|
loaded: collected.length,
|
|
total,
|
|
nodesLoaded: collected.length,
|
|
nodesTotal: total,
|
|
edgesLoaded: 0,
|
|
edgesTotal: null,
|
|
message: total
|
|
? `Loading nodes ${collected.length.toLocaleString()} of ${total.toLocaleString()}`
|
|
: `Loading nodes ${collected.length.toLocaleString()}`,
|
|
}));
|
|
|
|
if (!data.next_cursor) {
|
|
break;
|
|
}
|
|
cursor = data.next_cursor;
|
|
await yieldToMain();
|
|
}
|
|
|
|
return collected;
|
|
}
|
|
|
|
async function fetchAllEdges(
|
|
signal: AbortSignal,
|
|
nodeIds: Set<string>,
|
|
nodeProgress: { loaded: number; total: number | null },
|
|
onProgress?: (progress: GraphLoadProgress) => void,
|
|
): Promise<ApiEdge[]> {
|
|
let cursor: string | null = null;
|
|
const collected: ApiEdge[] = [];
|
|
const seenEdgeIds = new Set<string>();
|
|
let total: number | null = null;
|
|
let warnedOverTotal = false;
|
|
|
|
while (true) {
|
|
const url = new URL("/api/graph/edges", window.location.origin);
|
|
url.searchParams.set("limit", String(PAGE_LIMIT));
|
|
if (cursor) {
|
|
url.searchParams.set("cursor", cursor);
|
|
}
|
|
|
|
const response = await fetch(url.toString(), { signal });
|
|
if (!response.ok) {
|
|
throw new Error(`Fetch failed: ${response.status}`);
|
|
}
|
|
|
|
const data: EdgeListResponse = await response.json();
|
|
if (!data.edges?.length) {
|
|
break;
|
|
}
|
|
|
|
total = data.total ?? total;
|
|
const validEdges = data.edges.filter((edge) => {
|
|
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
|
|
return false;
|
|
}
|
|
if (seenEdgeIds.has(edge.id)) {
|
|
return false;
|
|
}
|
|
seenEdgeIds.add(edge.id);
|
|
return true;
|
|
});
|
|
collected.push(...validEdges);
|
|
const safeLoaded = total ? Math.min(seenEdgeIds.size, total) : seenEdgeIds.size;
|
|
if (!warnedOverTotal && total !== null && seenEdgeIds.size > total) {
|
|
warnedOverTotal = true;
|
|
console.warn("[graph-runtime] edge pagination returned more unique edge ids than total", {
|
|
uniqueEdgesLoaded: seenEdgeIds.size,
|
|
total,
|
|
});
|
|
}
|
|
onProgress?.(createGraphLoadProgress({
|
|
phase: "fetching_edges",
|
|
progressKind: total ? "determinate" : "indeterminate",
|
|
loaded: safeLoaded,
|
|
total,
|
|
nodesLoaded: nodeProgress.loaded,
|
|
nodesTotal: nodeProgress.total,
|
|
edgesLoaded: safeLoaded,
|
|
edgesTotal: total,
|
|
message: total
|
|
? `Loading edges ${safeLoaded.toLocaleString()} of ${total.toLocaleString()}`
|
|
: `Loading edges ${safeLoaded.toLocaleString()}`,
|
|
}));
|
|
|
|
if (!data.next_cursor) {
|
|
break;
|
|
}
|
|
cursor = data.next_cursor;
|
|
await yieldToMain();
|
|
}
|
|
|
|
return collected;
|
|
}
|
|
|
|
function yieldToMain(): Promise<void> {
|
|
if ("scheduler" in window && typeof (window as Window & { scheduler?: { yield?: () => Promise<void> } }).scheduler?.yield === "function") {
|
|
return (window as Window & { scheduler: { yield: () => Promise<void> } }).scheduler.yield();
|
|
}
|
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
function hasUsableCoordinate(value: number | null | undefined): value is number {
|
|
return typeof value === "number" && Number.isFinite(value);
|
|
}
|
|
|
|
interface UseGraphDataOptions {
|
|
enabled?: boolean;
|
|
onProgress?: (progress: GraphLoadProgress) => void;
|
|
}
|
|
|
|
export function useGraphData(options: UseGraphDataOptions = {}) {
|
|
const { enabled = true, onProgress } = options;
|
|
|
|
return useQuery<GraphDataSnapshot>({
|
|
queryKey: ["graph", "runtime-snapshot"],
|
|
enabled,
|
|
staleTime: Infinity,
|
|
queryFn: async ({ signal }): Promise<GraphDataSnapshot> => {
|
|
const startedAt = performance.now();
|
|
onProgress?.(createGraphLoadProgress({
|
|
phase: "bootstrapping",
|
|
progressKind: "indeterminate",
|
|
nodesLoaded: 0,
|
|
nodesTotal: null,
|
|
edgesLoaded: 0,
|
|
edgesTotal: null,
|
|
message: "Preparing graph session",
|
|
}));
|
|
|
|
const nodes = await fetchAllNodes(signal, onProgress);
|
|
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
const edges = await fetchAllEdges(
|
|
signal,
|
|
nodeIds,
|
|
{ loaded: nodes.length, total: nodes.length },
|
|
onProgress,
|
|
);
|
|
|
|
onProgress?.(createGraphLoadProgress({
|
|
phase: "hydrating_scene",
|
|
progressKind: "indeterminate",
|
|
nodesLoaded: nodes.length,
|
|
nodesTotal: nodes.length,
|
|
edgesLoaded: edges.length,
|
|
edgesTotal: edges.length,
|
|
message: "Preparing graph runtime snapshot",
|
|
}));
|
|
|
|
return {
|
|
nodes,
|
|
edges,
|
|
summary: {
|
|
nodeCount: nodes.length,
|
|
edgeCount: edges.length,
|
|
loadTimeMs: Math.round(performance.now() - startedAt),
|
|
hasCoordinates: nodes.some((node) => hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)),
|
|
layoutSource: (nodes.some((node) => hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y))
|
|
? "provided"
|
|
: "runtime") as GraphLayoutSource,
|
|
layoutReady: nodes.some((node) => hasUsableCoordinate(node.x) && hasUsableCoordinate(node.y)),
|
|
},
|
|
fetchedAt: Date.now(),
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useReloadGraphData() {
|
|
const queryClient = useQueryClient();
|
|
return () => queryClient.invalidateQueries({ queryKey: ["graph", "runtime-snapshot"] });
|
|
}
|