diff --git a/__tests__/components/graph/EntityGraphModal.test.tsx b/__tests__/components/graph/EntityGraphModal.test.tsx new file mode 100644 index 00000000..53c67f9c --- /dev/null +++ b/__tests__/components/graph/EntityGraphModal.test.tsx @@ -0,0 +1,192 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +// ── Mocks ────────────────────────────────────────────────────────── + +let capturedOnNavigateToClass: ((iri: string) => void) | undefined; +vi.mock("@/components/graph/OntologyGraph", () => ({ + OntologyGraph: ({ focusIri, projectId, onNavigateToClass }: { focusIri: string; projectId: string; onNavigateToClass?: (iri: string) => void }) => { + capturedOnNavigateToClass = onNavigateToClass; + return ( +
+ +
+ ); + }, +})); + +import { EntityGraphModal } from "@/components/graph/EntityGraphModal"; + +// ── Fixtures ─────────────────────────────────────────────────────── + +const defaultProps = { + focusIri: "urn:test:Class1", + label: "Class1", + projectId: "proj-1", + branch: "main", + onNavigateToClass: vi.fn(), + onClose: vi.fn(), +}; + +// ── Tests ────────────────────────────────────────────────────────── + +describe("EntityGraphModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedOnNavigateToClass = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // --- Dialog semantics --- + + it("renders with role=dialog and aria-modal", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeDefined(); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + }); + + it("has aria-labelledby pointing to the title", () => { + render(); + const dialog = screen.getByRole("dialog"); + const labelId = dialog.getAttribute("aria-labelledby"); + expect(labelId).toBe("entity-graph-title"); + const title = document.getElementById(labelId!); + expect(title).not.toBeNull(); + }); + + // --- Title rendering --- + + it("renders the label in the title", () => { + render(); + expect(screen.getByText("Class1")).toBeDefined(); + expect(screen.getByText("Entity Graph")).toBeDefined(); + }); + + // --- Close button --- + + it("renders a close button with aria-label", () => { + render(); + const closeBtn = screen.getByLabelText("Close graph modal"); + expect(closeBtn).toBeDefined(); + }); + + it("calls onClose when close button is clicked", () => { + render(); + fireEvent.click(screen.getByLabelText("Close graph modal")); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + // --- Escape key --- + + it("calls onClose when Escape is pressed", () => { + render(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + it("does not call onClose for non-Escape keys", () => { + render(); + fireEvent.keyDown(document, { key: "Enter" }); + expect(defaultProps.onClose).not.toHaveBeenCalled(); + }); + + // --- Focus management --- + + it("focuses the first focusable element on mount", () => { + render(); + // The close button is the first focusable element in the dialog + const closeBtn = screen.getByLabelText("Close graph modal"); + expect(document.activeElement).toBe(closeBtn); + }); + + it("restores focus on unmount", () => { + const button = document.createElement("button"); + button.textContent = "Trigger"; + document.body.appendChild(button); + button.focus(); + expect(document.activeElement).toBe(button); + + const { unmount } = render(); + // Focus moved to dialog + expect(document.activeElement).not.toBe(button); + + unmount(); + expect(document.activeElement).toBe(button); + + document.body.removeChild(button); + }); + + // --- Focus trapping --- + + it("traps focus: shift-tab from first focusable wraps to last", () => { + render(); + const dialog = screen.getByRole("dialog"); + const focusables = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + + expect(focusables.length).toBeGreaterThan(1); + focusables[0].focus(); + fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(focusables[focusables.length - 1]); + }); + + it("traps focus: tab from last focusable wraps to first", () => { + render(); + const dialog = screen.getByRole("dialog"); + const focusables = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + + expect(focusables.length).toBeGreaterThan(1); + focusables[focusables.length - 1].focus(); + fireEvent.keyDown(document, { key: "Tab", shiftKey: false }); + expect(document.activeElement).toBe(focusables[0]); + }); + + it("does not trap focus when Tab is pressed but focus is in the middle", () => { + render(); + const dialog = screen.getByRole("dialog"); + const focusables = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + + expect(focusables.length).toBeGreaterThan(1); + // Focus a non-first, non-last element — should not prevent default + // If only 2 elements, focus the first (Tab forward from first is not trapped) + const mid = focusables.length > 2 ? Math.floor(focusables.length / 2) : 0; + focusables[mid].focus(); + const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true }); + const preventSpy = vi.spyOn(event, "preventDefault"); + document.dispatchEvent(event); + expect(preventSpy).not.toHaveBeenCalled(); + }); + + // --- Esc keyboard hint --- + + it("renders Esc keyboard hint", () => { + render(); + expect(screen.getByText("Esc")).toBeDefined(); + }); + + // --- Navigate callback --- + + it("calls onNavigateToClass and onClose when graph triggers navigation", () => { + render(); + expect(capturedOnNavigateToClass).toBeDefined(); + capturedOnNavigateToClass!("urn:target:Class"); + expect(defaultProps.onNavigateToClass).toHaveBeenCalledWith("urn:target:Class"); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + // --- OntologyGraph rendered --- + + it("renders the OntologyGraph component", () => { + render(); + expect(screen.getByTestId("ontology-graph")).toBeDefined(); + }); +}); diff --git a/__tests__/components/graph/OntologyEdge.test.tsx b/__tests__/components/graph/OntologyEdge.test.tsx index 7df9ed3b..83ff6c93 100644 --- a/__tests__/components/graph/OntologyEdge.test.tsx +++ b/__tests__/components/graph/OntologyEdge.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { Position } from "@xyflow/react"; + import { OntologyEdge } from "@/components/graph/OntologyEdge"; vi.mock("@xyflow/react", () => ({ @@ -16,9 +17,18 @@ vi.mock("@xyflow/react", () => ({
{children}
), getBezierPath: (): [string, number, number] => ["M0,0 C10,10 20,20 30,30", 15, 15], + getSmoothStepPath: (): [string, number, number] => ["M0,0 L10,10 L30,30", 15, 15], Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" }, })); +// Mock the editor mode store — OntologyEdge subscribes for graphEdgeStyle. +// Without this, Zustand's persist middleware initializes against window.matchMedia +// (system theme detection), which jsdom doesn't ship. +vi.mock("@/lib/stores/editorModeStore", () => ({ + useEditorModeStore: (selector: (s: { graphEdgeStyle: string }) => unknown) => + selector({ graphEdgeStyle: "smoothstep" }), +})); + describe("OntologyEdge", () => { const baseProps = { id: "edge-1", @@ -49,9 +59,32 @@ describe("OntologyEdge", () => { ); const edge = screen.getByTestId("base-edge-edge-1"); expect(edge).toBeDefined(); + // Default edge type is subClassOf: solid stroke in slate color, strokeWidth 1.5 + expect(edge.style.stroke).toBe("rgb(148, 163, 184)"); // #94a3b8 + expect(edge.style.strokeWidth).toBe("1.5"); + expect(edge.style.strokeDasharray).toBeFalsy(); + }); + + it("uses config-based markerEnd for subClassOf edges", () => { + render( + + + + ); + const edge = screen.getByTestId("base-edge-edge-1"); expect(edge.getAttribute("data-marker-end")).toBe("url(#arrow-slate)"); }); + it("does not apply markerEnd for non-subClassOf edges", () => { + render( + + + + ); + const edge = screen.getByTestId("base-edge-edge-1"); + expect(edge.getAttribute("data-marker-end")).toBe(""); + }); + it("renders with equivalentClass edge type", () => { render( @@ -86,7 +119,7 @@ describe("OntologyEdge", () => { ); const edge = screen.getByTestId("base-edge-edge-1"); - expect(edge.style.strokeDasharray).toBe("2 4"); + expect(edge.style.strokeDasharray).toBe("6 3"); }); it("does not show label by default (not hovered)", () => { @@ -146,7 +179,10 @@ describe("OntologyEdge", () => { ); const edge = screen.getByTestId("base-edge-edge-1"); - expect(edge.getAttribute("data-marker-end")).toBe("url(#arrow-slate)"); + // Without data, edge defaults to subClassOf style + expect(edge.style.stroke).toBe("rgb(148, 163, 184)"); + expect(edge.style.strokeWidth).toBe("1.5"); + expect(edge.style.strokeDasharray).toBeFalsy(); }); it("renders invisible hover detection path with strokeWidth 16", () => { @@ -162,4 +198,15 @@ describe("OntologyEdge", () => { ); expect(hoverPath?.getAttribute("stroke-width")).toBe("16"); }); + + it("renders smoothstep path when graphEdgeStyle is 'smoothstep' (per store mock)", () => { + render( + + + + ); + const edge = screen.getByTestId("base-edge-edge-1"); + // Default mock returns "smoothstep" — getSmoothStepPath is invoked. + expect(edge.getAttribute("d")).toBe("M0,0 L10,10 L30,30"); + }); }); diff --git a/__tests__/components/graph/OntologyGraph.test.tsx b/__tests__/components/graph/OntologyGraph.test.tsx index 6b965240..7124e060 100644 --- a/__tests__/components/graph/OntologyGraph.test.tsx +++ b/__tests__/components/graph/OntologyGraph.test.tsx @@ -1,23 +1,52 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; +import type { EntityGraphResponse } from "@/lib/api/graph"; // ── Mocks ────────────────────────────────────────────────────────── const mockUseGraphData = vi.fn(); +const mockRunLayout = vi.fn().mockResolvedValue(undefined); +/* eslint-disable @typescript-eslint/no-explicit-any */ +let capturedReactFlowProps: Record = {}; vi.mock("@xyflow/react", () => ({ - ReactFlow: ({ children, nodes }: { children?: React.ReactNode; nodes?: unknown[] }) => ( -
- {children} -
- ), - MiniMap: () =>
, + ReactFlow: (props: Record) => { + capturedReactFlowProps = props; + return ( +
+ {props.children} +
+ ); + }, + MiniMap: (props: Record) => { + const nodeColorFn = props.nodeColor; + return ( +
+ {nodeColorFn && ( + + {JSON.stringify({ + focus: nodeColorFn({ data: { nodeType: "focus" } }), + root: nodeColorFn({ data: { nodeType: "root" } }), + secondary_root: nodeColorFn({ data: { nodeType: "secondary_root" } }), + property: nodeColorFn({ data: { nodeType: "property" } }), + individual: nodeColorFn({ data: { nodeType: "individual" } }), + external: nodeColorFn({ data: { nodeType: "external" } }), + other: nodeColorFn({ data: { nodeType: "class" } }), + })} + + )} +
+ ); + }, Controls: ({ children }: { children?: React.ReactNode }) =>
{children}
, Background: () =>
, BackgroundVariant: { Dots: "dots" }, useNodesState: (initial: unknown[]) => [initial || [], vi.fn(), vi.fn()], useEdgesState: (initial: unknown[]) => [initial || [], vi.fn(), vi.fn()], + ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => <>{children}, + useReactFlow: () => ({ setCenter: vi.fn() }), })); +/* eslint-enable @typescript-eslint/no-explicit-any */ vi.mock("@xyflow/react/dist/style.css", () => ({})); @@ -25,8 +54,15 @@ vi.mock("@/lib/hooks/useGraphData", () => ({ useGraphData: (...args: unknown[]) => mockUseGraphData(...args), })); -vi.mock("@/lib/graph/elkLayout", () => ({ - computeLayout: vi.fn().mockResolvedValue(new Map()), +let mockIsLayouting = false; +vi.mock("@/lib/graph/useELKLayout", () => ({ + useELKLayout: () => ({ + nodes: [], + edges: [], + get isLayouting() { return mockIsLayouting; }, + runLayout: mockRunLayout, + setNodeHandlers: vi.fn(), + }), })); vi.mock("@/components/graph/OntologyNode", () => ({ @@ -46,19 +82,25 @@ import { OntologyGraph } from "@/components/graph/OntologyGraph"; // ── Fixtures ─────────────────────────────────────────────────────── -const mockGraphData = { +const mockGraphData: EntityGraphResponse = { + focus_iri: "iri:Class1", + focus_label: "Class1", nodes: [ - { id: "iri:Class1", label: "Class1", nodeType: "focus" as const, deprecated: false, childCount: 2, isExpanded: true }, - { id: "iri:Class2", label: "Class2", nodeType: "class" as const, deprecated: false, childCount: 0, isExpanded: false }, + { id: "iri:Class1", label: "Class1", iri: "iri:Class1", definition: null, is_focus: true, is_root: false, depth: 0, node_type: "focus", child_count: 2 }, + { id: "iri:Class2", label: "Class2", iri: "iri:Class2", definition: null, is_focus: false, is_root: false, depth: 1, node_type: "class", child_count: 0 }, ], edges: [ - { id: "e1", source: "iri:Class1", target: "iri:Class2", edgeType: "subClassOf" as const }, + { id: "e1", source: "iri:Class1", target: "iri:Class2", edge_type: "subClassOf", label: null }, ], + truncated: false, + total_concept_count: 2, }; const defaultReturn = { graphData: mockGraphData, isLoading: false, + showAllDescendants: false, + setShowAllDescendants: vi.fn(), expandNode: vi.fn(), resetGraph: vi.fn(), resolvedCount: 2, @@ -67,7 +109,6 @@ const defaultReturn = { const defaultProps = { focusIri: "iri:Class1", projectId: "proj-1", - accessToken: "tok", branch: "main", onNavigateToClass: vi.fn(), }; @@ -77,6 +118,7 @@ const defaultProps = { describe("OntologyGraph", () => { beforeEach(() => { vi.clearAllMocks(); + mockIsLayouting = false; mockUseGraphData.mockReturnValue(defaultReturn); }); @@ -124,11 +166,12 @@ describe("OntologyGraph", () => { // --- No relationships state --- - it("shows no-relationships message when single node and no edges", () => { + it("shows no-relationships message when graphData has no nodes and no edges", () => { mockUseGraphData.mockReturnValue({ ...defaultReturn, graphData: { - nodes: [{ id: "iri:Class1", label: "Class1", nodeType: "focus", deprecated: false, childCount: 0, isExpanded: false }], + ...mockGraphData, + nodes: [], edges: [], }, }); @@ -145,17 +188,6 @@ describe("OntologyGraph", () => { expect(screen.getByText(/2 nodes, 1 edges/)).toBeDefined(); }); - it("shows resolved count when > 0", () => { - render(); - expect(screen.getByText(/2 resolved/)).toBeDefined(); - }); - - it("does not show resolved count when 0", () => { - mockUseGraphData.mockReturnValue({ ...defaultReturn, resolvedCount: 0 }); - render(); - expect(screen.queryByText(/resolved/)).toBeNull(); - }); - // --- Layout direction toggle --- it("defaults to Top-Down layout", () => { @@ -188,17 +220,10 @@ describe("OntologyGraph", () => { expect(defaultReturn.resetGraph).toHaveBeenCalledTimes(1); }); - // --- Fit view button --- - - it("renders Fit view button inside controls", () => { - render(); - expect(screen.getByLabelText("Fit view")).toBeDefined(); - }); - // --- Null graphData --- it("renders without crashing when graphData is null", () => { - mockUseGraphData.mockReturnValue({ ...defaultReturn, graphData: null }); + mockUseGraphData.mockReturnValue({ ...defaultReturn, graphData: null, resolvedCount: 0 }); render(); expect(screen.getByTestId("react-flow")).toBeDefined(); expect(screen.getByText(/0 nodes, 0 edges/)).toBeDefined(); @@ -207,16 +232,127 @@ describe("OntologyGraph", () => { // --- Passes correct options to useGraphData --- it("passes correct options to useGraphData", () => { - render(); + render(); expect(mockUseGraphData).toHaveBeenCalledWith( expect.objectContaining({ focusIri: "iri:Class1", projectId: "proj-1", - accessToken: "tok", branch: "main", + accessToken: "test-token-123", }) ); }); + + // --- Show Descendants button --- + + it("shows Show Descendants button", () => { + render(); + expect(screen.getByLabelText("Show all descendants")).toBeDefined(); + }); + + // --- Truncation badge --- + + it("shows truncation badge when graphData is truncated", () => { + mockUseGraphData.mockReturnValue({ + ...defaultReturn, + graphData: { ...mockGraphData, truncated: true, total_concept_count: 200 }, + }); + render(); + expect(screen.getByText(/Truncated/)).toBeDefined(); + }); + + // --- Show Descendants toggle --- + + it("calls setShowAllDescendants when descendants button is clicked", () => { + render(); + fireEvent.click(screen.getByLabelText("Show all descendants")); + expect(defaultReturn.setShowAllDescendants).toHaveBeenCalledWith(true); + }); + + it("shows 'All Descendants' when showAllDescendants is true", () => { + mockUseGraphData.mockReturnValue({ ...defaultReturn, showAllDescendants: true }); + render(); + expect(screen.getByText("All Descendants")).toBeDefined(); + expect(screen.getByLabelText("Show one level of descendants")).toBeDefined(); + }); + + // --- isLayouting spinner --- + + it("shows 'Computing layout...' when isLayouting is true", () => { + mockIsLayouting = true; + render(); + expect(screen.getByText("Computing layout...")).toBeDefined(); + }); + + it("does not show 'Computing layout...' when isLayouting is false", () => { + render(); + expect(screen.queryByText("Computing layout...")).toBeNull(); + }); + + // --- Node click callback --- + + it("calls expandNode after click delay when a node is clicked", () => { + vi.useFakeTimers(); + render(); + const onNodeClick = capturedReactFlowProps.onNodeClick; + expect(onNodeClick).toBeDefined(); + + onNodeClick({} as React.MouseEvent, { id: "iri:Class2" }); + // expandNode is delayed to distinguish from double-click + expect(defaultReturn.expandNode).not.toHaveBeenCalled(); + vi.advanceTimersByTime(200); + expect(defaultReturn.expandNode).toHaveBeenCalledWith("iri:Class2"); + vi.useRealTimers(); + }); + + it("cancels expand when double-click follows single click", () => { + vi.useFakeTimers(); + render(); + const onNodeClick = capturedReactFlowProps.onNodeClick; + const onNodeDoubleClick = capturedReactFlowProps.onNodeDoubleClick; + + // Single click, then double-click before timer fires + onNodeClick({} as React.MouseEvent, { id: "iri:Class1" }); + onNodeDoubleClick({} as React.MouseEvent, { id: "iri:Class1" }); + vi.advanceTimersByTime(200); + + expect(defaultReturn.expandNode).not.toHaveBeenCalled(); + expect(defaultProps.onNavigateToClass).toHaveBeenCalledWith("iri:Class1"); + vi.useRealTimers(); + }); + + // --- Node double-click callback --- + + it("calls onNavigateToClass on node double-click", () => { + render(); + const onNodeDoubleClick = capturedReactFlowProps.onNodeDoubleClick; + expect(onNodeDoubleClick).toBeDefined(); + + onNodeDoubleClick({} as React.MouseEvent, { id: "iri:Class2" }); + expect(defaultProps.onNavigateToClass).toHaveBeenCalledWith("iri:Class2"); + }); + + // --- MiniMap nodeColor function --- + + it("returns correct colors for each node type in MiniMap", () => { + render(); + const colorsEl = screen.getByTestId("minimap-colors"); + const colors = JSON.parse(colorsEl.textContent!); + expect(colors.focus).toBe("#3b82f6"); + expect(colors.root).toBe("#ef4444"); + expect(colors.secondary_root).toBe("#64748b"); + expect(colors.property).toBe("#93c5fd"); + expect(colors.individual).toBe("#f9a8d4"); + expect(colors.external).toBe("#e2e8f0"); + expect(colors.other).toBe("#d1d5db"); + }); + + // --- Edge style (bezier only, no toggle) --- + + it("does not render an edge style toggle button", () => { + render(); + expect(screen.queryByLabelText(/Switch to.*edges/)).toBeNull(); + }); }); describe("GraphLegend", () => { @@ -248,7 +384,8 @@ describe("GraphLegend", () => { fireEvent.click(screen.getByLabelText("Expand legend")); expect(screen.getByText("Focus")).toBeDefined(); expect(screen.getByText("Class")).toBeDefined(); - expect(screen.getByText("Root")).toBeDefined(); + expect(screen.getByText("Primary root")).toBeDefined(); + expect(screen.getByText("Branch root")).toBeDefined(); expect(screen.getByText("Individual")).toBeDefined(); expect(screen.getByText("Property")).toBeDefined(); expect(screen.getByText("External")).toBeDefined(); @@ -261,7 +398,7 @@ describe("GraphLegend", () => { expect(screen.getByText("subClassOf")).toBeDefined(); expect(screen.getByText("equivalentTo")).toBeDefined(); expect(screen.getByText("disjointWith")).toBeDefined(); - expect(screen.getByText("seeAlso")).toBeDefined(); + expect(screen.getByText("rdfs:seeAlso")).toBeDefined(); }); it("collapses legend when clicked again", () => { diff --git a/__tests__/components/graph/OntologyNode.test.tsx b/__tests__/components/graph/OntologyNode.test.tsx index d6c39fa4..d9cf71cc 100644 --- a/__tests__/components/graph/OntologyNode.test.tsx +++ b/__tests__/components/graph/OntologyNode.test.tsx @@ -49,71 +49,51 @@ describe("OntologyNode", () => { expect(screen.getByTestId("handle-source")).toBeDefined(); }); - it("calls onNavigate on click for non-external nodes", () => { - const onNavigate = vi.fn(); - render( - - ); - fireEvent.click(screen.getByRole("button")); - expect(onNavigate).toHaveBeenCalledWith("node-1"); - }); + // Mouse interactions are handled by ReactFlow at the parent (onNodeClick / + // onNodeDoubleClick), not on the inner div, to avoid double-firing. The + // component therefore exposes ONLY keyboard handlers. - it("does not call onNavigate for external node type", () => { + it("Enter keydown calls onExpandNode (parity with single-click expand)", () => { + const onExpandNode = vi.fn(); const onNavigate = vi.fn(); render( - + ); - fireEvent.click(screen.getByRole("button")); + fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); + expect(onExpandNode).toHaveBeenCalledWith("node-1"); expect(onNavigate).not.toHaveBeenCalled(); }); - it("calls onExpandNode on double-click for unexplored nodes", () => { + it("Space keydown calls onExpandNode (parity with single-click expand)", () => { const onExpandNode = vi.fn(); render( - + ); - fireEvent.doubleClick(screen.getByRole("button")); + fireEvent.keyDown(screen.getByRole("button"), { key: " " }); expect(onExpandNode).toHaveBeenCalledWith("node-1"); }); - it("does not call onExpandNode on double-click for non-unexplored nodes", () => { + it("Shift+Enter calls onNavigate (parity with double-click navigate)", () => { + const onNavigate = vi.fn(); const onExpandNode = vi.fn(); render( - + ); - fireEvent.doubleClick(screen.getByRole("button")); + fireEvent.keyDown(screen.getByRole("button"), { key: "Enter", shiftKey: true }); + expect(onNavigate).toHaveBeenCalledWith("node-1"); expect(onExpandNode).not.toHaveBeenCalled(); }); - it("handles Enter keydown for unexplored node", () => { + it("does not invoke handlers for external node type", () => { const onExpandNode = vi.fn(); - render( - - ); - fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); - expect(onExpandNode).toHaveBeenCalledWith("node-1"); - }); - - it("handles Enter keydown for regular node (calls onNavigate)", () => { const onNavigate = vi.fn(); render( - + ); fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" }); - expect(onNavigate).toHaveBeenCalledWith("node-1"); - }); - - it("handles Space keydown", () => { - const onNavigate = vi.fn(); - render( - - ); - fireEvent.keyDown(screen.getByRole("button"), { key: " " }); - expect(onNavigate).toHaveBeenCalledWith("node-1"); + fireEvent.keyDown(screen.getByRole("button"), { key: "Enter", shiftKey: true }); + expect(onExpandNode).not.toHaveBeenCalled(); + expect(onNavigate).not.toHaveBeenCalled(); }); it("shows child count when provided and > 0", () => { @@ -162,19 +142,29 @@ describe("OntologyNode", () => { expect(label.className).toContain("line-through"); }); - it("sets aria-label with expand hint for unexplored", () => { - render( - - ); + it("sets aria-label with keyboard hint for non-external nodes", () => { + render(); const btn = screen.getByRole("button"); - expect(btn.getAttribute("aria-label")).toBe( - "TestClass (click to expand)" - ); + expect(btn.getAttribute("aria-label")).toContain("TestClass"); + expect(btn.getAttribute("aria-label")).toContain("Enter to expand"); + expect(btn.getAttribute("aria-label")).toContain("Shift+Enter to navigate"); }); - it("sets aria-label without expand hint for regular nodes", () => { - render(); + it("sets bare aria-label for external nodes", () => { + render(); const btn = screen.getByRole("button"); expect(btn.getAttribute("aria-label")).toBe("TestClass"); }); + + it("uses Top/Bottom handle positions for TB layout (default)", () => { + render(); + expect(screen.getByTestId("handle-target").getAttribute("data-position")).toBe("top"); + expect(screen.getByTestId("handle-source").getAttribute("data-position")).toBe("bottom"); + }); + + it("uses Left/Right handle positions for LR layout", () => { + render(); + expect(screen.getByTestId("handle-target").getAttribute("data-position")).toBe("left"); + expect(screen.getByTestId("handle-source").getAttribute("data-position")).toBe("right"); + }); }); diff --git a/__tests__/lib/api/graph.test.ts b/__tests__/lib/api/graph.test.ts new file mode 100644 index 00000000..b4cd9a56 --- /dev/null +++ b/__tests__/lib/api/graph.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { graphApi } from "@/lib/api/graph"; + +// Mock the api client +vi.mock("@/lib/api/client", () => ({ + api: { + get: vi.fn(), + }, +})); + +import { api } from "@/lib/api/client"; +const mockGet = vi.mocked(api.get); + +describe("graphApi", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getEntityGraph", () => { + it("calls api.get with correct project-scoped URL", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + await graphApi.getEntityGraph("proj-1", "urn:test:Class1"); + + expect(mockGet).toHaveBeenCalledWith( + `/api/v1/projects/proj-1/ontology/classes/graph`, + expect.any(Object), + ); + }); + + it("passes class IRI as query param", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + const iri = "http://example.org/ontology#MyClass"; + await graphApi.getEntityGraph("proj-1", iri); + + const config = mockGet.mock.calls[0]?.[1] as { params: Record }; + expect(config.params.class_iri).toBe(iri); + }); + + it("passes branch as query param", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + await graphApi.getEntityGraph("proj-1", "urn:c", { branch: "dev" }); + + const config = mockGet.mock.calls[0]?.[1] as { params: Record }; + expect(config.params.branch).toBe("dev"); + }); + + it("passes depth parameters as query params", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + await graphApi.getEntityGraph("proj-1", "urn:c", { + ancestorsDepth: 3, + descendantsDepth: 1, + }); + + const config = mockGet.mock.calls[0]?.[1] as { params: Record }; + expect(config.params.ancestors_depth).toBe(3); + expect(config.params.descendants_depth).toBe(1); + }); + + it("passes maxNodes and includeSeeAlso params", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + await graphApi.getEntityGraph("proj-1", "urn:c", { + maxNodes: 100, + includeSeeAlso: false, + }); + + const config = mockGet.mock.calls[0]?.[1] as { params: Record }; + expect(config.params.max_nodes).toBe(100); + expect(config.params.include_see_also).toBe(false); + }); + + it("leaves optional params undefined when not provided", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + await graphApi.getEntityGraph("proj-1", "urn:c"); + + const config = mockGet.mock.calls[0]?.[1] as { params: Record }; + expect(config.params.branch).toBeUndefined(); + expect(config.params.ancestors_depth).toBeUndefined(); + expect(config.params.descendants_depth).toBeUndefined(); + expect(config.params.max_nodes).toBeUndefined(); + expect(config.params.include_see_also).toBeUndefined(); + }); + + it("returns the API response", async () => { + const mockResponse = { + focus_iri: "urn:c", + focus_label: "C", + nodes: [{ id: "urn:c", label: "C" }], + edges: [], + truncated: false, + total_concept_count: 1, + }; + mockGet.mockResolvedValue(mockResponse); + + const result = await graphApi.getEntityGraph("proj-1", "urn:c"); + expect(result).toEqual(mockResponse); + }); + + it("sends Authorization header when token is provided", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + await graphApi.getEntityGraph("proj-1", "urn:c", {}, "my-token"); + + const config = mockGet.mock.calls[0]?.[1] as { headers?: Record }; + expect(config.headers?.Authorization).toBe("Bearer my-token"); + }); + + it("omits Authorization header when no token is provided", async () => { + mockGet.mockResolvedValue({ nodes: [], edges: [] }); + + await graphApi.getEntityGraph("proj-1", "urn:c"); + + const config = mockGet.mock.calls[0]?.[1] as { headers?: Record }; + expect(config.headers).toBeUndefined(); + }); + + it("propagates API errors", async () => { + mockGet.mockRejectedValue(new Error("Network error")); + + await expect( + graphApi.getEntityGraph("proj-1", "urn:c"), + ).rejects.toThrow("Network error"); + }); + }); +}); diff --git a/__tests__/lib/graph/buildGraphData.test.ts b/__tests__/lib/graph/buildGraphData.test.ts deleted file mode 100644 index 783b2533..00000000 --- a/__tests__/lib/graph/buildGraphData.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getSeeAlsoIris, - extractTreeLabelMap, - buildGraphFromClassDetail, -} from "@/lib/graph/buildGraphData"; -import type { OWLClassDetail } from "@/lib/api/client"; -import type { ClassTreeNode } from "@/lib/ontology/types"; - -/** Helper to build a minimal OWLClassDetail for testing. */ -function makeClassDetail(overrides: Partial = {}): OWLClassDetail { - return { - iri: "urn:test", - labels: [], - comments: [], - deprecated: false, - parent_iris: [], - parent_labels: {}, - equivalent_iris: [], - disjoint_iris: [], - child_count: 0, - instance_count: 0, - is_defined: false, - annotations: [], - ...overrides, - }; -} - -// ─── getSeeAlsoIris ───────────────────────────────────────────── - -describe("getSeeAlsoIris", () => { - it("extracts seeAlso HTTP/HTTPS IRIs from annotations", () => { - const detail = makeClassDetail({ - annotations: [ - { - property_iri: "http://www.w3.org/2000/01/rdf-schema#seeAlso", - property_label: "seeAlso", - values: [ - { value: "https://example.org/related", lang: "" }, - { value: "http://example.org/other", lang: "" }, - ], - }, - ], - }); - - expect(getSeeAlsoIris(detail)).toEqual([ - "https://example.org/related", - "http://example.org/other", - ]); - }); - - it("ignores non-URL values", () => { - const detail = makeClassDetail({ - annotations: [ - { - property_iri: "http://www.w3.org/2000/01/rdf-schema#seeAlso", - property_label: "seeAlso", - values: [ - { value: "just some text", lang: "" }, - { value: "urn:not-http", lang: "" }, - ], - }, - ], - }); - - expect(getSeeAlsoIris(detail)).toEqual([]); - }); - - it("returns empty array when no seeAlso annotations", () => { - const detail = makeClassDetail({ - annotations: [ - { - property_iri: "http://www.w3.org/2000/01/rdf-schema#label", - property_label: "label", - values: [{ value: "Test", lang: "en" }], - }, - ], - }); - - expect(getSeeAlsoIris(detail)).toEqual([]); - }); -}); - -// ─── extractTreeLabelMap ──────────────────────────────────────── - -describe("extractTreeLabelMap", () => { - it("builds IRI→label map from flat list", () => { - const nodes: ClassTreeNode[] = [ - { - iri: "urn:a", - label: "Alpha", - children: [], - isExpanded: false, - isLoading: false, - hasChildren: false, - }, - { - iri: "urn:b", - label: "Beta", - children: [], - isExpanded: false, - isLoading: false, - hasChildren: false, - }, - ]; - - const map = extractTreeLabelMap(nodes); - - expect(map.get("urn:a")).toBe("Alpha"); - expect(map.get("urn:b")).toBe("Beta"); - expect(map.size).toBe(2); - }); - - it("walks nested children recursively", () => { - const nodes: ClassTreeNode[] = [ - { - iri: "urn:parent", - label: "Parent", - children: [ - { - iri: "urn:child", - label: "Child", - children: [ - { - iri: "urn:grandchild", - label: "GrandChild", - children: [], - isExpanded: false, - isLoading: false, - hasChildren: false, - }, - ], - isExpanded: false, - isLoading: false, - hasChildren: true, - }, - ], - isExpanded: false, - isLoading: false, - hasChildren: true, - }, - ]; - - const map = extractTreeLabelMap(nodes); - - expect(map.size).toBe(3); - expect(map.get("urn:grandchild")).toBe("GrandChild"); - }); - - it("skips nodes without labels", () => { - const nodes: ClassTreeNode[] = [ - { - iri: "urn:labelled", - label: "Has Label", - children: [], - isExpanded: false, - isLoading: false, - hasChildren: false, - }, - { - iri: "urn:unlabelled", - label: "", - children: [], - isExpanded: false, - isLoading: false, - hasChildren: false, - }, - ]; - - const map = extractTreeLabelMap(nodes); - - expect(map.has("urn:labelled")).toBe(true); - expect(map.has("urn:unlabelled")).toBe(false); - }); -}); - -// ─── buildGraphFromClassDetail ────────────────────────────────── - -describe("buildGraphFromClassDetail", () => { - it("always includes the focus node in output", () => { - const focusIri = "urn:focus"; - const resolved = new Map([ - [focusIri, makeClassDetail({ iri: focusIri, labels: [{ value: "Focus", lang: "" }] })], - ]); - - const result = buildGraphFromClassDetail(focusIri, resolved); - - const focusNode = result.nodes.find((n) => n.id === focusIri); - expect(focusNode).toBeDefined(); - expect(focusNode!.nodeType).toBe("focus"); - }); - - it("preserves nodes connected by edges", () => { - const focusIri = "urn:focus"; - const parentIri = "urn:parent"; - const resolved = new Map([ - [ - focusIri, - makeClassDetail({ - iri: focusIri, - parent_iris: [parentIri], - labels: [{ value: "Focus", lang: "" }], - }), - ], - ]); - - const result = buildGraphFromClassDetail(focusIri, resolved); - - expect(result.nodes.some((n) => n.id === parentIri)).toBe(true); - expect(result.edges.some((e) => e.source === focusIri && e.target === parentIri)).toBe(true); - }); - - it("prunes nodes orphaned by child-cap filtering", () => { - const focusIri = "urn:focus"; - const parentIri = "urn:parent"; - - // Create a parent with more than 20 children (child-cap = 20) - // The focus + 20 other children = 21 total → last one gets pruned - const childDetails: [string, OWLClassDetail][] = []; - for (let i = 0; i < 21; i++) { - const childIri = `urn:child-${i}`; - childDetails.push([ - childIri, - makeClassDetail({ - iri: childIri, - parent_iris: [parentIri], - labels: [{ value: `Child ${i}`, lang: "" }], - }), - ]); - } - - // Insert focus AFTER the 21 siblings so it's not kept just by insertion order - const resolved = new Map([ - ...childDetails, - [ - focusIri, - makeClassDetail({ - iri: focusIri, - parent_iris: [parentIri], - labels: [{ value: "Focus", lang: "" }], - }), - ], - ]); - - const result = buildGraphFromClassDetail(focusIri, resolved); - - // At most 20 capped siblings + the focus edge (always preserved) - const subClassEdges = result.edges.filter( - (e) => e.target === parentIri && e.edgeType === "subClassOf", - ); - expect(subClassEdges.length).toBeLessThanOrEqual(21); - - // Pruned children should not appear in nodes - const nodeIds = new Set(result.nodes.map((n) => n.id)); - const orphanedChildren = childDetails - .map(([iri]) => iri) - .filter((iri) => !subClassEdges.some((e) => e.source === iri)); - - for (const orphan of orphanedChildren) { - // Orphaned children (not connected by any edge) are pruned unless they're the focus - if (orphan !== focusIri) { - expect(nodeIds.has(orphan)).toBe(false); - } - } - - // The focus node's edge to its parent must survive the cap - expect( - result.edges.some((e) => e.source === focusIri && e.target === parentIri), - ).toBe(true); - }); - - it("excludes owl:Thing from nodes and edges", () => { - const focusIri = "urn:focus"; - const owlThing = "http://www.w3.org/2002/07/owl#Thing"; - - const resolved = new Map([ - [ - focusIri, - makeClassDetail({ - iri: focusIri, - parent_iris: [owlThing], - labels: [{ value: "Focus", lang: "" }], - }), - ], - ]); - - const result = buildGraphFromClassDetail(focusIri, resolved); - - expect(result.nodes.some((n) => n.id === owlThing)).toBe(false); - expect(result.edges.some((e) => e.source === owlThing || e.target === owlThing)).toBe(false); - }); -}); diff --git a/__tests__/lib/graph/elkLayout.test.ts b/__tests__/lib/graph/elkLayout.test.ts deleted file mode 100644 index b74cb7d5..00000000 --- a/__tests__/lib/graph/elkLayout.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; - -// Define the mock fn at module scope so it's available when the factory runs -const mockLayout = vi.fn(); - -vi.mock("elkjs/lib/elk.bundled.js", () => ({ - default: class MockELK { - layout(...args: unknown[]) { - return mockLayout(...args); - } - }, -})); - -import { computeLayout } from "@/lib/graph/elkLayout"; -import type { - OntologyGraphNode, - OntologyGraphEdge, -} from "@/lib/graph/types"; - -describe("computeLayout", () => { - beforeEach(() => { - mockLayout.mockReset(); - }); - - it("returns positions for each node", async () => { - const nodes: OntologyGraphNode[] = [ - { id: "A", label: "A", nodeType: "class" }, - { id: "B", label: "B", nodeType: "class" }, - ]; - const edges: OntologyGraphEdge[] = [ - { id: "e1", source: "A", target: "B", edgeType: "subClassOf" }, - ]; - - mockLayout.mockResolvedValueOnce({ - children: [ - { id: "A", x: 10, y: 20 }, - { id: "B", x: 30, y: 40 }, - ], - }); - - const positions = await computeLayout(nodes, edges); - expect(positions.get("A")).toEqual({ x: 10, y: 20 }); - expect(positions.get("B")).toEqual({ x: 30, y: 40 }); - }); - - it("defaults to 0 when coordinates are undefined", async () => { - const nodes: OntologyGraphNode[] = [ - { id: "A", label: "A", nodeType: "class" }, - ]; - - mockLayout.mockResolvedValueOnce({ - children: [{ id: "A", x: undefined, y: undefined }], - }); - - const positions = await computeLayout(nodes, []); - expect(positions.get("A")).toEqual({ x: 0, y: 0 }); - }); - - it("returns empty map when no children", async () => { - mockLayout.mockResolvedValueOnce({ children: undefined }); - - const positions = await computeLayout([], []); - expect(positions.size).toBe(0); - }); - - it("passes correct direction for TB", async () => { - mockLayout.mockResolvedValueOnce({ children: [] }); - - await computeLayout([], [], "TB"); - - const graph = mockLayout.mock.calls[0][0]; - expect(graph.layoutOptions["elk.direction"]).toBe("UP"); - }); - - it("passes correct direction for LR", async () => { - mockLayout.mockResolvedValueOnce({ children: [] }); - - await computeLayout([], [], "LR"); - - const graph = mockLayout.mock.calls[0][0]; - expect(graph.layoutOptions["elk.direction"]).toBe("LEFT"); - }); - - it("propagates ELK layout errors", async () => { - mockLayout.mockRejectedValueOnce(new Error("ELK layout failed")); - - await expect(computeLayout([], [])).rejects.toThrow("ELK layout failed"); - }); - - it("maps node and edge properties to ELK format", async () => { - const nodes: OntologyGraphNode[] = [ - { id: "N1", label: "Node 1", nodeType: "focus" }, - ]; - const edges: OntologyGraphEdge[] = [ - { id: "E1", source: "N1", target: "N1", edgeType: "subClassOf" }, - ]; - - mockLayout.mockResolvedValueOnce({ children: [{ id: "N1", x: 0, y: 0 }] }); - - await computeLayout(nodes, edges); - - const graph = mockLayout.mock.calls[0][0]; - expect(graph.children[0]).toMatchObject({ - id: "N1", - width: 180, - height: 44, - }); - expect(graph.edges[0]).toMatchObject({ - id: "E1", - sources: ["N1"], - targets: ["N1"], - }); - }); -}); diff --git a/__tests__/lib/graph/useELKLayout.test.ts b/__tests__/lib/graph/useELKLayout.test.ts new file mode 100644 index 00000000..f4c3d2b6 --- /dev/null +++ b/__tests__/lib/graph/useELKLayout.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useELKLayout } from "@/lib/graph/useELKLayout"; +import type { EntityGraphResponse } from "@/lib/api/graph"; + +// Hook for deferred layout tests — when set, layout() returns this promise instead +let deferredLayout: { + resolve: (v: unknown) => void; + promise: Promise; +} | null = null; + +// Capture the last ELK layout call for assertions +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let lastElkLayoutCall: any = null; + +// Mock elkjs to avoid loading the actual WASM module +vi.mock("elkjs/lib/elk.bundled.js", () => ({ + default: class MockELK { + async layout(graph: { children: Array<{ id: string; width: number; height: number }>; edges: Array<{ id: string }> }) { + lastElkLayoutCall = graph; + const result = { + children: graph.children.map((child, idx) => ({ + ...child, + x: idx * 200, + y: idx * 100, + })), + edges: graph.edges, + }; + if (deferredLayout) return deferredLayout.promise.then(() => result); + return result; + } + }, +})); + +function makeGraphResponse(nodeCount = 3): EntityGraphResponse { + const nodes = Array.from({ length: nodeCount }, (_, i) => ({ + id: `urn:node-${i}`, + label: `Node ${i}`, + iri: `urn:node-${i}`, + definition: null, + is_focus: i === 0, + is_root: i === nodeCount - 1, + depth: i, + node_type: i === 0 ? "focus" : "class", + child_count: nodeCount - i - 1, + })); + + const edges = Array.from({ length: nodeCount - 1 }, (_, i) => ({ + id: `subClassOf:urn:node-${i}:urn:node-${i + 1}`, + source: `urn:node-${i}`, + target: `urn:node-${i + 1}`, + edge_type: "subClassOf" as const, + label: null, + })); + + return { + focus_iri: "urn:node-0", + focus_label: "Node 0", + nodes, + edges, + truncated: false, + total_concept_count: nodeCount, + }; +} + +describe("useELKLayout", () => { + beforeEach(() => { + vi.clearAllMocks(); + deferredLayout = null; + lastElkLayoutCall = null; + }); + + it("starts with empty nodes and edges", () => { + const { result } = renderHook(() => useELKLayout()); + + expect(result.current.nodes).toEqual([]); + expect(result.current.edges).toEqual([]); + expect(result.current.isLayouting).toBe(false); + }); + + it("computes layout from graph response", async () => { + const data = makeGraphResponse(3); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + expect(result.current.nodes).toHaveLength(3); + expect(result.current.edges).toHaveLength(2); + expect(result.current.isLayouting).toBe(false); + }); + + it("assigns correct node type from backend data", async () => { + const data = makeGraphResponse(2); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + const focusNode = result.current.nodes.find((n) => n.id === "urn:node-0"); + expect(focusNode?.data.nodeType).toBe("focus"); + expect(focusNode?.type).toBe("ontologyNode"); + }); + + it("assigns correct edge type from backend data", async () => { + const data = makeGraphResponse(2); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + const edge = result.current.edges[0]; + expect(edge.data?.edgeType).toBe("subClassOf"); + expect(edge.type).toBe("ontologyEdge"); + }); + + it("positions nodes using ELK output", async () => { + const data = makeGraphResponse(2); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + // Mock positions x at idx*200, y at idx*100 + expect(result.current.nodes[0].position).toEqual({ x: 0, y: 0 }); + expect(result.current.nodes[1].position).toEqual({ x: 200, y: 100 }); + }); + + it("calculates dynamic node width from label length", async () => { + const data = makeGraphResponse(1); + const longLabel = "A very long label for testing width"; + data.nodes[0].label = longLabel; + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + expect(result.current.nodes).toHaveLength(1); + // Verify the formula: Math.max(180, label.length * 7.5 + 32) + const expectedWidth = Math.max(180, longLabel.length * 7.5 + 32); + expect(lastElkLayoutCall.children[0].width).toBe(expectedWidth); + }); + + it("adds arrowhead marker for subClassOf edges", async () => { + const data = makeGraphResponse(2); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + const edge = result.current.edges[0]; + expect(edge.markerEnd).toBeDefined(); + expect((edge.markerEnd as { type: string }).type).toBe("arrowclosed"); + }); + + it("does not add arrowhead marker for non-subClassOf edges", async () => { + const data = makeGraphResponse(2); + data.edges[0].edge_type = "seeAlso"; + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + const edge = result.current.edges[0]; + expect(edge.markerEnd).toBeUndefined(); + }); + + it("accepts LR direction parameter", async () => { + const data = makeGraphResponse(2); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data, "LR"); + }); + + expect(result.current.nodes).toHaveLength(2); + expect(result.current.edges).toHaveLength(1); + }); + + it("only applies results from the latest layout run", async () => { + const data1 = makeGraphResponse(2); + const data2 = makeGraphResponse(3); + const { result } = renderHook(() => useELKLayout()); + + // Start two layouts concurrently — only the second should apply + await act(async () => { + const run1 = result.current.runLayout(data1); + const run2 = result.current.runLayout(data2); + await Promise.all([run1, run2]); + }); + + // The second layout (3 nodes) should win + expect(result.current.nodes).toHaveLength(3); + }); + + it("sets isLayouting during layout computation", async () => { + let resolve!: (v: unknown) => void; + deferredLayout = { + promise: new Promise((r) => { resolve = r; }), + resolve: null!, + }; + deferredLayout.resolve = resolve; + + const data = makeGraphResponse(2); + const { result } = renderHook(() => useELKLayout()); + + expect(result.current.isLayouting).toBe(false); + + let layoutPromise: Promise; + act(() => { + layoutPromise = result.current.runLayout(data); + }); + + // While the deferred promise is pending, isLayouting should be true + expect(result.current.isLayouting).toBe(true); + + // Resolve the layout and wait for completion + await act(async () => { + deferredLayout!.resolve(undefined); + await layoutPromise!; + }); + + expect(result.current.isLayouting).toBe(false); + deferredLayout = null; + }); + + it("assigns ontologyEdge type to all edges", async () => { + const data = makeGraphResponse(3); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + for (const edge of result.current.edges) { + expect(edge.type).toBe("ontologyEdge"); + } + }); + + it("assigns ontologyNode type to all nodes", async () => { + const data = makeGraphResponse(3); + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + for (const node of result.current.nodes) { + expect(node.type).toBe("ontologyNode"); + } + }); + + it("preserves secondary_root node_type as distinct type", async () => { + const data = makeGraphResponse(2); + data.nodes[1].node_type = "secondary_root"; + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + expect(result.current.nodes[1].data.nodeType).toBe("secondary_root"); + }); + + it("pins root and secondary_root nodes to the first ELK layer", async () => { + const data = makeGraphResponse(3); + data.nodes[1].node_type = "root"; + data.nodes[2].node_type = "secondary_root"; + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + // Check that the ELK layout call included layerConstraint for root nodes + const rootElkNode = lastElkLayoutCall.children.find((c: { id: string }) => c.id === "urn:node-1"); + const secRootElkNode = lastElkLayoutCall.children.find((c: { id: string }) => c.id === "urn:node-2"); + expect(rootElkNode.layoutOptions?.["elk.layered.layering.layerConstraint"]).toBe("FIRST"); + expect(secRootElkNode.layoutOptions?.["elk.layered.layering.layerConstraint"]).toBe("FIRST"); + + // Non-root node should NOT have the constraint + const focusElkNode = lastElkLayoutCall.children.find((c: { id: string }) => c.id === "urn:node-0"); + expect(focusElkNode.layoutOptions).toBeUndefined(); + }); + + it("falls back to class for unknown node_type", async () => { + const data = makeGraphResponse(2); + data.nodes[1].node_type = "bogus_type"; + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + expect(result.current.nodes[1].data.nodeType).toBe("class"); + }); + + it("falls back to subClassOf for unknown edge_type", async () => { + const data = makeGraphResponse(2); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (data.edges[0] as any).edge_type = "unknownRelation"; + const { result } = renderHook(() => useELKLayout()); + + await act(async () => { + await result.current.runLayout(data); + }); + + expect(result.current.edges[0].data!.edgeType).toBe("subClassOf"); + }); +}); diff --git a/__tests__/lib/graph/utils.test.ts b/__tests__/lib/graph/utils.test.ts new file mode 100644 index 00000000..515d2a76 --- /dev/null +++ b/__tests__/lib/graph/utils.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { extractTreeLabelMap } from "@/lib/graph/utils"; +import type { ClassTreeNode } from "@/lib/ontology/types"; + +describe("extractTreeLabelMap", () => { + it("builds IRI→label map from flat list", () => { + const nodes: ClassTreeNode[] = [ + { + iri: "urn:a", + label: "Alpha", + children: [], + isExpanded: false, + isLoading: false, + hasChildren: false, + }, + { + iri: "urn:b", + label: "Beta", + children: [], + isExpanded: false, + isLoading: false, + hasChildren: false, + }, + ]; + + const map = extractTreeLabelMap(nodes); + + expect(map.get("urn:a")).toBe("Alpha"); + expect(map.get("urn:b")).toBe("Beta"); + expect(map.size).toBe(2); + }); + + it("walks nested children recursively", () => { + const nodes: ClassTreeNode[] = [ + { + iri: "urn:parent", + label: "Parent", + children: [ + { + iri: "urn:child", + label: "Child", + children: [ + { + iri: "urn:grandchild", + label: "GrandChild", + children: [], + isExpanded: false, + isLoading: false, + hasChildren: false, + }, + ], + isExpanded: false, + isLoading: false, + hasChildren: true, + }, + ], + isExpanded: false, + isLoading: false, + hasChildren: true, + }, + ]; + + const map = extractTreeLabelMap(nodes); + + expect(map.size).toBe(3); + expect(map.get("urn:grandchild")).toBe("GrandChild"); + }); + + it("skips nodes without labels", () => { + const nodes: ClassTreeNode[] = [ + { + iri: "urn:labelled", + label: "Has Label", + children: [], + isExpanded: false, + isLoading: false, + hasChildren: false, + }, + { + iri: "urn:unlabelled", + label: "", + children: [], + isExpanded: false, + isLoading: false, + hasChildren: false, + }, + ]; + + const map = extractTreeLabelMap(nodes); + + expect(map.has("urn:labelled")).toBe(true); + expect(map.has("urn:unlabelled")).toBe(false); + }); + + it("returns empty map for empty input", () => { + const map = extractTreeLabelMap([]); + expect(map.size).toBe(0); + }); +}); diff --git a/__tests__/lib/hooks/useGraphData.test.ts b/__tests__/lib/hooks/useGraphData.test.ts index 33410ce5..552cffc2 100644 --- a/__tests__/lib/hooks/useGraphData.test.ts +++ b/__tests__/lib/hooks/useGraphData.test.ts @@ -1,221 +1,413 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import type { ReactNode } from "react"; import { useGraphData } from "@/lib/hooks/useGraphData"; +import { graphApi, type EntityGraphResponse } from "@/lib/api/graph"; -// Mock API and graph builder -vi.mock("@/lib/api/client", () => ({ - projectOntologyApi: { - getClassDetail: vi.fn(), - getClassAncestors: vi.fn(), - searchEntities: vi.fn(), +vi.mock("@/lib/api/graph", () => ({ + graphApi: { + getEntityGraph: vi.fn(), }, })); -vi.mock("@/lib/graph/buildGraphData", () => ({ - buildGraphFromClassDetail: vi.fn(), - getSeeAlsoIris: vi.fn().mockReturnValue([]), -})); - -vi.mock("@/lib/utils", () => ({ - getLocalName: vi.fn((iri: string) => { - const hash = iri.lastIndexOf("#"); - if (hash >= 0) return iri.slice(hash + 1); - const slash = iri.lastIndexOf("/"); - if (slash >= 0) return iri.slice(slash + 1); - return iri; - }), -})); +const mockGetEntityGraph = vi.mocked(graphApi.getEntityGraph); -import { projectOntologyApi } from "@/lib/api/client"; -import { buildGraphFromClassDetail } from "@/lib/graph/buildGraphData"; - -const mockedGetClassDetail = projectOntologyApi.getClassDetail as ReturnType; -const mockedGetClassAncestors = projectOntologyApi.getClassAncestors as ReturnType; -const mockedBuildGraph = buildGraphFromClassDetail as ReturnType; +function createWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const Wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children); + Wrapper.displayName = "QueryWrapper"; + return Wrapper; +} -function makeDetail(iri: string, parentIris: string[] = []) { +function makeGraphResponse(overrides: Partial = {}): EntityGraphResponse { return { - iri, - labels: [{ value: iri.split("/").pop() || iri, lang: "en" }], - comments: [], - parent_iris: parentIris, - annotations: [], - deprecated: false, - equivalent_iris: [], - disjoint_iris: [], + focus_iri: "urn:focus", + focus_label: "Focus", + nodes: [ + { + id: "urn:focus", + label: "Focus", + iri: "urn:focus", + definition: null, + is_focus: true, + is_root: false, + depth: 0, + node_type: "focus", + child_count: 2, + }, + { + id: "urn:parent", + label: "Parent", + iri: "urn:parent", + definition: null, + is_focus: false, + is_root: true, + depth: 1, + node_type: "root", + child_count: 5, + }, + ], + edges: [ + { + id: "subClassOf:urn:focus:urn:parent", + source: "urn:focus", + target: "urn:parent", + edge_type: "subClassOf", + label: null, + }, + ], + truncated: false, + total_concept_count: 2, + ...overrides, }; } -beforeEach(() => { - vi.clearAllMocks(); - mockedBuildGraph.mockReturnValue({ - nodes: [{ id: "http://example.org/A", label: "A", nodeType: "focus" }], - edges: [], +describe("useGraphData", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); - mockedGetClassAncestors.mockResolvedValue({ nodes: [] }); -}); -describe("useGraphData", () => { it("returns null graphData when focusIri is null", () => { - const { result } = renderHook(() => - useGraphData({ - focusIri: null, - projectId: "proj-1", - accessToken: "token", - }), + const { result } = renderHook( + () => useGraphData({ focusIri: null, projectId: "proj-1", accessToken: "tok" }), + { wrapper: createWrapper() }, ); expect(result.current.graphData).toBeNull(); expect(result.current.isLoading).toBe(false); + expect(mockGetEntityGraph).not.toHaveBeenCalled(); }); - it("returns null graphData when accessToken is missing", () => { - const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - }), + it("does not fetch when accessToken is missing", () => { + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1" }), + { wrapper: createWrapper() }, ); expect(result.current.graphData).toBeNull(); + expect(mockGetEntityGraph).not.toHaveBeenCalled(); }); - it("fetches focus node and builds graph", async () => { - const detail = makeDetail("http://example.org/A"); - mockedGetClassDetail.mockResolvedValue(detail); + it("fetches graph data when focusIri and accessToken are provided", async () => { + const response = makeGraphResponse(); + mockGetEntityGraph.mockResolvedValue(response); - const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - accessToken: "token", - }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1", branch: "main", accessToken: "tok" }), + { wrapper: createWrapper() }, ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => { + expect(result.current.graphData).toEqual(response); + }); - expect(mockedGetClassDetail).toHaveBeenCalledWith( + expect(mockGetEntityGraph).toHaveBeenCalledWith( "proj-1", - "http://example.org/A", - "token", - undefined, + "urn:focus", + expect.objectContaining({ ancestorsDepth: 5, descendantsDepth: 1 }), + "tok", ); - expect(mockedBuildGraph).toHaveBeenCalled(); - expect(result.current.graphData).not.toBeNull(); }); - it("fetches parent nodes at depth 1", async () => { - const detailA = makeDetail("http://example.org/A", ["http://example.org/B"]); - const detailB = makeDetail("http://example.org/B"); - - mockedGetClassDetail - .mockResolvedValueOnce(detailA) // focus node - .mockResolvedValueOnce(detailB); // parent node + it("passes descendantsDepth=2 when showAllDescendants is toggled", async () => { + const response = makeGraphResponse(); + mockGetEntityGraph.mockResolvedValue(response); - const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - accessToken: "token", - }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1", accessToken: "tok" }), + { wrapper: createWrapper() }, ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => { + expect(result.current.graphData).not.toBeNull(); + }); - expect(mockedGetClassDetail).toHaveBeenCalledTimes(2); + act(() => { + result.current.setShowAllDescendants(true); + }); + + await waitFor(() => { + expect(mockGetEntityGraph).toHaveBeenCalledWith("proj-1", "urn:focus", { + branch: undefined, + ancestorsDepth: 5, + descendantsDepth: 2, + }, "tok"); + }); }); - it("handles fetch errors for focus node gracefully", async () => { - mockedGetClassDetail.mockRejectedValue(new Error("Not found")); + it("sets graphData to null on API error", async () => { + mockGetEntityGraph.mockRejectedValue(new Error("Network error")); - const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - accessToken: "token", - }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1", accessToken: "tok" }), + { wrapper: createWrapper() }, ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); - // Should still build a graph (single-node graph fallback) - expect(mockedBuildGraph).toHaveBeenCalled(); + expect(result.current.graphData).toBeNull(); }); - it("expandNode fetches detail and rebuilds graph", async () => { - const detailA = makeDetail("http://example.org/A"); - const detailB = makeDetail("http://example.org/B"); + it("merges expanded node data into existing graph", async () => { + const initial = makeGraphResponse(); + const expansion = makeGraphResponse({ + focus_iri: "urn:parent", + focus_label: "Parent", + nodes: [ + { + id: "urn:parent", + label: "Parent", + iri: "urn:parent", + definition: null, + is_focus: true, + is_root: true, + depth: 0, + node_type: "focus", + child_count: 5, + }, + { + id: "urn:grandparent", + label: "GrandParent", + iri: "urn:grandparent", + definition: null, + is_focus: false, + is_root: true, + depth: 1, + node_type: "root", + child_count: 0, + }, + ], + edges: [ + { + id: "subClassOf:urn:parent:urn:grandparent", + source: "urn:parent", + target: "urn:grandparent", + edge_type: "subClassOf", + label: null, + }, + ], + total_concept_count: 2, + }); - mockedGetClassDetail - .mockResolvedValueOnce(detailA) // initial load - .mockResolvedValueOnce(detailB); // expand node + mockGetEntityGraph + .mockResolvedValueOnce(initial) + .mockResolvedValueOnce(expansion); - const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - accessToken: "token", - }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1", accessToken: "tok" }), + { wrapper: createWrapper() }, ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - - mockedBuildGraph.mockClear(); + await waitFor(() => { + expect(result.current.graphData).not.toBeNull(); + }); - await act(async () => { - result.current.expandNode("http://example.org/B"); + act(() => { + result.current.expandNode("urn:parent"); }); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => { + const nodes = result.current.graphData!.nodes; + expect(nodes.some((n) => n.id === "urn:grandparent")).toBe(true); + }); - expect(mockedBuildGraph).toHaveBeenCalled(); + // Should not duplicate existing nodes + const nodeIds = result.current.graphData!.nodes.map((n) => n.id); + const parentCount = nodeIds.filter((id) => id === "urn:parent").length; + expect(parentCount).toBe(1); }); - it("resetGraph clears the graph data", async () => { - mockedGetClassDetail.mockResolvedValue( - makeDetail("http://example.org/A"), - ); + it("resetGraph clears data and re-fetches baseline", async () => { + const response = makeGraphResponse(); + mockGetEntityGraph.mockResolvedValue(response); - const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - accessToken: "token", - }), + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1", accessToken: "tok" }), + { wrapper: createWrapper() }, ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.graphData).not.toBeNull(); + await waitFor(() => { + expect(result.current.graphData).not.toBeNull(); + }); + + const callCountBefore = mockGetEntityGraph.mock.calls.length; act(() => { result.current.resetGraph(); }); expect(result.current.graphData).toBeNull(); - expect(result.current.resolvedCount).toBe(0); + + // Should re-fetch the baseline graph + await waitFor(() => { + expect(mockGetEntityGraph.mock.calls.length).toBeGreaterThan(callCountBefore); + expect(result.current.graphData).not.toBeNull(); + }); }); - it("passes branch to API calls", async () => { - mockedGetClassDetail.mockResolvedValue( - makeDetail("http://example.org/A"), + it("preserves truncated flag when merging expansion", async () => { + const initial = makeGraphResponse({ truncated: false }); + const expansion = makeGraphResponse({ + focus_iri: "urn:parent", + focus_label: "Parent", + truncated: true, + nodes: [ + { + id: "urn:new-node", + label: "New", + iri: "urn:new-node", + definition: null, + is_focus: false, + is_root: false, + depth: 1, + node_type: "class", + child_count: 0, + }, + ], + edges: [], + total_concept_count: 1, + }); + + mockGetEntityGraph + .mockResolvedValueOnce(initial) + .mockResolvedValueOnce(expansion); + + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1", accessToken: "tok" }), + { wrapper: createWrapper() }, ); - const { result } = renderHook(() => - useGraphData({ - focusIri: "http://example.org/A", - projectId: "proj-1", - accessToken: "token", - branch: "dev", - }), + await waitFor(() => { + expect(result.current.graphData).not.toBeNull(); + }); + + act(() => { + result.current.expandNode("urn:parent"); + }); + + await waitFor(() => { + expect(result.current.graphData!.truncated).toBe(true); + }); + }); + + it("skips expandNode when node was already expanded", async () => { + const initial = makeGraphResponse(); + const expansion = makeGraphResponse({ + focus_iri: "urn:parent", + nodes: [ + { id: "urn:new", label: "New", iri: "urn:new", definition: null, is_focus: false, is_root: false, depth: 1, node_type: "class", child_count: 0 }, + ], + edges: [], + total_concept_count: 1, + }); + + mockGetEntityGraph + .mockResolvedValueOnce(initial) + .mockResolvedValueOnce(expansion); + + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1", accessToken: "tok" }), + { wrapper: createWrapper() }, ); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => { + expect(result.current.graphData).not.toBeNull(); + }); - expect(mockedGetClassDetail).toHaveBeenCalledWith( - "proj-1", - "http://example.org/A", - "token", - "dev", + const callsBefore = mockGetEntityGraph.mock.calls.length; + + // First expand + act(() => { result.current.expandNode("urn:parent"); }); + await waitFor(() => { + expect(mockGetEntityGraph.mock.calls.length).toBe(callsBefore + 1); + }); + + // Second expand of same node — should be skipped + act(() => { result.current.expandNode("urn:parent"); }); + expect(mockGetEntityGraph.mock.calls.length).toBe(callsBefore + 1); + }); + + it("expandNode does nothing when graphData is null", async () => { + mockGetEntityGraph.mockRejectedValue(new Error("fail")); + + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1", accessToken: "tok" }), + { wrapper: createWrapper() }, ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.graphData).toBeNull(); + const callsBefore = mockGetEntityGraph.mock.calls.length; + act(() => { result.current.expandNode("urn:parent"); }); + expect(mockGetEntityGraph.mock.calls.length).toBe(callsBefore); + }); + + it("reports resolvedCount from node count", async () => { + const response = makeGraphResponse(); + mockGetEntityGraph.mockResolvedValue(response); + + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1", accessToken: "tok" }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => { + expect(result.current.resolvedCount).toBe(2); + }); + }); + + it("discards stale expandNode response after resetGraph", async () => { + const initial = makeGraphResponse(); + let resolveExpansion!: (v: EntityGraphResponse) => void; + const expansionPromise = new Promise((r) => { resolveExpansion = r; }); + + mockGetEntityGraph + .mockResolvedValueOnce(initial) // initial fetch + .mockReturnValueOnce(expansionPromise); // deferred expansion + + const { result } = renderHook( + () => useGraphData({ focusIri: "urn:focus", projectId: "proj-1", accessToken: "tok" }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => { + expect(result.current.graphData).not.toBeNull(); + }); + + // Start expansion (returns deferred promise) + act(() => { result.current.expandNode("urn:parent"); }); + + // Reset graph before expansion resolves — this bumps the epoch + const resetResponse = makeGraphResponse({ focus_label: "Reset" }); + mockGetEntityGraph.mockResolvedValueOnce(resetResponse); + act(() => { result.current.resetGraph(); }); + + await waitFor(() => { + expect(result.current.graphData?.focus_label).toBe("Reset"); + }); + + // Now resolve the stale expansion — it should be discarded + const staleExpansion = makeGraphResponse({ + nodes: [ + { id: "urn:stale", label: "Stale", iri: "urn:stale", definition: null, is_focus: false, is_root: false, depth: 1, node_type: "class", child_count: 0 }, + ], + edges: [], + }); + await act(async () => { resolveExpansion(staleExpansion); }); + + // Stale node should NOT appear in the graph + expect(result.current.graphData!.nodes.some((n) => n.id === "urn:stale")).toBe(false); }); }); diff --git a/components/editor/developer/DeveloperEditorLayout.tsx b/components/editor/developer/DeveloperEditorLayout.tsx index ad19e4ae..afa3fdbd 100644 --- a/components/editor/developer/DeveloperEditorLayout.tsx +++ b/components/editor/developer/DeveloperEditorLayout.tsx @@ -28,7 +28,7 @@ import type { OntologySourceEditorRef } from "@/components/editor/OntologySource import type { IriPosition } from "@/lib/editor/indexWorker"; import { useDraftStore } from "@/lib/stores/draftStore"; import { getLocalName } from "@/lib/utils"; -import { extractTreeLabelMap } from "@/lib/graph/buildGraphData"; +import { extractTreeLabelMap } from "@/lib/graph/utils"; import { useAnnounce } from "@/components/ui/ScreenReaderAnnouncer"; const OntologySourceEditor = dynamic( @@ -378,9 +378,8 @@ export function DeveloperEditorLayout(props: DeveloperEditorLayoutProps) { { handleViewModeChange("tree"); navigateToNode(iri); diff --git a/components/editor/standard/StandardEditorLayout.tsx b/components/editor/standard/StandardEditorLayout.tsx index 9bbb3648..eaf941e1 100644 --- a/components/editor/standard/StandardEditorLayout.tsx +++ b/components/editor/standard/StandardEditorLayout.tsx @@ -13,7 +13,7 @@ import { ResizablePanelDivider } from "@/components/editor/ResizablePanelDivider import { EntityTreeToolbar } from "@/components/editor/shared/EntityTreeToolbar"; import { useTreeSearch } from "@/lib/hooks/useTreeSearch"; import { useFilteredTree } from "@/lib/hooks/useFilteredTree"; -import { Share2, ArrowLeft } from "lucide-react"; +import { Share2, ArrowLeft, Maximize2 } from "lucide-react"; import { DraggableTreeWrapper } from "@/components/editor/shared/DraggableTreeWrapper"; import { useTreeDragDrop, type DragMode } from "@/lib/hooks/useTreeDragDrop"; import { useToast } from "@/lib/context/ToastContext"; @@ -29,13 +29,18 @@ const OntologyGraph = dynamic( ), } ); + +const EntityGraphModal = dynamic( + () => import("@/components/graph/EntityGraphModal").then((mod) => mod.EntityGraphModal), + { ssr: false } +); import type { ClassUpdatePayload } from "@/lib/api/client"; import type { TurtlePropertyUpdateData } from "@/lib/ontology/turtlePropertyUpdater"; import type { TurtleIndividualUpdateData } from "@/lib/ontology/turtleIndividualUpdater"; import type { ClassTreeNode } from "@/lib/ontology/types"; import { useDraftStore } from "@/lib/stores/draftStore"; import { getLocalName } from "@/lib/utils"; -import { extractTreeLabelMap } from "@/lib/graph/buildGraphData"; +import { extractTreeLabelMap } from "@/lib/graph/utils"; import { useAnnounce } from "@/components/ui/ScreenReaderAnnouncer"; export interface StandardEditorLayoutProps { @@ -209,6 +214,7 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { // Graph view state const [showGraph, setShowGraph] = useState(false); + const [showGraphModal, setShowGraphModal] = useState(false); // Entity tab state const [activeTab, setActiveTab] = useState("classes"); @@ -373,14 +379,22 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { Back to Details +
+
{ setShowGraph(false); navigateToNode(iri); @@ -442,6 +456,23 @@ export function StandardEditorLayout(props: StandardEditorLayoutProps) { /> )}
+ + {/* Full-screen graph modal */} + {showGraphModal && selectedIri && ( + { + setShowGraphModal(false); + setShowGraph(false); + navigateToNode(iri); + }} + onClose={() => setShowGraphModal(false)} + /> + )}
); } diff --git a/components/graph/EntityGraphModal.tsx b/components/graph/EntityGraphModal.tsx new file mode 100644 index 00000000..9933f68b --- /dev/null +++ b/components/graph/EntityGraphModal.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { Suspense, lazy, useCallback, useEffect, useRef } from "react"; +import { X } from "lucide-react"; + +const OntologyGraph = lazy(() => + import("./OntologyGraph").then((m) => ({ default: m.OntologyGraph })), +); + +interface EntityGraphModalProps { + focusIri: string; + label: string; + projectId: string; + branch?: string; + accessToken?: string; + onNavigateToClass?: (iri: string) => void; + onClose: () => void; +} + +export function EntityGraphModal({ + focusIri, + label, + projectId, + branch, + accessToken, + onNavigateToClass, + onClose, +}: EntityGraphModalProps) { + const dialogRef = useRef(null); + const previousFocusRef = useRef(null); + + useEffect(() => { + previousFocusRef.current = document.activeElement; + + // Focus the first focusable element, or fall back to the dialog container + const firstFocusable = dialogRef.current?.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (firstFocusable) { + firstFocusable.focus(); + } else { + dialogRef.current?.focus(); + } + + return () => { + // Restore focus on unmount + if (previousFocusRef.current instanceof HTMLElement) { + previousFocusRef.current.focus(); + } + }; + }, []); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + return; + } + + // Trap focus within the modal + if (e.key === "Tab" && dialogRef.current) { + const focusable = dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + // When length === 1, first === last — both branches below keep focus on the single element + + if (e.shiftKey && (document.activeElement === first || document.activeElement === dialogRef.current)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + const handleNavigate = useCallback( + (iri: string) => { + onNavigateToClass?.(iri); + onClose(); + }, + [onNavigateToClass, onClose], + ); + + return ( +
+
+ {/* Header */} +
+
+ + + + + + + +

+ + Entity Graph + + | + {label} +

+
+ +
+ + {/* Body */} +
+ +
+ + Loading graph viewer... +
+
+ } + > + + +
+
+
+ ); +} diff --git a/components/graph/OntologyEdge.tsx b/components/graph/OntologyEdge.tsx index e8f27896..256fdad1 100644 --- a/components/graph/OntologyEdge.tsx +++ b/components/graph/OntologyEdge.tsx @@ -5,9 +5,11 @@ import { BaseEdge, EdgeLabelRenderer, getBezierPath, + getSmoothStepPath, type EdgeProps, } from "@xyflow/react"; import type { GraphEdgeType } from "@/lib/graph/types"; +import { useEditorModeStore } from "@/lib/stores/editorModeStore"; export interface OntologyEdgeData { [key: string]: unknown; @@ -40,9 +42,9 @@ const edgeTypeConfig: Record s.graphEdgeStyle); - const [edgePath, labelX, labelY] = getBezierPath({ + const pathBuilder = edgeStyle === "smoothstep" ? getSmoothStepPath : getBezierPath; + const [edgePath, labelX, labelY] = pathBuilder({ sourceX, sourceY, sourcePosition, diff --git a/components/graph/OntologyGraph.tsx b/components/graph/OntologyGraph.tsx index 2b8a4f77..a597be92 100644 --- a/components/graph/OntologyGraph.tsx +++ b/components/graph/OntologyGraph.tsx @@ -1,37 +1,39 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ReactFlow, + ReactFlowProvider, MiniMap, Controls, Background, BackgroundVariant, useNodesState, useEdgesState, + useReactFlow, type Node, type Edge, type ColorMode, + type NodeMouseHandler, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { ArrowDown, ArrowRight, ChevronDown, ChevronUp, Maximize2, RotateCcw } from "lucide-react"; +import { ArrowDown, ArrowRight, ChevronDown, ChevronUp, RotateCcw } from "lucide-react"; import { cn } from "@/lib/utils"; import { useGraphData } from "@/lib/hooks/useGraphData"; -import { computeLayout } from "@/lib/graph/elkLayout"; -import { OntologyNode, type OntologyNodeData } from "./OntologyNode"; -import { OntologyEdge, type OntologyEdgeData } from "./OntologyEdge"; +import { useELKLayout, NODE_WIDTH, NODE_HEIGHT, type LayoutDirection } from "@/lib/graph/useELKLayout"; +import { OntologyNode } from "./OntologyNode"; +import { OntologyEdge } from "./OntologyEdge"; interface OntologyGraphProps { focusIri: string | null; projectId: string; - accessToken?: string; branch?: string; + accessToken?: string; onNavigateToClass?: (iri: string) => void; - labelHints?: Map; } -const nodeTypes = { ontology: OntologyNode }; -const edgeTypes = { ontology: OntologyEdge }; +const nodeTypes = { ontologyNode: OntologyNode }; +const edgeTypes = { ontologyEdge: OntologyEdge }; function GraphLegend() { const [expanded, setExpanded] = useState(false); @@ -49,20 +51,19 @@ function GraphLegend() { {expanded && (
- {/* Node types */}
Nodes
- + +
- {/* Edge types */}
Edges
@@ -70,7 +71,7 @@ function GraphLegend() { - +
)} @@ -130,24 +131,41 @@ function LegendEdgeItem({ ); } -export function OntologyGraph({ +export function OntologyGraph(props: OntologyGraphProps) { + return ( + + + + ); +} + +function OntologyGraphInner({ focusIri, projectId, - accessToken, branch, + accessToken, onNavigateToClass, - labelHints, }: OntologyGraphProps) { - const { graphData, isLoading, expandNode, resetGraph, resolvedCount } = - useGraphData({ - focusIri, - projectId, - accessToken, - branch, - labelHints, - }); + const { setCenter } = useReactFlow(); + const { + graphData, + isLoading, + showAllDescendants, + setShowAllDescendants, + expandNode, + resetGraph, + resolvedCount, + } = useGraphData({ focusIri, projectId, branch, accessToken }); - const [direction, setDirection] = useState<"TB" | "LR">("TB"); + const [direction, setDirection] = useState("TB"); + const toggleDirection = useCallback(() => setDirection((d) => (d === "TB" ? "LR" : "TB")), []); + const { + nodes: layoutNodes, + edges: layoutEdges, + isLayouting, + runLayout, + setNodeHandlers, + } = useELKLayout(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [colorMode, setColorMode] = useState("light"); @@ -167,97 +185,81 @@ export function OntologyGraph({ return () => observer.disconnect(); }, []); - const handleNavigate = useCallback( - (iri: string) => { - onNavigateToClass?.(iri); - }, - [onNavigateToClass], - ); - - const handleExpandNode = useCallback( - (iri: string) => { - expandNode(iri); - }, - [expandNode], - ); - - // SVG marker definitions for arrows - const arrowMarker = useMemo( - () => ( - - - - - - - - ), - [], - ); - - // Apply layout when graph data changes + // Run ELK layout when data or direction changes useEffect(() => { if (!graphData || graphData.nodes.length === 0) { setNodes([]); setEdges([]); return; } + runLayout(graphData, direction).catch((err) => { + console.error("[OntologyGraph] ELK layout failed:", err); + }); + }, [graphData, direction, runLayout, setNodes, setEdges]); - let cancelled = false; - - async function applyLayout() { - const positions = await computeLayout( - graphData!.nodes, - graphData!.edges, - direction, - ); - if (cancelled) return; + // Keep node-level keyboard handlers in sync with current callbacks. Stored + // via ref inside useELKLayout, so this does NOT trigger a re-layout. + useEffect(() => { + setNodeHandlers({ + onNavigate: onNavigateToClass, + onExpandNode: expandNode, + }); + }, [setNodeHandlers, onNavigateToClass, expandNode]); - const flowNodes: Node[] = graphData!.nodes.map((n) => ({ - id: n.id, - type: "ontology", - position: positions.get(n.id) || { x: 0, y: 0 }, - data: { - label: n.label, - nodeType: n.nodeType, - deprecated: n.deprecated, - childCount: n.childCount, - isExpanded: n.isExpanded, - onNavigate: handleNavigate, - onExpandNode: handleExpandNode, - } as OntologyNodeData, - })); + // Sync layout results to React Flow state (skip if graph was cleared) + useEffect(() => { + if (!graphData || graphData.nodes.length === 0) return; + setNodes(layoutNodes); + setEdges(layoutEdges); + }, [layoutNodes, layoutEdges, setNodes, setEdges, graphData]); - const flowEdges: Edge[] = graphData!.edges.map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - type: "ontology", - data: { edgeType: e.edgeType } as OntologyEdgeData, - })); + // Track last expanded node to center viewport after layout + const lastExpandedIdRef = useRef(null); - setNodes(flowNodes); - setEdges(flowEdges); + // Center viewport on the expanded node after layout completes + useEffect(() => { + if (!lastExpandedIdRef.current || layoutNodes.length === 0) return; + const target = layoutNodes.find((n) => n.id === lastExpandedIdRef.current); + if (target) { + setCenter(target.position.x + NODE_WIDTH / 2, target.position.y + NODE_HEIGHT / 2, { duration: 300, zoom: 1 }); } + lastExpandedIdRef.current = null; + }, [layoutNodes, setCenter]); + + // Click-delay guard: distinguish single click (expand) from double click (navigate) + const clickTimerRef = useRef | null>(null); + + const handleNodeClick: NodeMouseHandler = useCallback( + (_event, node) => { + if (clickTimerRef.current) clearTimeout(clickTimerRef.current); + clickTimerRef.current = setTimeout(() => { + clickTimerRef.current = null; + lastExpandedIdRef.current = node.id; + expandNode(node.id); + }, 200); + }, + [expandNode], + ); + + const handleNodeDoubleClick: NodeMouseHandler = useCallback( + (_event, node) => { + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } + onNavigateToClass?.(node.id); + }, + [onNavigateToClass], + ); - applyLayout(); + // Clean up pending click timer on unmount + useEffect(() => { return () => { - cancelled = true; + if (clickTimerRef.current) clearTimeout(clickTimerRef.current); }; - }, [graphData, direction, handleNavigate, handleExpandNode, setNodes, setEdges]); - - const toggleDirection = useCallback(() => { - setDirection((prev) => (prev === "TB" ? "LR" : "TB")); }, []); + if (!focusIri) { return (
@@ -287,6 +289,18 @@ export function OntologyGraph({ )} {direction === "TB" ? "Top-Down" : "Left-Right"} + + {isLayouting && ( + + + Computing layout... + + )}
- {graphData?.nodes.length ?? 0} nodes, {graphData?.edges.length ?? 0} edges - {resolvedCount > 0 && ` (${resolvedCount} resolved)`} + {resolvedCount} nodes, {graphData?.edges.length ?? 0} edges + {graphData?.truncated && ( + + Truncated ({graphData.total_concept_count} discovered) + + )}
{/* Graph */}
- {arrowMarker} + + + + + + + {isLoading && (
-
+
+ + Loading entity graph... +
)} - {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> { - const graph = { - id: "root", - layoutOptions: { - "elk.algorithm": "layered", - "elk.direction": direction === "TB" ? "UP" : "LEFT", - "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", - "elk.spacing.nodeNode": "40", - "elk.layered.spacing.nodeNodeBetweenLayers": "80", - "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", - }, - children: nodes.map((n) => ({ - id: n.id, - width: NODE_WIDTH, - height: NODE_HEIGHT, - })), - edges: edges.map((e) => ({ - id: e.id, - sources: [e.source], - targets: [e.target], - })), - }; - - const layout = await elk.layout(graph); - const positions = new Map(); - - for (const child of layout.children || []) { - positions.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 }); - } - - return positions; -} diff --git a/lib/graph/types.ts b/lib/graph/types.ts index 8d4cc12c..3b3068d8 100644 --- a/lib/graph/types.ts +++ b/lib/graph/types.ts @@ -1,23 +1,16 @@ -export type GraphNodeType = "focus" | "class" | "root" | "individual" | "property" | "external" | "unexplored"; -export type GraphEdgeType = "subClassOf" | "equivalentClass" | "disjointWith" | "seeAlso"; - -export interface OntologyGraphNode { - id: string; - label: string; - nodeType: GraphNodeType; - deprecated?: boolean; - childCount?: number; - isExpanded?: boolean; -} +// String-literal union types shared between rendering layer (OntologyNode, +// OntologyEdge, useELKLayout) and the API client (`lib/api/graph.ts`). +// +// Mirror of the Python `GraphNodeType` / `GraphEdgeType` literals in +// `ontokit/schemas/graph.py` — keep in sync when adding new values. +export type GraphNodeType = + | "focus" + | "class" + | "root" + | "secondary_root" + | "individual" + | "property" + | "external" + | "unexplored"; -export interface OntologyGraphEdge { - id: string; - source: string; - target: string; - edgeType: GraphEdgeType; -} - -export interface GraphData { - nodes: OntologyGraphNode[]; - edges: OntologyGraphEdge[]; -} +export type GraphEdgeType = "subClassOf" | "equivalentClass" | "disjointWith" | "seeAlso"; diff --git a/lib/graph/useELKLayout.ts b/lib/graph/useELKLayout.ts new file mode 100644 index 00000000..dd5cdbff --- /dev/null +++ b/lib/graph/useELKLayout.ts @@ -0,0 +1,164 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; +import { MarkerType, type Node, type Edge } from "@xyflow/react"; +import type { EntityGraphResponse } from "@/lib/api/graph"; +import type { OntologyNodeData } from "@/components/graph/OntologyNode"; +import type { OntologyEdgeData } from "@/components/graph/OntologyEdge"; +import type { GraphNodeType, GraphEdgeType } from "@/lib/graph/types"; + +export type LayoutDirection = "TB" | "LR"; + +export interface NodeHandlers { + onNavigate?: (iri: string) => void; + onExpandNode?: (iri: string) => void; +} + +interface LayoutResult { + nodes: Node[]; + edges: Edge[]; + isLayouting: boolean; + runLayout: (data: EntityGraphResponse, direction?: LayoutDirection) => Promise; + /** Update keyboard/click handlers without re-running layout. Node data + * receives stable callbacks that read from a ref, so changing handlers + * won't bust React.memo on `OntologyNode`. */ + setNodeHandlers: (handlers: NodeHandlers) => void; +} + +export const NODE_WIDTH = 180; +export const NODE_HEIGHT = 44; + +const VALID_NODE_TYPES = new Set([ + "focus", "class", "root", "secondary_root", "individual", "property", "external", "unexplored", +]); + +function normalizeNodeType(raw: string | undefined): GraphNodeType { + if (raw && VALID_NODE_TYPES.has(raw as GraphNodeType)) return raw as GraphNodeType; + return "class"; +} + +const VALID_EDGE_TYPES = new Set([ + "subClassOf", "equivalentClass", "disjointWith", "seeAlso", +]); + +function normalizeEdgeType(raw: string | undefined): GraphEdgeType { + if (raw && VALID_EDGE_TYPES.has(raw as GraphEdgeType)) return raw as GraphEdgeType; + return "subClassOf"; +} + +export function useELKLayout(): LayoutResult { + const [nodes, setNodes] = useState[]>([]); + const [edges, setEdges] = useState[]>([]); + const [isLayouting, setIsLayouting] = useState(false); + const layoutRunRef = useRef(0); + + // Keyboard/click handlers live in a ref so they can be swapped without + // re-running layout or busting React.memo on OntologyNode. + const handlersRef = useRef({}); + const stableHandlers = useMemo( + () => ({ + onNavigate: (iri: string) => handlersRef.current.onNavigate?.(iri), + onExpandNode: (iri: string) => handlersRef.current.onExpandNode?.(iri), + }), + [], + ); + const setNodeHandlers = useCallback((h: NodeHandlers) => { + handlersRef.current = h; + }, []); + + const runLayout = useCallback( + async (data: EntityGraphResponse, direction: LayoutDirection = "TB") => { + const localRunId = ++layoutRunRef.current; + setIsLayouting(true); + + try { + const ELK = (await import("elkjs/lib/elk.bundled.js")).default; + const elk = new ELK(); + + const elkNodes = data.nodes.map((n) => ({ + id: n.id, + width: Math.max(NODE_WIDTH, n.label.length * 7.5 + 32), + height: NODE_HEIGHT, + // Pin root nodes to the first layer so they align at the top + ...(n.node_type === "root" || n.node_type === "secondary_root" + ? { layoutOptions: { "elk.layered.layering.layerConstraint": "FIRST" } } + : {}), + })); + + const elkEdges = data.edges.map((e) => ({ + id: e.id, + sources: [e.source], + targets: [e.target], + // seeAlso edges should not influence layering — mark as non-hierarchical + ...(e.edge_type === "seeAlso" + ? { layoutOptions: { "elk.layered.priority.direction": "0" } } + : {}), + })); + + const elkGraph = await elk.layout({ + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": direction === "TB" ? "DOWN" : "RIGHT", + "elk.spacing.nodeNode": "40", + "elk.layered.spacing.nodeNodeBetweenLayers": "70", + "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", + "elk.edgeRouting": "SPLINES", + "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", + "elk.layered.cycleBreaking.strategy": "DEPTH_FIRST", + "elk.separateConnectedComponents": "false", + }, + children: elkNodes, + edges: elkEdges, + }); + + const nodeMap = new Map(data.nodes.map((n) => [n.id, n])); + const layoutedNodes: Node[] = (elkGraph.children ?? []).map( + (elkNode) => { + const backendNode = nodeMap.get(elkNode.id)!; + return { + id: elkNode.id, + type: "ontologyNode", + position: { x: elkNode.x ?? 0, y: elkNode.y ?? 0 }, + data: { + label: backendNode.label, + nodeType: normalizeNodeType(backendNode.node_type), + childCount: backendNode.child_count ?? undefined, + deprecated: false, + isExpanded: false, + layoutDirection: direction, + onNavigate: stableHandlers.onNavigate, + onExpandNode: stableHandlers.onExpandNode, + }, + }; + }, + ); + + const layoutedEdges: Edge[] = data.edges.map((e) => { + const normalized = normalizeEdgeType(e.edge_type); + return { + id: e.id, + source: e.source, + target: e.target, + type: "ontologyEdge", + ...(normalized === "subClassOf" && { + markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8", width: 16, height: 16 }, + }), + data: { + edgeType: normalized, + }, + }; + }); + + if (layoutRunRef.current !== localRunId) return; + setNodes(layoutedNodes); + setEdges(layoutedEdges); + } finally { + if (layoutRunRef.current === localRunId) setIsLayouting(false); + } + }, + [], + ); + + return { nodes, edges, isLayouting, runLayout, setNodeHandlers }; +} diff --git a/lib/graph/utils.ts b/lib/graph/utils.ts new file mode 100644 index 00000000..16f1bf34 --- /dev/null +++ b/lib/graph/utils.ts @@ -0,0 +1,16 @@ +import type { ClassTreeNode } from "@/lib/ontology/types"; + +/** + * 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; +} diff --git a/lib/hooks/useGraphData.ts b/lib/hooks/useGraphData.ts index 7e1674d8..5d411c11 100644 --- a/lib/hooks/useGraphData.ts +++ b/lib/hooks/useGraphData.ts @@ -1,344 +1,153 @@ "use client"; -import { useState, useCallback, useEffect, useRef } from "react"; -import { projectOntologyApi, type OWLClassDetail } from "@/lib/api/client"; -import { buildGraphFromClassDetail, getSeeAlsoIris } from "@/lib/graph/buildGraphData"; -import { getLocalName } from "@/lib/utils"; -import type { GraphData } from "@/lib/graph/types"; - -const MAX_RESOLVED_NODES = 100; +import { useState, useCallback, useLayoutEffect, useMemo, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { graphApi, type EntityGraphResponse } from "@/lib/api/graph"; interface UseGraphDataOptions { focusIri: string | null; projectId: string; - accessToken?: string; branch?: string; - initialDepth?: number; - labelHints?: Map; + accessToken?: string; } interface UseGraphDataReturn { - graphData: GraphData | null; + graphData: EntityGraphResponse | null; isLoading: boolean; + showAllDescendants: boolean; + setShowAllDescendants: (v: boolean) => void; expandNode: (iri: string) => void; resetGraph: () => void; resolvedCount: number; } -export function useGraphData({ - focusIri, - projectId, - accessToken, - branch, - initialDepth = 2, - labelHints, -}: UseGraphDataOptions): UseGraphDataReturn { - const [graphData, setGraphData] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const resolvedNodesRef = useRef>(new Map()); - // IRIs that failed getClassDetail (non-class entities) — don't retry - const failedIrisRef = useRef>(new Set()); - // Labels + entity types discovered for non-class entities via search API - const nonClassLabelsRef = useRef>(new Map()); - const [resolvedCount, setResolvedCount] = useState(0); - - const fetchDetail = useCallback( - async (iri: string): Promise => { - if (resolvedNodesRef.current.has(iri)) { - return resolvedNodesRef.current.get(iri)!; - } - if (failedIrisRef.current.has(iri)) return null; - if (resolvedNodesRef.current.size >= MAX_RESOLVED_NODES) return null; - try { - const detail = await projectOntologyApi.getClassDetail( - projectId, - iri, - accessToken, - branch, - ); - resolvedNodesRef.current.set(iri, detail); - return detail; - } catch { - failedIrisRef.current.add(iri); - return null; - } - }, - [projectId, accessToken, branch], - ); - - const fetchNeighbors = useCallback( - async (iris: string[]): Promise => { - const unresolved = iris.filter( - (iri) => - !resolvedNodesRef.current.has(iri) && - !failedIrisRef.current.has(iri) && - resolvedNodesRef.current.size < MAX_RESOLVED_NODES, - ); - if (unresolved.length === 0) return []; - - const results = await Promise.allSettled( - unresolved.map((iri) => fetchDetail(iri)), - ); - - const newNeighborIris: string[] = []; - for (const result of results) { - if (result.status === "fulfilled" && result.value) { - const detail = result.value; - for (const parentIri of detail.parent_iris) { - if (!resolvedNodesRef.current.has(parentIri)) { - newNeighborIris.push(parentIri); - } - } - for (const eqIri of detail.equivalent_iris ?? []) { - if (!resolvedNodesRef.current.has(eqIri)) { - newNeighborIris.push(eqIri); - } - } - for (const djIri of detail.disjoint_iris ?? []) { - if (!resolvedNodesRef.current.has(djIri)) { - newNeighborIris.push(djIri); - } - } - for (const seeAlsoIri of getSeeAlsoIris(detail)) { - if (!resolvedNodesRef.current.has(seeAlsoIri)) { - newNeighborIris.push(seeAlsoIri); - } - } - } +function mergeExpansions( + base: EntityGraphResponse, + expansions: EntityGraphResponse[], +): EntityGraphResponse { + if (expansions.length === 0) return base; + + const nodeIds = new Set(base.nodes.map((n) => n.id)); + const edgeIds = new Set(base.edges.map((e) => e.id)); + const mergedNodes = [...base.nodes]; + const mergedEdges = [...base.edges]; + let truncated = base.truncated; + let totalConceptCount = base.total_concept_count; + + for (const expansion of expansions) { + for (const node of expansion.nodes) { + if (!nodeIds.has(node.id)) { + nodeIds.add(node.id); + mergedNodes.push(node); } - return [...new Set(newNeighborIris)]; - }, - [fetchDetail], - ); - - /** - * Resolve full ancestry for all currently resolved class nodes. - * Calls the ancestors endpoint for each class, adds missing ancestor nodes. - */ - const resolveAncestry = useCallback(async () => { - const classIris = [...resolvedNodesRef.current.keys()]; - const MAX_ANCESTOR_CALLS = 50; - let callCount = 0; - - // Collect all ancestor IRIs we need to resolve - const ancestorIrisToResolve = new Set(); - - await Promise.allSettled( - classIris.map(async (iri) => { - if (callCount >= MAX_ANCESTOR_CALLS) return; - callCount++; - try { - const response = await projectOntologyApi.getClassAncestors( - projectId, - iri, - accessToken, - branch, - ); - for (const node of response.nodes) { - if (!resolvedNodesRef.current.has(node.iri) && !failedIrisRef.current.has(node.iri)) { - ancestorIrisToResolve.add(node.iri); - } - } - } catch { - // Ancestors endpoint failed — skip this node - } - }), - ); - - // Fetch full details for discovered ancestors - if (ancestorIrisToResolve.size > 0) { - await Promise.allSettled( - [...ancestorIrisToResolve].map((iri) => fetchDetail(iri)), - ); - } - }, [projectId, accessToken, branch, fetchDetail]); - - /** Collect all IRIs referenced by resolved nodes that are still unresolved. */ - const getUnresolvedReferenced = useCallback((): string[] => { - const referenced = new Set(); - for (const detail of resolvedNodesRef.current.values()) { - for (const iri of detail.parent_iris) referenced.add(iri); - for (const iri of detail.equivalent_iris ?? []) referenced.add(iri); - for (const iri of detail.disjoint_iris ?? []) referenced.add(iri); - for (const iri of getSeeAlsoIris(detail)) referenced.add(iri); } - return [...referenced].filter( - (iri) => !resolvedNodesRef.current.has(iri) && !failedIrisRef.current.has(iri), - ); - }, []); - - /** - * Resolve labels for non-class entities (individuals, properties) via search API. - * Searches by each IRI's local name and matches by exact IRI in results. - */ - const resolveNonClassLabels = useCallback(async () => { - const needLabels = [...failedIrisRef.current].filter( - (iri) => !nonClassLabelsRef.current.has(iri), - ); - if (needLabels.length === 0) return; - - await Promise.allSettled( - needLabels.map(async (iri) => { - try { - const localName = getLocalName(iri); - const response = await projectOntologyApi.searchEntities( - projectId, - localName, - accessToken, - branch, - ); - const match = response.results.find((r) => r.iri === iri); - if (match) { - nonClassLabelsRef.current.set(iri, { - label: match.label || getLocalName(iri), - entityType: match.entity_type, - }); - } - } catch { - // Search failed for this IRI — label will fall back to getLocalName - } - }), - ); - }, [projectId, accessToken, branch]); - - const buildGraph = useCallback( - (focus: string) => { - // Merge all label sources: caller hints + non-class entity labels - let mergedHints = labelHints; - let entityTypes: Map | undefined; - if (nonClassLabelsRef.current.size > 0) { - mergedHints = new Map(labelHints); - entityTypes = new Map(); - for (const [iri, info] of nonClassLabelsRef.current) { - if (!mergedHints.has(iri)) mergedHints.set(iri, info.label); - entityTypes.set(iri, info.entityType); - } + for (const edge of expansion.edges) { + if (!edgeIds.has(edge.id)) { + edgeIds.add(edge.id); + mergedEdges.push(edge); } - const data = buildGraphFromClassDetail(focus, resolvedNodesRef.current, mergedHints, entityTypes); - setGraphData(data); - setResolvedCount(resolvedNodesRef.current.size); - }, - [labelHints], - ); - - // Initial load when focus changes - useEffect(() => { - if (!focusIri || !accessToken) { - setGraphData(null); - return; } + truncated = truncated || expansion.truncated; + totalConceptCount = Math.max(totalConceptCount, expansion.total_concept_count); + } + + return { + ...base, + nodes: mergedNodes, + edges: mergedEdges, + truncated, + total_concept_count: totalConceptCount, + }; +} - let cancelled = false; - - async function loadGraph() { - setIsLoading(true); - resolvedNodesRef.current = new Map(); - failedIrisRef.current = new Set(); - nonClassLabelsRef.current = new Map(); - - try { - // Depth 0: focus node - const focusDetail = await fetchDetail(focusIri!); - if (cancelled || !focusDetail) { - if (!cancelled) { - // Even without detail, show a single-node graph - buildGraph(focusIri!); - } - return; - } - - // Depth 1: immediate neighbors (including seeAlso/isDefinedBy targets) - const depth1Iris = [ - ...focusDetail.parent_iris, - ...(focusDetail.equivalent_iris ?? []), - ...(focusDetail.disjoint_iris ?? []), - ...getSeeAlsoIris(focusDetail), - ]; - const depth2Candidates = await fetchNeighbors(depth1Iris); - if (cancelled) return; - - // Depth 2: neighbors of neighbors (if initialDepth >= 2) - if (initialDepth >= 2 && depth2Candidates.length > 0) { - await fetchNeighbors(depth2Candidates); - if (cancelled) return; - } - - // Label resolution pass: fetch details for any remaining unresolved - // relationship targets so graph nodes show labels instead of opaque IDs - const unresolved = getUnresolvedReferenced(); - if (unresolved.length > 0) { - await Promise.allSettled(unresolved.map((iri) => fetchDetail(iri))); - if (cancelled) return; - } - - // Ancestry resolution: trace all displayed classes back to root - await resolveAncestry(); - if (cancelled) return; - - // Resolve labels for non-class entities (individuals/properties) - if (failedIrisRef.current.size > 0) { - await resolveNonClassLabels(); - if (cancelled) return; - } - - buildGraph(focusIri!); - } finally { - if (!cancelled) setIsLoading(false); - } - } - - loadGraph(); - return () => { - cancelled = true; - }; - }, [focusIri, accessToken, branch, fetchDetail, fetchNeighbors, getUnresolvedReferenced, resolveAncestry, resolveNonClassLabels, buildGraph, initialDepth]); - +export function useGraphData({ + focusIri, + projectId, + branch, + accessToken, +}: UseGraphDataOptions): UseGraphDataReturn { + const [expansions, setExpansions] = useState([]); + const [showAllDescendants, setShowAllDescendants] = useState(false); + const [resetKey, setResetKey] = useState(0); + const expandedNodes = useRef(new Set()); + const graphEpoch = useRef(0); + const expandingNodes = useRef(new Set()); + + const query = useQuery({ + queryKey: ["entityGraph", projectId, focusIri, branch, showAllDescendants, resetKey, !!accessToken], + queryFn: () => + graphApi.getEntityGraph(projectId, focusIri!, { + branch, + ancestorsDepth: 5, + descendantsDepth: showAllDescendants ? 2 : 1, + }, accessToken), + enabled: !!focusIri && !!accessToken, + staleTime: 30_000, + }); + + // Reset expansion tracking synchronously in the commit phase so stale + // expandNode() promise callbacks cannot pass the epoch guard before the reset. + useLayoutEffect(() => { + graphEpoch.current++; + expandedNodes.current = focusIri ? new Set([focusIri]) : new Set(); + expandingNodes.current = new Set(); + setExpansions([]); + }, [projectId, focusIri, branch, showAllDescendants, resetKey]); + + // Merge base query data with accumulated expansions + const graphData = useMemo(() => { + if (!query.data) return null; + return mergeExpansions(query.data, expansions); + }, [query.data, expansions]); + + // Progressive expansion: fetch 1-hop neighborhood and merge const expandNode = useCallback( - async (iri: string) => { - if (!focusIri) return; - setIsLoading(true); + (iri: string) => { + if (!graphData || expandedNodes.current.has(iri) || expandingNodes.current.has(iri)) return; - try { - const detail = await fetchDetail(iri); - if (detail) { - const neighborIris = [ - ...detail.parent_iris, - ...(detail.equivalent_iris ?? []), - ...(detail.disjoint_iris ?? []), - ...getSeeAlsoIris(detail), - ]; - await fetchNeighbors(neighborIris); + const epoch = graphEpoch.current; + expandingNodes.current.add(iri); - // Label resolution pass for newly discovered relationship targets - const unresolved = getUnresolvedReferenced(); - if (unresolved.length > 0) { - await Promise.allSettled(unresolved.map((i) => fetchDetail(i))); - } - - // Ancestry resolution for newly discovered nodes - await resolveAncestry(); - - // Resolve labels for non-class entities - if (failedIrisRef.current.size > 0) { - await resolveNonClassLabels(); - } - } - - buildGraph(focusIri); - } finally { - setIsLoading(false); - } + graphApi + .getEntityGraph(projectId, iri, { + branch, + ancestorsDepth: 1, + descendantsDepth: 1, + maxNodes: 50, + }, accessToken) + .then((newData) => { + // Drop stale responses from a previous graph context + if (graphEpoch.current !== epoch || !expandingNodes.current.has(iri)) return; + + expandingNodes.current.delete(iri); + expandedNodes.current.add(iri); + setExpansions((prev) => [...prev, newData]); + }) + .catch(() => { + expandingNodes.current.delete(iri); + }); }, - [focusIri, fetchDetail, fetchNeighbors, getUnresolvedReferenced, resolveAncestry, resolveNonClassLabels, buildGraph], + [graphData, projectId, branch, accessToken], ); const resetGraph = useCallback(() => { - resolvedNodesRef.current = new Map(); - failedIrisRef.current = new Set(); - nonClassLabelsRef.current = new Map(); - setGraphData(null); - setResolvedCount(0); + expandedNodes.current = new Set(); + setExpansions([]); + setResetKey((k) => k + 1); }, []); - return { graphData, isLoading, expandNode, resetGraph, resolvedCount }; + const resolvedCount = useMemo( + () => graphData?.nodes.length ?? 0, + [graphData], + ); + + return { + graphData, + isLoading: query.isLoading, + showAllDescendants, + setShowAllDescendants, + expandNode, + resetGraph, + resolvedCount, + }; } diff --git a/lib/stores/editorModeStore.ts b/lib/stores/editorModeStore.ts index 88d41fa9..dea5bed4 100644 --- a/lib/stores/editorModeStore.ts +++ b/lib/stores/editorModeStore.ts @@ -3,16 +3,19 @@ import { persist } from "zustand/middleware"; export type EditorMode = "standard" | "developer"; export type ThemePreference = "light" | "dark" | "system"; +export type GraphEdgeStyle = "bezier" | "smoothstep"; interface EditorModeState { editorMode: EditorMode; theme: ThemePreference; continuousEditing: boolean; hideSaveButton: boolean; + graphEdgeStyle: GraphEdgeStyle; setEditorMode: (mode: EditorMode) => void; setTheme: (theme: ThemePreference) => void; setContinuousEditing: (on: boolean) => void; setHideSaveButton: (on: boolean) => void; + setGraphEdgeStyle: (style: GraphEdgeStyle) => void; } /** @@ -41,6 +44,7 @@ export const useEditorModeStore = create()( theme: "system", continuousEditing: false, hideSaveButton: false, + graphEdgeStyle: "smoothstep", setEditorMode: (mode) => set({ editorMode: mode }), @@ -51,6 +55,7 @@ export const useEditorModeStore = create()( setContinuousEditing: (on) => set({ continuousEditing: on }), setHideSaveButton: (on) => set({ hideSaveButton: on }), + setGraphEdgeStyle: (style) => set({ graphEdgeStyle: style }), }), { name: "ontokit-editor-preferences",