)}
- {graphData && graphData.nodes.length === 1 && graphData.edges.length === 0 && !isLoading && (
+ {graphData && graphData.nodes.length === 0 && !isLoading && (
No relationships found for this class
@@ -322,31 +363,35 @@ export function OntologyGraph({
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
+ onNodeClick={handleNodeClick}
+ onNodeDoubleClick={handleNodeDoubleClick}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
colorMode={colorMode}
fitView
- fitViewOptions={{ padding: 0.2 }}
+ fitViewOptions={{ padding: 0.2, duration: 400 }}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
+ defaultEdgeOptions={{ type: "ontologyEdge" }}
>
-
-
-
+
{
+ const nodeType = node.data?.nodeType;
+ if (nodeType === "focus") return "#3b82f6";
+ if (nodeType === "root") return "#ef4444";
+ if (nodeType === "secondary_root") return "#64748b";
+ if (nodeType === "property") return "#93c5fd";
+ if (nodeType === "individual") return "#f9a8d4";
+ if (nodeType === "external") return "#e2e8f0";
+ return "#d1d5db";
+ }}
+ maskColor="rgba(0,0,0,0.1)"
+ className="!bg-slate-100 dark:!bg-slate-800"
/>
diff --git a/components/graph/OntologyNode.tsx b/components/graph/OntologyNode.tsx
index 3973b679..832b691b 100644
--- a/components/graph/OntologyNode.tsx
+++ b/components/graph/OntologyNode.tsx
@@ -13,6 +13,7 @@ export interface OntologyNodeData {
deprecated?: boolean;
childCount?: number;
isExpanded?: boolean;
+ layoutDirection?: "TB" | "LR";
onNavigate?: (iri: string) => void;
onExpandNode?: (iri: string) => void;
}
@@ -27,7 +28,9 @@ const nodeStyles: Record = {
class:
"border border-slate-300 bg-white dark:bg-slate-800 dark:border-slate-600",
root:
- "border-2 border-amber-400 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-500/70 font-medium",
+ "border-[3px] border-red-500 bg-red-50 dark:bg-red-950/30 dark:border-red-500/70 font-semibold",
+ secondary_root:
+ "border-2 border-slate-500 bg-slate-100 dark:bg-slate-700 dark:border-slate-400 font-semibold",
individual:
"border border-pink-300 bg-pink-50 dark:bg-pink-950/30 dark:border-pink-500/60",
property:
@@ -53,30 +56,29 @@ export const OntologyNode = memo(function OntologyNode({
data,
id,
}: OntologyNodeProps) {
- const { label, nodeType, deprecated, childCount, isExpanded, onNavigate, onExpandNode } = data;
+ const { label, nodeType, deprecated, childCount, isExpanded, layoutDirection, onNavigate, onExpandNode } = data;
+ const targetPosition = layoutDirection === "LR" ? Position.Left : Position.Top;
+ const sourcePosition = layoutDirection === "LR" ? Position.Right : Position.Bottom;
- const handleClick = () => {
+ // Mouse interactions are handled by ReactFlow's onNodeClick / onNodeDoubleClick
+ // at the parent level (OntologyGraph). Keyboard events here provide parity:
+ // Enter / Space → expand (matches single-click semantics)
+ // Shift + Enter → navigate (matches double-click semantics)
+ const handleKeyDown = (e: React.KeyboardEvent) => {
if (nodeType === "external") return;
- onNavigate?.(id);
- };
-
- const handleDoubleClick = () => {
- if (nodeType === "unexplored") {
- onExpandNode?.(id);
+ if (e.key === "Enter" && e.shiftKey) {
+ e.preventDefault();
+ onNavigate?.(id);
+ return;
}
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
- if (nodeType === "unexplored") {
- onExpandNode?.(id);
- } else {
- handleClick();
- }
+ onExpandNode?.(id);
}
};
+ const expandable = nodeType !== "external";
+
return (
-
+
{typeBadge[nodeType] && (
@@ -128,7 +131,7 @@ export const OntologyNode = memo(function OntologyNode({
)}
-
+
);
});
diff --git a/lib/api/graph.ts b/lib/api/graph.ts
new file mode 100644
index 00000000..fd3e190b
--- /dev/null
+++ b/lib/api/graph.ts
@@ -0,0 +1,67 @@
+/**
+ * Entity Graph API client.
+ *
+ * Fetches server-side BFS graph data for visualization.
+ */
+
+import { api } from "./client";
+
+export interface GraphNode {
+ id: string;
+ label: string;
+ iri: string;
+ definition: string | null;
+ is_focus: boolean;
+ is_root: boolean;
+ depth: number;
+ node_type: string;
+ child_count: number | null;
+}
+
+export interface GraphEdge {
+ id: string;
+ source: string;
+ target: string;
+ edge_type: "subClassOf" | "equivalentClass" | "disjointWith" | "seeAlso";
+ label: string | null;
+}
+
+export interface EntityGraphResponse {
+ focus_iri: string;
+ focus_label: string;
+ nodes: GraphNode[];
+ edges: GraphEdge[];
+ truncated: boolean;
+ total_concept_count: number;
+}
+
+export interface FetchGraphOptions {
+ branch?: string;
+ ancestorsDepth?: number;
+ descendantsDepth?: number;
+ maxNodes?: number;
+ includeSeeAlso?: boolean;
+}
+
+export const graphApi = {
+ getEntityGraph: (
+ projectId: string,
+ classIri: string,
+ options: FetchGraphOptions = {},
+ token?: string,
+ ) =>
+ api.get
(
+ `/api/v1/projects/${projectId}/ontology/classes/graph`,
+ {
+ params: {
+ class_iri: classIri,
+ branch: options.branch,
+ ancestors_depth: options.ancestorsDepth,
+ descendants_depth: options.descendantsDepth,
+ max_nodes: options.maxNodes,
+ include_see_also: options.includeSeeAlso,
+ },
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
+ },
+ ),
+};
diff --git a/lib/graph/buildGraphData.ts b/lib/graph/buildGraphData.ts
deleted file mode 100644
index f6477b01..00000000
--- a/lib/graph/buildGraphData.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-import { getLocalName } from "@/lib/utils";
-import type { OWLClassDetail } from "@/lib/api/client";
-import type { ClassTreeNode } from "@/lib/ontology/types";
-import type { GraphData, OntologyGraphNode, OntologyGraphEdge, GraphNodeType } from "./types";
-
-const WELL_KNOWN_NAMESPACES = new Set([
- "http://www.w3.org/2002/07/owl#",
- "http://www.w3.org/2000/01/rdf-schema#",
- "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
- "http://www.w3.org/2001/XMLSchema#",
- "http://purl.org/dc/elements/1.1/",
- "http://purl.org/dc/terms/",
- "http://xmlns.com/foaf/0.1/",
- "http://www.w3.org/2004/02/skos/core#",
-]);
-
-function isExternalIri(iri: string): boolean {
- for (const ns of WELL_KNOWN_NAMESPACES) {
- if (iri.startsWith(ns)) return true;
- }
- return false;
-}
-
-const OWL_THING_IRI = "http://www.w3.org/2002/07/owl#Thing";
-const SEEALSO_IRI = "http://www.w3.org/2000/01/rdf-schema#seeAlso";
-const IS_DEFINED_BY_IRI = "http://www.w3.org/2000/01/rdf-schema#isDefinedBy";
-const MAX_CHILDREN_PER_NODE = 20;
-
-/**
- * Extract IRI-valued seeAlso/isDefinedBy targets from a class detail's annotations.
- */
-export function getSeeAlsoIris(detail: OWLClassDetail): string[] {
- const iris: string[] = [];
- for (const annot of detail.annotations) {
- if (annot.property_iri === SEEALSO_IRI || annot.property_iri === IS_DEFINED_BY_IRI) {
- for (const val of annot.values) {
- const v = typeof val === "string" ? val : val.value;
- if (v.startsWith("http://") || v.startsWith("https://")) {
- iris.push(v);
- }
- }
- }
- }
- return iris;
-}
-
-/**
- * Recursively walk tree nodes to build an IRI → label map.
- */
-export function extractTreeLabelMap(nodes: ClassTreeNode[]): Map {
- const map = new Map();
- function walk(list: ClassTreeNode[]) {
- for (const node of list) {
- if (node.label) map.set(node.iri, node.label);
- if (node.children.length > 0) walk(node.children);
- }
- }
- walk(nodes);
- return map;
-}
-
-/**
- * Build graph data from a set of resolved class details centered on a focus IRI.
- */
-export function buildGraphFromClassDetail(
- focusIri: string,
- resolvedNodes: Map,
- labelHints?: Map,
- entityTypes?: Map,
-): GraphData {
- const visited = new Set();
- const nodeMap = new Map();
- const edges: OntologyGraphEdge[] = [];
- const edgeSet = new Set();
-
- // Collect resolved labels from all parent_labels maps so unresolved nodes
- // can display human-readable names instead of raw IRI fragments.
- const knownLabels = new Map();
- for (const detail of resolvedNodes.values()) {
- if (detail.parent_labels) {
- for (const [iri, label] of Object.entries(detail.parent_labels)) {
- if (label) knownLabels.set(iri, label);
- }
- }
- }
-
- function addEdge(source: string, target: string, edgeType: OntologyGraphEdge["edgeType"]) {
- if (source === OWL_THING_IRI || target === OWL_THING_IRI) return;
- const key = `${edgeType}:${source}:${target}`;
- if (edgeSet.has(key)) return;
- edgeSet.add(key);
- edges.push({ id: key, source, target, edgeType });
- }
-
- function getNodeType(iri: string): GraphNodeType {
- if (iri === focusIri) return "focus";
- if (isExternalIri(iri)) return "external";
- // Check entity type for non-class entities
- const et = entityTypes?.get(iri);
- if (et === "individual") return "individual";
- if (et === "property") return "property";
- if (!resolvedNodes.has(iri)) return "unexplored";
- const detail = resolvedNodes.get(iri)!;
- if (detail.parent_iris.length === 0 || detail.parent_iris.every(p => p === OWL_THING_IRI)) return "root";
- return "class";
- }
-
- function resolveLabel(iri: string): string {
- // 1. Resolved node's own labels
- const detail = resolvedNodes.get(iri);
- if (detail?.labels[0]?.value) return detail.labels[0].value;
- // 2. Label from another node's parent_labels map
- const known = knownLabels.get(iri);
- if (known) return known;
- // 3. Label from tree nodes already in memory
- const hint = labelHints?.get(iri);
- if (hint) return hint;
- // 4. Fallback to local name
- return getLocalName(iri);
- }
-
- function ensureNode(iri: string): void {
- if (iri === OWL_THING_IRI) return;
- if (nodeMap.has(iri)) return;
- const detail = resolvedNodes.get(iri);
- nodeMap.set(iri, {
- id: iri,
- label: resolveLabel(iri),
- nodeType: getNodeType(iri),
- deprecated: detail?.deprecated,
- childCount: detail?.child_count,
- isExpanded: resolvedNodes.has(iri),
- });
- }
-
- function processNode(iri: string): void {
- if (visited.has(iri)) return;
- visited.add(iri);
-
- const detail = resolvedNodes.get(iri);
- if (!detail) return;
-
- ensureNode(iri);
-
- // subClassOf edges: child → parent (natural OWL direction; ELK "UP" places parents at top)
- for (const parentIri of detail.parent_iris) {
- ensureNode(parentIri);
- addEdge(iri, parentIri, "subClassOf");
- }
-
- // equivalentClass edges
- for (const eqIri of detail.equivalent_iris ?? []) {
- ensureNode(eqIri);
- addEdge(iri, eqIri, "equivalentClass");
- }
-
- // disjointWith edges
- for (const djIri of detail.disjoint_iris ?? []) {
- ensureNode(djIri);
- addEdge(iri, djIri, "disjointWith");
- }
-
- // seeAlso / isDefinedBy from annotations
- for (const annot of detail.annotations) {
- if (annot.property_iri === SEEALSO_IRI || annot.property_iri === IS_DEFINED_BY_IRI) {
- for (const val of annot.values) {
- const v = typeof val === "string" ? val : val.value;
- if (v.startsWith("http://") || v.startsWith("https://")) {
- ensureNode(v);
- addEdge(iri, v, "seeAlso");
- }
- }
- }
- }
- }
-
- // Process all resolved nodes
- for (const iri of resolvedNodes.keys()) {
- processNode(iri);
- }
-
- // Ensure focus node exists even if not in resolvedNodes
- ensureNode(focusIri);
-
- // Cap children: for any parent with more than MAX_CHILDREN_PER_NODE children shown, trim.
- // Always preserve edges involving the focus node so it's never pruned.
- const childrenCount = new Map();
- const filteredEdges = edges.filter((edge) => {
- if (edge.edgeType === "subClassOf") {
- // source is child, target is parent — always keep the focus node's edges
- if (edge.source === focusIri) return true;
- const parent = edge.target;
- const count = (childrenCount.get(parent) || 0) + 1;
- childrenCount.set(parent, count);
- if (count > MAX_CHILDREN_PER_NODE) return false;
- }
- return true;
- });
-
- // Prune nodes that are not connected after child-cap filtering
- const visibleNodeIds = new Set([focusIri]);
- for (const edge of filteredEdges) {
- visibleNodeIds.add(edge.source);
- visibleNodeIds.add(edge.target);
- }
- const nodes = Array.from(nodeMap.values()).filter((node) => visibleNodeIds.has(node.id));
-
- // If no relationships at all
- if (filteredEdges.length === 0 && nodes.length <= 1) {
- return { nodes, edges: [] };
- }
-
- return { nodes, edges: filteredEdges };
-}
diff --git a/lib/graph/elkLayout.ts b/lib/graph/elkLayout.ts
deleted file mode 100644
index d2af9989..00000000
--- a/lib/graph/elkLayout.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import ELK from "elkjs/lib/elk.bundled.js";
-import type { OntologyGraphNode, OntologyGraphEdge } from "./types";
-
-const elk = new ELK();
-
-const NODE_WIDTH = 180;
-const NODE_HEIGHT = 44;
-
-export async function computeLayout(
- nodes: OntologyGraphNode[],
- edges: OntologyGraphEdge[],
- direction: "TB" | "LR" = "TB",
-): Promise