This commit is contained in:
Ole
2026-05-31 20:25:41 +00:00
commit 0a07ab8593
275 changed files with 52660 additions and 0 deletions
@@ -0,0 +1,177 @@
import { useCallback, useRef } from "react";
import {
GraphCanvas as ReagraphCanvas,
type GraphCanvasRef,
type LayoutTypes,
} from "reagraph";
import type { Theme } from "reagraph";
import type { GraphData, GraphEdge, GraphNode, LayoutType } from "./types";
import { ZoomControls } from "./ZoomControls";
import { GraphViewerLegend } from "./GraphViewerLegend";
import { useCanvasResize } from "./useCanvasResize";
import type { LiteFeatureFlags } from "./types";
const LAYOUT_MAP: Record<LayoutType, LayoutTypes> = {
forceDirected2d: "forceDirected2d",
forceDirected3d: "forceDirected3d",
treeTd2d: "treeTd2d",
treeLr2d: "treeLr2d",
radialOut2d: "radialOut2d",
circular2d: "circular2d",
};
const DOUBLE_CLICK_MS = 300;
export interface GraphViewerCanvasProps {
reagraphNodes: Array<{
id: string;
label: string;
fill: string;
icon: string;
data: GraphNode;
}>;
reagraphEdges: Array<{
id: string;
source: string;
target: string;
label?: string;
fill?: string;
size?: number;
data: GraphEdge;
}>;
displayedGraphData: GraphData;
layout: LayoutType;
theme: Theme;
selections: string[];
selectedNode: GraphNode | null;
selectedEdge: GraphEdge | null;
features: LiteFeatureFlags;
selectedNodeType: string | null;
graphRef: React.RefObject<GraphCanvasRef | null>;
onNodeClick: (node: GraphNode) => void;
onEdgeClick: (edge: GraphEdge) => void;
onCanvasClick: () => void;
onExpandNode: (nodeId: string) => void;
onNodeTypeClick: (typeKey: string) => void;
onClearNodeTypeSelection: () => void;
className?: string;
}
export function GraphViewerCanvas({
reagraphNodes,
reagraphEdges,
displayedGraphData,
layout,
theme,
selections,
features,
selectedNodeType,
graphRef,
onNodeClick,
onEdgeClick,
onCanvasClick,
onExpandNode,
onNodeTypeClick,
onClearNodeTypeSelection,
className,
}: GraphViewerCanvasProps) {
const canvasContainerRef = useRef<HTMLDivElement>(null);
const lastClickedIdRef = useRef<string | null>(null);
const lastClickTimeRef = useRef(0);
const pendingClickRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useCanvasResize(canvasContainerRef, graphRef as React.RefObject<GraphCanvasRef>);
const handleNodeClick = useCallback(
(node: { id: string }) => {
const graphNode = displayedGraphData.nodes.find((n) => n.id === node.id);
if (!graphNode) return;
const now = Date.now();
const isDoubleClick =
lastClickedIdRef.current === node.id &&
now - lastClickTimeRef.current < DOUBLE_CLICK_MS;
lastClickedIdRef.current = node.id;
lastClickTimeRef.current = now;
if (isDoubleClick && features.enableNodeExpansion) {
if (pendingClickRef.current) {
clearTimeout(pendingClickRef.current);
pendingClickRef.current = null;
}
onExpandNode(node.id);
return;
}
pendingClickRef.current = setTimeout(() => {
pendingClickRef.current = null;
onNodeClick(graphNode);
}, DOUBLE_CLICK_MS);
},
[displayedGraphData.nodes, onNodeClick, onExpandNode, features.enableNodeExpansion]
);
const handleEdgeClick = useCallback(
(edge: { id: string }) => {
const graphEdge = displayedGraphData.connections.find((c) => c.id === edge.id);
if (graphEdge) {
onEdgeClick(graphEdge);
}
},
[displayedGraphData.connections, onEdgeClick]
);
const hasNodes = reagraphNodes.length > 0;
return (
<div
ref={canvasContainerRef}
className={`relative w-full h-full min-h-0 min-w-0 ${className ?? ""}`}
>
{hasNodes && (
<ReagraphCanvas
ref={graphRef}
nodes={reagraphNodes}
edges={reagraphEdges}
layoutType={LAYOUT_MAP[layout]}
theme={theme}
labelType="nodes"
edgeLabelPosition="natural"
edgeArrowPosition="end"
draggable
animated
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onCanvasClick={onCanvasClick}
selections={selections}
cameraMode={layout.includes("3d") ? "rotate" : "pan"}
/>
)}
{!hasNodes && (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm">
No nodes to display
</div>
)}
{features.enableZoomControls && hasNodes && (
<ZoomControls
onZoomIn={() => graphRef.current?.zoomIn()}
onZoomOut={() => graphRef.current?.zoomOut()}
onFitView={() => graphRef.current?.fitNodesInView()}
/>
)}
{features.enableLegend &&
displayedGraphData.nodeTypes.length > 0 && (
<GraphViewerLegend
nodeTypes={displayedGraphData.nodeTypes}
selectedNodeType={selectedNodeType}
onNodeTypeClick={onNodeTypeClick}
onClearSelection={onClearNodeTypeSelection}
/>
)}
</div>
);
}
@@ -0,0 +1,122 @@
import * as LucideIcons from "lucide-react";
import type { NodeTypeInfo } from "./types";
import { getIconForType } from "./types";
type LucideIconComponent = React.ComponentType<{
size?: number;
color?: string;
strokeWidth?: number;
className?: string;
}>;
function getLucideIcon(iconName: string): LucideIconComponent {
const icons = LucideIcons as unknown as Record<string, LucideIconComponent>;
const icon = icons[iconName];
if (!icon) {
console.warn(`[getLucideIcon] Icon "${iconName}" not found, using Circle`);
}
return icon || icons.Circle;
}
const POSITION_CLASSES = {
"bottom-left": "bottom-4 left-4",
"bottom-right": "bottom-4 right-4",
"top-left": "top-4 left-4",
"top-right": "top-4 right-4",
};
export interface LegendProps {
nodeTypes: NodeTypeInfo[];
selectedNodeType: string | null;
onNodeTypeClick: (typeKey: string) => void;
onClearSelection: () => void;
maxVisibleTypes?: number;
position?: keyof typeof POSITION_CLASSES;
}
export function GraphViewerLegend({
nodeTypes,
selectedNodeType,
onNodeTypeClick,
onClearSelection,
maxVisibleTypes = 12,
position = "bottom-left",
}: LegendProps) {
if (!nodeTypes || nodeTypes.length === 0) {
return null;
}
const visibleTypes = nodeTypes.slice(0, maxVisibleTypes);
const remainingCount = nodeTypes.length - maxVisibleTypes;
return (
<div
className={`absolute ${POSITION_CLASSES[position]} bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg dark:shadow-gray-950/50 p-3 max-w-sm z-10`}
>
<p className="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-2 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-[#00205B] dark:bg-primary animate-pulse" />
Node Types ({nodeTypes.length})
{selectedNodeType && (
<button
type="button"
onClick={onClearSelection}
className="ml-auto text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
title="Clear filter"
>
</button>
)}
</p>
<div className="flex flex-wrap gap-2">
{visibleTypes.map((nodeType) => {
const iconName = getIconForType(nodeType.externalId);
const IconComponent = getLucideIcon(iconName);
const typeKey = `${nodeType.space}:${nodeType.externalId}`;
const isSelected = selectedNodeType === typeKey;
return (
<button
type="button"
key={typeKey}
onClick={() => onNodeTypeClick(typeKey)}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-all cursor-pointer border ${
isSelected
? "bg-[#00205B] border-[#00205B] dark:bg-primary dark:border-primary shadow-md"
: "bg-gray-50 border-gray-200 hover:bg-gray-100 hover:border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-500"
}`}
title={`Click to highlight ${nodeType.externalId} nodes`}
>
<div className="w-5 h-5 relative shrink-0">
<div
className="absolute inset-0 rounded-full shadow-sm"
style={{ backgroundColor: nodeType.color }}
/>
<div className="absolute inset-0 flex items-center justify-center">
<IconComponent size={11} className="shrink-0" color="#ffffff" strokeWidth={2.5} />
</div>
</div>
<span
className={`text-xs truncate max-w-[80px] font-medium ${
isSelected ? "text-white" : "text-gray-700 dark:text-gray-200"
}`}
title={nodeType.externalId}
>
{nodeType.externalId}
</span>
<span
className={`text-xs tabular-nums ${isSelected ? "text-gray-300 dark:text-white/70" : "text-gray-400 dark:text-gray-500"}`}
>
{nodeType.count}
</span>
</button>
);
})}
{remainingCount > 0 && (
<span className="text-xs text-gray-400 dark:text-gray-500 px-2 py-1">
+{remainingCount} more
</span>
)}
</div>
</div>
);
}
+293
View File
@@ -0,0 +1,293 @@
# Graph Viewer — Component Reference
An interactive graph visualization for exploring **Cognite Data Fusion (CDF)** data model instances and their relationships. Built on [reagraph](https://github.com/reaviz/reagraph), it exposes a single hook — `useGraphViewer` — that returns a ready-to-render canvas and a full set of programmatic controls.
> This document is the **complete API reference** for the bundle in this folder. For the agent-facing integration workflow, see `../SKILL.md`.
---
## Features
- **Data model-aware** — automatically loads CDF data model metadata to resolve node types, icons, and colors.
- **Progressive exploration** — starts from a seed instance and lets users expand the graph by double-clicking nodes to fetch connected instances (edges, direct relations, and configurable reverse relations).
- **LRU node buffer** — keeps the graph performant by evicting least-recently-used nodes when `maxNodes` is exceeded.
- **Multiple layouts** — Force-directed (2D/3D), tree (top-down / left-right), radial, and circular.
- **Interactive legend** — color-coded node type legend with click-to-filter.
- **Zoom controls** — built-in zoom in/out and fit-to-view buttons.
- **Theming** — fully customizable node, edge, ring, arrow, and canvas colors via `GraphThemeConfig` and `GraphVisualConfig`.
- **Type-aware icons** — maps CDF view types (ISA-95 assets, equipment, files, time series, etc.) to SVG icons rendered inside node circles.
---
## API
### `useGraphViewer(config): UseGraphViewerReturn`
#### `UseGraphViewerConfig`
| Field | Type | Required | Description |
| ------------ | -------------------------------- | -------- | ----------------------------------------- |
| `dataModel` | `{ space, externalId, version }` | Yes | The CDF data model to load. |
| `instance` | `{ space, externalId }` | No | Optional seed instance to load on mount. |
| `options` | `UseGraphViewerOptions` | No | Optional overrides (see below). |
#### `UseGraphViewerOptions`
| Option | Type | Default | Description |
| -------------------------- | --------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `maxNodes` | `number` | `1000` | Maximum nodes held in the LRU buffer. Older nodes are evicted first. |
| `layout` | `LayoutType` | `"forceDirected2d"` | Initial graph layout algorithm. |
| `initialConnectionLimit` | `number` | `100` | **Hard maximum** number of connected instances fetched per expansion (edges + reverse-relation nodes). |
| `whitelistedRelationProps` | `string[]` | all | Property names to follow when extracting direct relations. Strongly recommended for large data models. |
| `coreReverseQueries` | `ReverseRelationQuery[]` | `[]` | Reverse-relation queries to run on node expansion. See shape below. |
| `viewPriorityConfig` | `ViewPriorityConfig` | built-in | Controls which CDF views determine node types. |
| `visualConfig` | `Partial<GraphVisualConfig>` | defaults | Node colors, palette, icon size, path highlight. |
| `themeConfig` | `Partial<GraphThemeConfig>` | defaults | Full reagraph theme overrides. |
| `features` | `Partial<LiteFeatureFlags>` | all enabled | Toggle legend, zoom controls, and node expansion. |
##### `ReverseRelationQuery`
```ts
type ReverseRelationQuery = [
space: string, // space of the view that defines the relation
viewExternalId: string, // external id of the view
viewVersion: string, // view version, e.g. "v1" — required, never assumed
propertyName: string, // direct-relation property pointing back to the expanded node
isList: boolean, // true for list<direct>, false for direct
];
```
Example:
```ts
const coreReverseQueries: ReverseRelationQuery[] = [
["industrial-dm", "Cavity", "v1", "connector", false],
["industrial-dm", "Cable", "v1", "wireGroup", true],
];
```
##### `LiteFeatureFlags`
| Flag | Default | Controls |
| --------------------- | ------- | ----------------------------------------- |
| `enableLegend` | `true` | Node-type color legend overlay |
| `enableZoomControls` | `true` | Zoom in / out / fit buttons |
| `enableNodeExpansion` | `true` | Double-click node to expand its neighbors |
#### `UseGraphViewerReturn`
| Property | Type | Description |
| --------------- | -------------------------------------- | -------------------------------------------------------------- |
| `GraphCanvas` | `React.FC<{ className? }>` | Self-contained canvas component to render. |
| `isLoading` | `boolean` | `true` while data model, seed node, or expansion is in flight. |
| `error` | `string \| null` | Error message, if any. |
| `graphData` | `GraphData` | Current nodes, connections, and node type metadata. |
| `stats` | `GraphStats \| null` | Aggregate counts by node/connection type. |
| `layout` | `LayoutType` | Current layout. |
| `setLayout` | `(layout) => void` | Change the layout algorithm. |
| `selections` | `string[]` | Currently selected node/edge IDs. |
| `setSelections` | `(ids) => void` | Programmatically select nodes/edges. |
| `selectedNode` | `GraphNode \| null` | The selected node object. |
| `selectedEdge` | `GraphEdge \| null` | The selected edge object. |
| `expandNode` | `(nodeId) => Promise<void>` | Fetch and add connected instances for a node. |
| `loadInstance` | `(space, externalId) => Promise<void>` | Load a new seed instance (replaces the graph). |
| `fitView` | `() => void` | Fit all nodes into the viewport. |
| `zoomIn` | `() => void` | Zoom in. |
| `zoomOut` | `() => void` | Zoom out. |
| `clear` | `() => void` | Remove all nodes and edges from the buffer. |
| `graphRef` | `RefObject<GraphCanvasRef>` | Direct ref to the underlying reagraph canvas. |
---
## Layout Options
| ID | Label |
| ----------------- | ----------------- |
| `forceDirected2d` | Force 2D |
| `forceDirected3d` | Force 3D |
| `treeTd2d` | Tree (Top-Down) |
| `treeLr2d` | Tree (Left-Right) |
| `radialOut2d` | Radial |
| `circular2d` | Circular |
---
## Examples
### Minimal embed
```tsx
function GraphPanel() {
const { GraphCanvas } = useGraphViewer({
dataModel: { space: "equipment", externalId: "EquipmentModel", version: "1" },
});
return <GraphCanvas className="h-full w-full" />;
}
```
No seed instance — the canvas renders empty until you call `loadInstance`.
### Layout switcher with stats
```tsx
function GraphWithControls() {
const { GraphCanvas, stats, layout, setLayout } = useGraphViewer({
dataModel: { space: "equipment", externalId: "EquipmentModel", version: "1" },
instance: { space: "assets", externalId: "pump-001" },
});
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-4 border-b p-2">
<select value={layout} onChange={(e) => setLayout(e.target.value as LayoutType)}>
<option value="forceDirected2d">Force 2D</option>
<option value="treeTd2d">Tree</option>
<option value="radialOut2d">Radial</option>
<option value="circular2d">Circular</option>
</select>
{stats && <span>{stats.totalNodes} nodes</span>}
</div>
<GraphCanvas className="flex-1" />
</div>
);
}
```
### Programmatic node loading
```tsx
function SearchAndGraph() {
const { GraphCanvas, loadInstance, isLoading } = useGraphViewer({
dataModel: { space: "equipment", externalId: "EquipmentModel", version: "1" },
});
return (
<div className="flex h-full flex-col">
<input
placeholder="Enter node externalId…"
onKeyDown={(e) => {
if (e.key === "Enter") loadInstance("assets", e.currentTarget.value);
}}
/>
{isLoading && <p>Loading</p>}
<GraphCanvas className="flex-1" />
</div>
);
}
```
### Disable features
```tsx
const { GraphCanvas } = useGraphViewer({
dataModel: { space: "s", externalId: "dm", version: "1" },
instance: { space: "s", externalId: "node-1" },
options: {
features: {
enableLegend: false,
enableZoomControls: false,
enableNodeExpansion: false,
},
},
});
```
### Conservative expansion for large data models
Whitelist relation properties and bound the per-expansion budget to keep CDF
load predictable:
```tsx
const { GraphCanvas } = useGraphViewer({
dataModel: { space: "industrial", externalId: "EWIS", version: "1" },
instance: { space: "instances", externalId: "connector-001" },
options: {
maxNodes: 500,
initialConnectionLimit: 50,
whitelistedRelationProps: ["parent", "child", "connectedTo"],
coreReverseQueries: [
["industrial-dm", "Cavity", "v1", "connector", false],
["industrial-dm", "Cable", "v1", "wireGroup", true],
],
},
});
```
---
## Sizing
`<GraphCanvas>` fills its parent. Give the parent explicit dimensions:
```tsx
<GraphCanvas className="h-[600px] w-full" />
<div className="h-full w-full">
<GraphCanvas className="h-full w-full" />
</div>
<div className="flex h-screen flex-col">
<header></header>
<GraphCanvas className="flex-1" />
</div>
```
---
## Common Patterns
### React to selection
```tsx
const { GraphCanvas, selectedNode } = useGraphViewer({ /* … */ });
useEffect(() => {
if (selectedNode) console.log("Selected:", selectedNode.data.externalId);
}, [selectedNode]);
```
### Expand from an external trigger
```tsx
const { expandNode } = useGraphViewer({ /* … */ });
// nodeId format is "space:externalId"
await expandNode("my-space:pump-001");
```
---
## Architecture
```
graph-viewer/
├── useGraphViewer.tsx # Main hook — composes all sub-hooks, returns GraphCanvas + controls
├── GraphViewerCanvas.tsx # Renders reagraph canvas, zoom controls, and legend
├── GraphViewerLegend.tsx # Color-coded node type legend with click-to-filter
├── ZoomControls.tsx # Zoom in / out / fit-view button group
├── graph-service.ts # CDF API calls — fetchNodeDetails, fetchConnectedNodes
├── graph-config.ts # Theme defaults, icon generation, node/edge transformers
├── useDataModelLoader.ts # Loads data model views from CDF
├── useSeedNode.ts # Loads the initial instance and its connections
├── useNodeBuffer.ts # LRU buffer that caps total nodes at maxNodes
├── useGraphDataPipeline.ts # Transforms raw CDF instances into GraphData + reagraph format
├── useGraphSelection.ts # Tracks selected node/edge state
├── useCanvasResize.ts # Observes container size changes and triggers reagraph resize
├── types.ts # All shared TypeScript types, constants, and helpers
└── index.ts # Public exports
```
---
## Dependencies
| Package | Purpose |
| --------------- | ---------------------------------------------------- |
| `react` | UI framework (peer) |
| `@cognite/sdk` | CDF API client (instances, data models) |
| `@cognite/dune` | Provides the authenticated SDK via `useDune()` |
| `reagraph` | WebGL graph rendering engine |
| `lucide-react` | Icon set used by the node-type legend |
Install latest compatible versions using the target app's package manager. Prefer the React version already pinned by the app rather than upgrading it.
@@ -0,0 +1,111 @@
function ZoomOutIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Zoom out"
>
<circle cx="9" cy="9" r="6" stroke="currentColor" strokeWidth="1.5" />
<path d="M13.5 13.5L17 17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M6 9H12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function ZoomInIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Zoom in"
>
<circle cx="9" cy="9" r="6" stroke="currentColor" strokeWidth="1.5" />
<path d="M13.5 13.5L17 17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M9 6V12M6 9H12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function FitViewIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Fit view"
>
<path
d="M2 7V3.5C2 2.67 2.67 2 3.5 2H7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 2H16.5C17.33 2 18 2.67 18 3.5V7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M18 13V16.5C18 17.33 17.33 18 16.5 18H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 18H3.5C2.67 18 2 17.33 2 16.5V13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<rect
x="8"
y="8"
width="4"
height="4"
rx="0.5"
stroke="currentColor"
strokeWidth="1.5"
transform="rotate(0 10 10)"
/>
</svg>
);
}
interface ZoomControlsProps {
onZoomIn: () => void;
onZoomOut: () => void;
onFitView: () => void;
}
export function ZoomControls({ onZoomIn, onZoomOut, onFitView }: ZoomControlsProps) {
const btnClass =
"p-2 text-muted-foreground hover:text-foreground hover:bg-accent dark:hover:bg-accent/50 rounded-lg active:scale-95 transition-colors";
return (
<div className="absolute bottom-4 right-4 flex items-center gap-0.5 bg-background/95 backdrop-blur-sm border border-border rounded-xl shadow-lg p-1.5 z-10">
<button type="button" onClick={onZoomOut} className={btnClass} title="Zoom Out">
<ZoomOutIcon className="w-4 h-4" />
</button>
<button type="button" onClick={onZoomIn} className={btnClass} title="Zoom In">
<ZoomInIcon className="w-4 h-4" />
</button>
<div className="w-px h-5 bg-border mx-1" />
<button type="button" onClick={onFitView} className={btnClass} title="Fit to View">
<FitViewIcon className="w-4 h-4" />
</button>
</div>
);
}
@@ -0,0 +1,271 @@
import { type Theme, lightTheme } from "reagraph";
import {
DEFAULT_NODE_COLOR,
NODE_TYPE_PALETTE,
getIconForType,
type GraphEdge,
type GraphNode,
type GraphThemeConfig,
type GraphVisualConfig,
} from "./types";
// =============================================================================
// Visual Configuration Defaults
// =============================================================================
export const DEFAULT_VISUAL_CONFIG: GraphVisualConfig = {
pathHighlightColor: "#22c55e",
pathHighlightSize: 3,
iconSize: 64,
defaultNodeColor: DEFAULT_NODE_COLOR,
nodeTypePalette: NODE_TYPE_PALETTE,
};
// =============================================================================
// Theme Configuration Defaults
// =============================================================================
export const DEFAULT_THEME_CONFIG: GraphThemeConfig = {
canvas: {
background: "#FAFBFC",
},
node: {
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.3,
label: {
color: "#1E293B",
stroke: "#FFFFFF",
strokeWidth: 3,
activeColor: "#0F172A",
fontSize: 12,
},
},
edge: {
fill: "#94A3B8",
activeFill: "#3B82F6",
opacity: 0.7,
selectedOpacity: 1,
inactiveOpacity: 0.2,
label: {
color: "#64748B",
stroke: "#FFFFFF",
strokeWidth: 2,
activeColor: "#3B82F6",
fontSize: 10,
},
},
ring: {
fill: "#3B82F6",
activeFill: "#2563EB",
},
arrow: {
fill: "#94A3B8",
activeFill: "#3B82F6",
},
cluster: {
stroke: "#E2E8F0",
fill: "#F8FAFC",
label: {
color: "#475569",
},
},
lasso: {
border: "#3B82F6",
background: "rgba(59, 130, 246, 0.1)",
},
};
// =============================================================================
// Icon Paths
// =============================================================================
export const ICON_PATHS: Record<string, string> = {
Plug: "M6 8h12v8H6z M9 8V5 M12 8V5 M15 8V5 M9 16v3 M12 16v3 M15 16v3",
Minus: "M3 12h18 M3 10v4 M21 10v4",
Cable:
"M4 6c3 0 5 3 8 6c3-3 5-6 8-6 M4 12h4c2 0 3 1 4 2c1-1 2-2 4-2h4 M4 18c3 0 5-3 8-6c3 3 5 6 8 6",
CircleDot: "M12 6a6 6 0 0 1 6 6v6H6v-6a6 6 0 0 1 6-6z M12 10v4 M10 12h4",
Zap: "M12 3L6 12h5v6l6-9h-5V3z",
Cpu: "M7 7h10v10H7z M10 7V4 M14 7V4 M10 17v3 M14 17v3 M7 10H4 M7 14H4 M17 10h3 M17 14h3",
ArrowDownToLine: "M12 3v10 M7 13h10 M9 16h6 M11 19h2",
Type: "M6 6h12 M12 6v12 M8 18h8",
LayoutGrid: "M4 4h6v6H4z M14 4h6v6h-6z M4 14h6v6H4z M14 14h6v6h-6z",
GitCommit: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M3 12h5 M16 12h5",
FileText:
"M6 2h8l4 4v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z M14 2v4h4 M8 12h8 M8 16h8",
Activity: "M3 12h4l2-6 3 12 2-6h7",
Box: "M3 8l9-5 9 5v8l-9 5-9-5V8z M12 8v14 M3 8l9 5 9-5",
Wrench: "M14 4l-4 4 6 6 4-4a5 5 0 0 0-6-6z M10 8L4 14l4 4 6-6",
MapPin:
"M12 2a8 8 0 0 0-8 8c0 6 8 12 8 12s8-6 8-12a8 8 0 0 0-8-8z M12 7a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",
Building:
"M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16 M9 7h2 M13 7h2 M9 11h2 M13 11h2 M9 15h6",
AlertTriangle: "M12 3L2 20h20L12 3z M12 9v5 M12 16v2",
Circle: "M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z",
Package: "M3 8l9-5 9 5-9 5-9-5z M3 8v8l9 5V13 M21 8v8l-9 5V13",
ClipboardList: "M8 4h8v2H8V4z M6 6h12v14H6V6z M9 10h6 M9 14h6",
Cog: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M12 4v2 M12 18v2 M5 12H3 M21 12h-2 M6.3 6.3l1.4 1.4 M16.3 16.3l1.4 1.4 M6.3 17.7l1.4-1.4 M16.3 7.7l1.4-1.4",
default: "M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z",
};
// =============================================================================
// Helper Functions
// =============================================================================
export function buildReagraphTheme(config: Partial<GraphThemeConfig> = {}): Theme {
const theme = { ...DEFAULT_THEME_CONFIG, ...config };
return {
...lightTheme,
canvas: {
...lightTheme.canvas,
background: theme.canvas.background,
},
node: {
...lightTheme.node,
opacity: theme.node.opacity,
selectedOpacity: theme.node.selectedOpacity,
inactiveOpacity: theme.node.inactiveOpacity,
label: {
...lightTheme.node.label,
color: theme.node.label.color,
stroke: theme.node.label.stroke,
activeColor: theme.node.label.activeColor,
},
},
edge: {
...lightTheme.edge,
fill: theme.edge.fill,
activeFill: theme.edge.activeFill,
opacity: theme.edge.opacity,
selectedOpacity: theme.edge.selectedOpacity,
inactiveOpacity: theme.edge.inactiveOpacity,
label: {
...lightTheme.edge.label,
color: theme.edge.label.color,
stroke: theme.edge.label.stroke,
activeColor: theme.edge.label.activeColor,
},
},
ring: {
...lightTheme.ring,
fill: theme.ring.fill,
activeFill: theme.ring.activeFill,
},
arrow: {
...lightTheme.arrow,
fill: theme.arrow.fill,
activeFill: theme.arrow.activeFill,
},
cluster: {
...lightTheme.cluster,
stroke: theme.cluster.stroke,
fill: theme.cluster.fill,
label: {
...lightTheme.cluster?.label,
color: theme.cluster.label.color,
},
},
lasso: {
...lightTheme.lasso,
border: theme.lasso.border,
background: theme.lasso.background,
},
};
}
export function mergeVisualConfig(config?: Partial<GraphVisualConfig>): GraphVisualConfig {
return { ...DEFAULT_VISUAL_CONFIG, ...config };
}
// =============================================================================
// Icon Generation
// =============================================================================
const iconUrlCache = new Map<string, string>();
export function getIconUrl(iconName: string, bgColor: string, iconSize = 64): string {
const cacheKey = `${iconName}:${bgColor}:${iconSize}`;
if (iconUrlCache.has(cacheKey)) {
return iconUrlCache.get(cacheKey)!;
}
const pathData = ICON_PATHS[iconName] || ICON_PATHS.default;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 ${iconSize} ${iconSize}">
<circle cx="${iconSize / 2}" cy="${iconSize / 2}" r="${iconSize / 2 - 2}" fill="${bgColor}"/>
<circle cx="${iconSize / 2}" cy="${iconSize / 2}" r="${iconSize / 2 - 4}" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="2"/>
<g transform="translate(${iconSize * 0.1875}, ${iconSize * 0.1875}) scale(${iconSize * 0.026})" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none">
<path d="${pathData}"/>
</g>
</svg>`;
const dataUrl = `data:image/svg+xml;base64,${btoa(svg)}`;
iconUrlCache.set(cacheKey, dataUrl);
return dataUrl;
}
// =============================================================================
// Node/Edge Transformations (Reagraph format)
// =============================================================================
export function transformNodes(
nodes: GraphNode[],
visualConfig: GraphVisualConfig
): Array<{
id: string;
label: string;
fill: string;
icon: string;
data: GraphNode;
}> {
return nodes.map((node) => {
const typeExternalId = node.data?.type?.externalId;
const iconName = getIconForType(typeExternalId);
const fillColor = node.fill || visualConfig.defaultNodeColor;
const iconUrl = getIconUrl(iconName, fillColor, visualConfig.iconSize);
return {
id: node.id,
label: node.label,
fill: fillColor,
icon: iconUrl,
data: node,
};
});
}
export function transformEdges(
connections: GraphEdge[],
highlightedConnectionIds?: Set<string>,
visualConfig?: Partial<GraphVisualConfig>
): Array<{
id: string;
source: string;
target: string;
label?: string;
fill?: string;
size?: number;
data: GraphEdge;
}> {
const config = visualConfig || {
pathHighlightColor: "#22c55e",
pathHighlightSize: 3,
};
return connections.map((edge) => {
const isHighlighted = highlightedConnectionIds?.has(edge.id) ?? false;
return {
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.label,
data: edge,
...(isHighlighted && {
fill: config.pathHighlightColor,
size: config.pathHighlightSize,
}),
};
});
}
@@ -0,0 +1,855 @@
import type { CogniteClient } from "@cognite/sdk";
import type {
CDFEdge,
CDFNode,
DataModelInfo,
GraphData,
ReverseRelationQuery,
ViewPriorityConfig,
} from "./types";
// =============================================================================
// Node Type Detection
// =============================================================================
const DEFAULT_VIEW_TYPE_PRIORITY = [
"ISA95Asset",
"Equipment",
"Area",
"Site",
"Enterprise",
"WorkCell",
"WorkCenter",
"ProcessCell",
"ProcessArea",
"ProductionLine",
"ProductionRun",
"Product",
"WorkOrder",
"WorkUnit",
"QualityAlert",
"FaultCode",
"CogniteAsset",
"CogniteEquipment",
"CogniteFile",
"CogniteTimeSeries",
"CogniteActivity",
"Cognite3DModel",
"CogniteDescribable",
"CogniteSourceable",
"CogniteVisualizable",
"CogniteSchedulable",
];
const DEFAULT_SKIP_VIEWS_FOR_TYPE = new Set([
"CogniteDescribable",
"CogniteSourceable",
"CogniteVisualizable",
"CogniteSchedulable",
"CogniteSourceSystem",
]);
const DEFAULT_SKIP_VIEWS_FOR_PROPERTIES = new Set([
"CogniteSourceable",
"CogniteVisualizable",
"CogniteSchedulable",
]);
function deriveNodeTypeFromProperties(
properties: Record<string, Record<string, unknown>> | undefined,
viewPriorityConfig?: ViewPriorityConfig
): { space: string; externalId: string } | undefined {
if (!properties) return undefined;
const viewTypePriority = viewPriorityConfig?.viewTypePriority ?? DEFAULT_VIEW_TYPE_PRIORITY;
const skipViewsForType = viewPriorityConfig?.skipViewsForType
? new Set(viewPriorityConfig.skipViewsForType)
: DEFAULT_SKIP_VIEWS_FOR_TYPE;
const viewKeys: Array<{
space: string;
externalId: string;
priority: number;
}> = [];
for (const [spaceKey, viewsObj] of Object.entries(properties)) {
if (typeof viewsObj !== "object" || viewsObj === null) continue;
for (const viewKey of Object.keys(viewsObj)) {
const viewExternalId = viewKey.split("/")[0];
if (skipViewsForType.has(viewExternalId)) continue;
const priorityIndex = viewTypePriority.indexOf(viewExternalId);
const priority = priorityIndex >= 0 ? priorityIndex : 100;
viewKeys.push({
space: spaceKey,
externalId: viewExternalId,
priority,
});
}
}
if (viewKeys.length === 0) return undefined;
viewKeys.sort((a, b) => a.priority - b.priority);
return {
space: viewKeys[0].space,
externalId: viewKeys[0].externalId,
};
}
// =============================================================================
// Query with cursor-based pagination
// =============================================================================
const QUERY_PAGE_LIMIT = 10_000;
/**
* Paginate a CDF instances.query selection until either:
* 1. the API has no more cursors,
* 2. the cumulative number of items reaches `maxTotal` (hard cap), or
* 3. an empty page is returned.
*
* `maxTotal` is a HARD MAXIMUM. The function never returns more than `maxTotal`
* items, and it stops fetching additional pages as soon as the cap is reached.
* Pass `Infinity` to disable the cap (legacy "fetch everything" behaviour).
*/
async function queryWithCursorPagination<T>(
client: CogniteClient,
selectionName: string,
query: {
with: Record<
string,
{
nodes?: { filter?: unknown };
edges?: { filter?: unknown };
limit?: number;
}
>;
select: Record<string, { sources?: unknown[]; sort?: unknown[] }>;
includeTyping?: boolean;
},
limitPerPage: number = QUERY_PAGE_LIMIT,
maxTotal: number = Infinity
): Promise<T[]> {
const results: T[] = [];
let cursors: Record<string, string> | undefined;
while (results.length < maxTotal) {
const remaining = maxTotal - results.length;
const pageLimit = Math.max(1, Math.min(limitPerPage, remaining));
const withWithLimit = { ...query.with };
const firstKey = Object.keys(withWithLimit)[0];
if (firstKey && withWithLimit[firstKey]) {
withWithLimit[firstKey] = {
...withWithLimit[firstKey],
limit: pageLimit,
};
}
const request = {
...query,
with: withWithLimit,
cursors,
};
const res = await client.instances.query(
request as Parameters<CogniteClient["instances"]["query"]>[0]
);
const chunk = (res.items[selectionName] ?? []) as T[];
results.push(...chunk);
cursors =
res.nextCursor && Object.keys(res.nextCursor).length > 0
? (res.nextCursor as Record<string, string>)
: undefined;
if (chunk.length === 0 || !cursors?.[selectionName]) break;
}
return results.length > maxTotal ? results.slice(0, maxTotal) : results;
}
// =============================================================================
// fetchNodeDetails
// =============================================================================
export async function fetchNodeDetails(
client: CogniteClient | null,
space: string,
externalId: string
): Promise<CDFNode | null> {
if (!client) {
throw new Error("CDF client is not available");
}
try {
const inspectResult = await client.instances.inspect({
items: [
{
instanceType: "node" as const,
space,
externalId,
},
],
inspectionOperations: {
involvedViews: {
allVersions: false,
},
},
});
const involvedViews = inspectResult.items?.[0]?.inspectionResults?.involvedViews || [];
const sources = involvedViews.slice(0, 10).map((view) => ({
source: {
type: "view" as const,
space: view.space,
externalId: view.externalId,
version: view.version ?? "latest",
},
}));
const response = await client.instances.retrieve({
items: [
{
instanceType: "node" as const,
space,
externalId,
},
],
includeTyping: true,
sources: sources.length > 0 ? sources : undefined,
});
if (response.items.length === 0) {
return null;
}
const item = response.items[0];
const properties = item.properties as Record<string, Record<string, unknown>>;
const derivedType = deriveNodeTypeFromProperties(properties);
return {
space: item.space,
externalId: item.externalId,
instanceType: "node" as const,
version: item.version,
createdTime: item.createdTime,
lastUpdatedTime: item.lastUpdatedTime,
type: item.type || derivedType,
properties,
};
} catch (error) {
console.error("[GraphViewer] Error fetching node details:", error);
return null;
}
}
// =============================================================================
// fetchConnectedNodes
// =============================================================================
export interface ExpandNodeResult {
newNodes: CDFNode[];
newEdges: CDFEdge[];
connectedNodeIds: string[];
}
export async function fetchConnectedNodes(
client: CogniteClient | null,
nodeSpace: string,
nodeExternalId: string,
existingNodeIds: Set<string>,
_dataModel?: DataModelInfo,
limit = 100,
whitelistedRelationProps?: Set<string>,
coreReverseQueries?: ReverseRelationQuery[],
viewPriorityConfig?: ViewPriorityConfig
): Promise<ExpandNodeResult> {
if (!client) {
throw new Error("CDF client is not available. Please ensure you are authenticated.");
}
const extractDirectRelations = (
properties: Record<string, Record<string, unknown>> | undefined
): Array<{ space: string; externalId: string }> => {
if (!properties) return [];
const refs: Array<{ space: string; externalId: string }> = [];
const seen = new Set<string>();
const processValue = (val: unknown) => {
if (val && typeof val === "object" && "space" in val && "externalId" in val) {
const ref = val as { space: string; externalId: string };
if (typeof ref.space === "string" && typeof ref.externalId === "string") {
const key = `${ref.space}:${ref.externalId}`;
if (!seen.has(key)) {
seen.add(key);
refs.push({ space: ref.space, externalId: ref.externalId });
}
}
} else if (Array.isArray(val)) {
val.forEach(processValue);
}
};
for (const spaceObj of Object.values(properties)) {
if (typeof spaceObj !== "object" || spaceObj === null) continue;
for (const viewObj of Object.values(spaceObj)) {
if (typeof viewObj !== "object" || viewObj === null) continue;
for (const [propName, propVal] of Object.entries(viewObj as Record<string, unknown>)) {
if (!whitelistedRelationProps || whitelistedRelationProps.has(propName)) {
processValue(propVal);
}
}
}
}
return refs;
};
const [sourceNodeRefs, edgeResponse] = await Promise.all([
(async () => {
try {
const sourceInspect = await client.instances.inspect({
items: [
{
instanceType: "node" as const,
space: nodeSpace,
externalId: nodeExternalId,
},
],
inspectionOperations: { involvedViews: { allVersions: false } },
});
if (sourceInspect.items.length > 0) {
const involvedViews =
(
sourceInspect.items[0] as {
inspectionResults?: {
involvedViews?: Array<{
space: string;
externalId: string;
version: string;
}>;
};
}
).inspectionResults?.involvedViews || [];
const sources = involvedViews.slice(0, 10).map((v) => ({
source: {
type: "view" as const,
space: v.space,
externalId: v.externalId,
version: v.version,
},
}));
if (sources.length > 0) {
const sourceNodeResponse = await client.instances.retrieve({
items: [
{
instanceType: "node" as const,
space: nodeSpace,
externalId: nodeExternalId,
},
],
sources,
includeTyping: true,
});
if (sourceNodeResponse.items.length > 0) {
const props = sourceNodeResponse.items[0].properties as Record<
string,
Record<string, unknown>
>;
return extractDirectRelations(props);
}
}
}
return [];
} catch {
return [];
}
})(),
(async () => {
type EdgeItem = {
space: string;
externalId: string;
version: number;
createdTime: number;
lastUpdatedTime: number;
type: { space: string; externalId: string };
startNode: { space: string; externalId: string };
endNode: { space: string; externalId: string };
properties?: Record<string, Record<string, unknown>>;
};
const items = await queryWithCursorPagination<EdgeItem>(
client,
"edges",
{
with: {
edges: {
edges: {
filter: {
or: [
{
equals: {
property: ["edge", "startNode"],
value: { space: nodeSpace, externalId: nodeExternalId },
},
},
{
equals: {
property: ["edge", "endNode"],
value: { space: nodeSpace, externalId: nodeExternalId },
},
},
],
},
},
},
},
select: { edges: {} },
includeTyping: true,
},
Math.min(limit * 2, QUERY_PAGE_LIMIT),
// Hard cap: never return more than `limit` edges per expansion. This
// upper bound protects the consumer from runaway pagination and matches
// the documented contract of `initialConnectionLimit`.
limit
);
return { items };
})(),
]);
const newEdges: CDFEdge[] = edgeResponse.items.map((edgeItem) => {
const item = edgeItem as {
space: string;
externalId: string;
version: number;
createdTime: number;
lastUpdatedTime: number;
type: { space: string; externalId: string };
startNode: { space: string; externalId: string };
endNode: { space: string; externalId: string };
properties?: Record<string, Record<string, unknown>>;
};
return {
space: item.space,
externalId: item.externalId,
instanceType: "edge" as const,
version: item.version,
createdTime: item.createdTime,
lastUpdatedTime: item.lastUpdatedTime,
type: item.type,
startNode: item.startNode,
endNode: item.endNode,
properties: item.properties,
};
});
const connectedNodeRefs = new Map<string, { space: string; externalId: string }>();
for (const edge of newEdges) {
const startKey = `${edge.startNode.space}:${edge.startNode.externalId}`;
const endKey = `${edge.endNode.space}:${edge.endNode.externalId}`;
if (!existingNodeIds.has(startKey)) {
connectedNodeRefs.set(startKey, edge.startNode);
}
if (!existingNodeIds.has(endKey)) {
connectedNodeRefs.set(endKey, edge.endNode);
}
}
const sourceNodeKey = `${nodeSpace}:${nodeExternalId}`;
const directRelationEdges: CDFEdge[] = [];
for (const ref of sourceNodeRefs) {
const refKey = `${ref.space}:${ref.externalId}`;
if (
refKey !== sourceNodeKey &&
!existingNodeIds.has(refKey) &&
!connectedNodeRefs.has(refKey)
) {
connectedNodeRefs.set(refKey, ref);
directRelationEdges.push({
space: nodeSpace,
externalId: `synthetic:${nodeExternalId}->direct->${ref.externalId}`,
instanceType: "edge" as const,
version: 1,
createdTime: Date.now(),
lastUpdatedTime: Date.now(),
type: { space: "synthetic", externalId: "direct-relation" },
startNode: { space: nodeSpace, externalId: nodeExternalId },
endNode: { space: ref.space, externalId: ref.externalId },
properties: {},
});
}
}
newEdges.push(...directRelationEdges);
// Reverse relation queries
const reverseRelationRefs: Array<{ space: string; externalId: string }> = [];
const CORE_REVERSE_QUERIES = coreReverseQueries ?? [];
try {
const nodeRef = { space: nodeSpace, externalId: nodeExternalId };
// Spread the per-expansion budget across all configured reverse queries so
// the total number of nodes contributed by reverse relations stays within
// `limit`. Each query gets at least 1 row.
const perQueryLimit =
CORE_REVERSE_QUERIES.length > 0
? Math.max(1, Math.ceil(limit / CORE_REVERSE_QUERIES.length))
: limit;
const queryPromises = CORE_REVERSE_QUERIES.map(
async ([viewSpace, viewExternalId, viewVersion, propertyName, isList]) => {
try {
const propertyPath = [
viewSpace,
`${viewExternalId}/${viewVersion}`,
propertyName,
];
const filter = isList
? {
containsAny: {
property: propertyPath,
values: [nodeRef],
},
}
: {
equals: {
property: propertyPath,
value: nodeRef,
},
};
const items = await queryWithCursorPagination<{
space: string;
externalId: string;
}>(
client,
"nodes",
{
with: {
nodes: {
nodes: { filter },
},
},
select: { nodes: {} },
includeTyping: false,
},
Math.min(50, perQueryLimit),
perQueryLimit
);
return items.map((item) => ({
space: item.space,
externalId: item.externalId,
}));
} catch {
return [];
}
}
);
const results = await Promise.all(queryPromises);
for (const refs of results) {
reverseRelationRefs.push(...refs);
}
} catch {
// Silently ignore reverse relation query failures
}
const syntheticEdges: CDFEdge[] = [];
for (const ref of reverseRelationRefs) {
const refKey = `${ref.space}:${ref.externalId}`;
if (
refKey !== sourceNodeKey &&
!existingNodeIds.has(refKey) &&
!connectedNodeRefs.has(refKey)
) {
connectedNodeRefs.set(refKey, ref);
syntheticEdges.push({
space: ref.space,
externalId: `synthetic:${ref.externalId}->assets->${nodeExternalId}`,
instanceType: "edge" as const,
version: 1,
createdTime: Date.now(),
lastUpdatedTime: Date.now(),
type: { space: "cdf_cdm", externalId: "references-asset" },
startNode: { space: ref.space, externalId: ref.externalId },
endNode: { space: nodeSpace, externalId: nodeExternalId },
properties: {},
});
}
}
newEdges.push(...syntheticEdges);
const connectedNodeIds = newEdges.flatMap((edge) => [
`${edge.startNode.space}:${edge.startNode.externalId}`,
`${edge.endNode.space}:${edge.endNode.externalId}`,
]);
const nodesToFetch = Array.from(connectedNodeRefs.values()).slice(0, limit);
let newNodes: CDFNode[] = [];
if (nodesToFetch.length > 0) {
const inspectResponse = await client.instances.inspect({
items: nodesToFetch.map((ref) => ({
instanceType: "node" as const,
space: ref.space,
externalId: ref.externalId,
})),
inspectionOperations: { involvedViews: {} },
});
const nodeTypeMap = new Map<string, { space: string; externalId: string }>();
const allViews = new Map<string, { space: string; externalId: string; version: string }>();
for (const inspectItem of inspectResponse.items) {
const item = inspectItem as {
space: string;
externalId: string;
inspectionResults?: {
involvedViews?: Array<{
space: string;
externalId: string;
version: string;
}>;
};
};
const key = `${item.space}:${item.externalId}`;
const views = item.inspectionResults?.involvedViews || [];
const skipViewsForProperties = viewPriorityConfig?.skipViewsForProperties
? new Set(viewPriorityConfig.skipViewsForProperties)
: DEFAULT_SKIP_VIEWS_FOR_PROPERTIES;
const skipViewsForType = viewPriorityConfig?.skipViewsForType
? new Set(viewPriorityConfig.skipViewsForType)
: DEFAULT_SKIP_VIEWS_FOR_TYPE;
for (const view of views) {
if (!skipViewsForProperties.has(view.externalId)) {
const viewKey = `${view.space}:${view.externalId}`;
if (!allViews.has(viewKey)) {
allViews.set(viewKey, view);
}
}
}
const domainView = views.find(
(v) => !v.space.startsWith("cdf_cdm") && !skipViewsForType.has(v.externalId)
);
const cdmView = views.find(
(v) => v.space.startsWith("cdf_cdm") && !skipViewsForType.has(v.externalId)
);
const bestView = domainView || cdmView;
if (bestView) {
nodeTypeMap.set(key, {
space: bestView.space,
externalId: bestView.externalId,
});
}
}
const sources = [
{
source: {
type: "view" as const,
space: "cdf_cdm",
externalId: "CogniteDescribable",
version: "v1",
},
},
...Array.from(allViews.values()).map((v) => ({
source: {
type: "view" as const,
space: v.space,
externalId: v.externalId,
version: v.version,
},
})),
];
const retrieveItems = nodesToFetch.map((ref) => ({
instanceType: "node" as const,
space: ref.space,
externalId: ref.externalId,
}));
let nodeResponse: Awaited<ReturnType<CogniteClient["instances"]["retrieve"]>>;
try {
nodeResponse = await client.instances.retrieve({
items: retrieveItems,
includeTyping: true,
});
} catch {
nodeResponse = { items: [] };
}
const propertiesMap = new Map<string, Record<string, Record<string, unknown>>>();
if (sources.length > 0 && nodeResponse.items.length > 0) {
try {
const propsResponse = await client.instances.retrieve({
items: retrieveItems,
sources,
includeTyping: true,
});
for (const item of propsResponse.items) {
const key = `${item.space}:${item.externalId}`;
if (item.properties) {
propertiesMap.set(key, item.properties as Record<string, Record<string, unknown>>);
}
}
} catch {
// Continue without additional properties
}
}
const nodesWithoutProps = retrieveItems.filter((item) => {
const key = `${item.space}:${item.externalId}`;
return !propertiesMap.has(key);
});
if (nodesWithoutProps.length > 0) {
const individualFetches = nodesWithoutProps.map(async (nodeRef) => {
const key = `${nodeRef.space}:${nodeRef.externalId}`;
try {
const inspectResult = await client.instances.inspect({
items: [nodeRef],
inspectionOperations: { involvedViews: { allVersions: false } },
});
const views =
(
inspectResult.items?.[0] as {
inspectionResults?: {
involvedViews?: Array<{
space: string;
externalId: string;
version: string;
}>;
};
}
)?.inspectionResults?.involvedViews || [];
if (views.length > 0) {
const nodeSources = views.slice(0, 10).map((v) => ({
source: {
type: "view" as const,
space: v.space,
externalId: v.externalId,
version: v.version,
},
}));
const propsResp = await client.instances.retrieve({
items: [nodeRef],
sources: nodeSources,
includeTyping: true,
});
if (propsResp.items.length > 0 && propsResp.items[0].properties) {
propertiesMap.set(
key,
propsResp.items[0].properties as Record<string, Record<string, unknown>>
);
}
}
} catch {
// Silently ignore individual fetch failures
}
});
await Promise.all(individualFetches);
}
newNodes = nodeResponse.items
.filter(
(item): item is typeof item & { instanceType: "node" } =>
(item as { instanceType?: string }).instanceType === "node" || !("instanceType" in item)
)
.map((item) => {
const key = `${item.space}:${item.externalId}`;
const inspectType = nodeTypeMap.get(key);
const mergedProps =
propertiesMap.get(key) || (item.properties as Record<string, Record<string, unknown>>);
const derivedType =
deriveNodeTypeFromProperties(mergedProps, viewPriorityConfig) || inspectType;
return {
space: item.space,
externalId: item.externalId,
instanceType: "node" as const,
version: item.version,
createdTime: item.createdTime,
lastUpdatedTime: item.lastUpdatedTime,
type: item.type || derivedType,
properties: mergedProps,
};
});
}
return { newNodes, newEdges, connectedNodeIds };
}
// =============================================================================
// Graph utility functions (pure, no API calls)
// =============================================================================
export function getGraphStats(graphData: GraphData) {
const nodeTypes = new Map<string, number>();
const connectionTypes = new Map<string, number>();
graphData.nodes.forEach((node) => {
const type = node.data.type?.externalId || "Unknown";
nodeTypes.set(type, (nodeTypes.get(type) || 0) + 1);
});
graphData.connections.forEach((connection) => {
const type = connection.data.type.externalId;
connectionTypes.set(type, (connectionTypes.get(type) || 0) + 1);
});
return {
totalNodes: graphData.nodes.length,
totalConnections: graphData.connections.length,
nodeTypes: Object.fromEntries(nodeTypes),
connectionTypes: Object.fromEntries(connectionTypes),
};
}
export function getConnectedNodes(graphData: GraphData, nodeId: string) {
const connectedNodeIds = new Set<string>();
graphData.connections.forEach((connection) => {
if (connection.source === nodeId) {
connectedNodeIds.add(connection.target);
}
if (connection.target === nodeId) {
connectedNodeIds.add(connection.source);
}
});
return graphData.nodes.filter((node) => connectedNodeIds.has(node.id));
}
export function getNodeEdges(graphData: GraphData, nodeId: string) {
return graphData.connections.filter(
(connection) => connection.source === nodeId || connection.target === nodeId
);
}
+21
View File
@@ -0,0 +1,21 @@
export { useGraphViewer } from "./useGraphViewer";
export type {
UseGraphViewerConfig,
UseGraphViewerOptions,
UseGraphViewerReturn,
LiteFeatureFlags,
GraphStats,
LayoutType,
GraphData,
GraphNode,
GraphEdge,
NodeTypeInfo,
CDFNode,
CDFEdge,
DataModelInfo,
ReverseRelationQuery,
ViewReference,
ViewPriorityConfig,
GraphThemeConfig,
GraphVisualConfig,
} from "./types";
+431
View File
@@ -0,0 +1,431 @@
import type { GraphCanvasRef } from "reagraph";
// =============================================================================
// Layout
// =============================================================================
export type LayoutType =
| "forceDirected2d"
| "forceDirected3d"
| "treeTd2d"
| "treeLr2d"
| "radialOut2d"
| "circular2d";
export interface LayoutOption {
id: LayoutType;
label: string;
}
export const LAYOUT_OPTIONS: LayoutOption[] = [
{ id: "forceDirected2d", label: "Force 2D" },
{ id: "forceDirected3d", label: "Force 3D" },
{ id: "treeTd2d", label: "Tree (Top-Down)" },
{ id: "treeLr2d", label: "Tree (Left-Right)" },
{ id: "radialOut2d", label: "Radial" },
{ id: "circular2d", label: "Circular" },
];
// =============================================================================
// CDF Data Model Types
// =============================================================================
export interface ViewReference {
space: string;
externalId: string;
version?: string;
}
export interface DataModelInfo {
space: string;
externalId: string;
name?: string;
description?: string;
version?: string;
views: ViewReference[];
}
/**
* Tuple describing a reverse-relation query to run on node expansion.
*
* `[space, viewExternalId, viewVersion, propertyName, isList]`
*
* - `space` - space of the view that defines the relation.
* - `viewExternalId` - external id of the view that defines the relation.
* - `viewVersion` - version of the view (e.g. `"v1"`, `"1"`). Required so the
* lookup is not pinned to any specific version.
* - `propertyName` - direct relation property on the view that points back to
* the node being expanded.
* - `isList` - `true` when the relation is `list<direct>`, otherwise `false`.
*/
export type ReverseRelationQuery = [
space: string,
viewExternalId: string,
viewVersion: string,
propertyName: string,
isList: boolean,
];
export interface ViewPriorityConfig {
viewTypePriority?: string[];
priorityViewNames?: string[];
skipViewsForType?: string[];
skipViewsForProperties?: string[];
}
// =============================================================================
// CDF Instance Types
// =============================================================================
export interface CDFNode {
instanceType: "node";
space: string;
externalId: string;
version?: number;
createdTime?: number;
lastUpdatedTime?: number;
type?: { space: string; externalId: string };
properties?: Record<string, unknown>;
}
export interface CDFEdge {
instanceType: "edge";
space: string;
externalId: string;
version?: number;
createdTime?: number;
lastUpdatedTime?: number;
type: { space: string; externalId: string };
startNode: { space: string; externalId: string };
endNode: { space: string; externalId: string };
properties?: Record<string, unknown>;
}
// =============================================================================
// Graph Data Types
// =============================================================================
export interface GraphNode {
id: string;
label: string;
fill?: string;
data: CDFNode;
}
export interface GraphEdge {
id: string;
source: string;
target: string;
label?: string;
fill?: string;
size?: number;
data: CDFEdge;
}
export interface NodeTypeInfo {
externalId: string;
space: string;
color: string;
count: number;
}
export interface GraphData {
nodes: GraphNode[];
connections: GraphEdge[];
nodeTypes: NodeTypeInfo[];
}
export interface GraphSelection {
type: "node" | "edge" | null;
id: string | null;
node?: GraphNode;
edge?: GraphEdge;
}
// =============================================================================
// Visual / Theme Configuration
// =============================================================================
export interface GraphThemeConfig {
canvas: {
background: string;
};
node: {
opacity: number;
selectedOpacity: number;
inactiveOpacity: number;
label: {
color: string;
stroke: string;
strokeWidth: number;
activeColor: string;
fontSize: number;
};
};
edge: {
fill: string;
activeFill: string;
opacity: number;
selectedOpacity: number;
inactiveOpacity: number;
label: {
color: string;
stroke: string;
strokeWidth: number;
activeColor: string;
fontSize: number;
};
};
ring: {
fill: string;
activeFill: string;
};
arrow: {
fill: string;
activeFill: string;
};
cluster: {
stroke: string;
fill: string;
label: {
color: string;
};
};
lasso: {
border: string;
background: string;
};
}
export interface GraphVisualConfig {
pathHighlightColor: string;
pathHighlightSize: number;
iconSize: number;
defaultNodeColor: string;
nodeTypePalette: string[];
}
// =============================================================================
// Node Color Palette & Icons
// =============================================================================
export const NODE_TYPE_PALETTE = [
"#3b82f6",
"#22c55e",
"#ef4444",
"#a855f7",
"#f59e0b",
"#06b6d4",
"#e11d48",
"#0ea5e9",
"#8b5cf6",
"#f97316",
"#14b8a6",
"#f43f5e",
];
export const DEFAULT_NODE_COLOR = "#94a3b8";
export const NODE_TYPE_ICONS: Record<string, string> = {
Connector: "Plug",
Wire: "Minus",
Cable: "Cable",
Cavity: "CircleDot",
Shunt: "Zap",
ShuntCollection: "LayoutGrid",
GroundReference: "ArrowDownToLine",
HardwareOccurence: "Cpu",
TextElement: "Type",
WireExtermity: "GitCommitHorizontal",
CogniteFile: "FileText",
CogniteFileCategory: "FolderOpen",
CogniteTimeSeries: "Activity",
CogniteDatapoint: "TrendingUp",
CogniteAsset: "Box",
CogniteEquipment: "Wrench",
CogniteEquipmentType: "Settings",
CogniteAssetClass: "Layers",
CogniteAssetType: "Tag",
ISA95Asset: "Factory",
Enterprise: "Building2",
Site: "Building",
Area: "MapPin",
ProcessCell: "Grid3X3",
ProcessArea: "LayoutGrid",
ProductionLine: "ArrowRightLeft",
ProductionUnit: "Cpu",
Equipment: "Cog",
EquipmentModule: "CircuitBoard",
WorkCell: "Workflow",
WorkCenter: "Server",
WorkUnit: "Puzzle",
WorkOrder: "ClipboardList",
CogniteActivity: "Calendar",
MaintenanceOrder: "Hammer",
FaultCode: "AlertTriangle",
QualityAlert: "ShieldAlert",
Product: "Package",
ProductComponent: "Component",
ProductNode: "Boxes",
Batch: "Beaker",
Cognite3DModel: "Box",
CogniteCADModel: "Box",
CogniteCADNode: "Shapes",
Cognite360Image: "Image",
CognitePointCloudModel: "Scan",
CogniteAnnotation: "MessageSquare",
CogniteDiagramAnnotation: "StickyNote",
CogniteSourceSystem: "Database",
default: "Circle",
};
export function getIconForType(typeExternalId: string | undefined): string {
if (!typeExternalId) return NODE_TYPE_ICONS.default;
if (NODE_TYPE_ICONS[typeExternalId]) {
return NODE_TYPE_ICONS[typeExternalId];
}
const lower = typeExternalId.toLowerCase();
if (lower.includes("connector") || lower.includes("plug")) return "Plug";
if (lower.includes("wire")) return "Minus";
if (lower.includes("cable")) return "Cable";
if (lower.includes("cavity")) return "CircleDot";
if (lower.includes("shunt") && lower.includes("collection")) return "LayoutGrid";
if (lower.includes("shunt")) return "Zap";
if (lower.includes("ground")) return "ArrowDownToLine";
if (lower.includes("hardware")) return "Cpu";
if (lower.includes("file") || lower.includes("document")) return "FileText";
if (lower.includes("timeseries") || lower.includes("series")) return "Activity";
if (lower.includes("asset")) return "Box";
if (lower.includes("equipment")) return "Wrench";
if (lower.includes("work") || lower.includes("order") || lower.includes("maintenance"))
return "ClipboardList";
if (lower.includes("product")) return "Package";
if (lower.includes("area") || lower.includes("location")) return "MapPin";
if (lower.includes("site") || lower.includes("building")) return "Building";
if (lower.includes("3d") || lower.includes("model") || lower.includes("cad")) return "Box";
if (lower.includes("image") || lower.includes("photo")) return "Image";
if (lower.includes("batch")) return "Beaker";
if (lower.includes("alert") || lower.includes("fault")) return "AlertTriangle";
return NODE_TYPE_ICONS.default;
}
// =============================================================================
// Instance ID Helpers
// =============================================================================
export function createInstanceId(space: string, externalId: string) {
return `${space}:${externalId}`;
}
export function parseInstanceId(id: string) {
const [space, ...rest] = id.split(":");
return { space, externalId: rest.join(":") };
}
// =============================================================================
// Node Label
// =============================================================================
function findNameInObject(obj: Record<string, unknown> | unknown): string | undefined {
if (!obj || typeof obj !== "object") return undefined;
const objRecord = obj as Record<string, unknown>;
if (typeof objRecord.name === "string" && objRecord.name.trim().length > 0) return objRecord.name;
for (const value of Object.values(objRecord)) {
if (value && typeof value === "object") {
const nested = findNameInObject(value);
if (nested) return nested;
}
}
return undefined;
}
export function getNodeLabel(node: CDFNode): string {
if (node.properties && typeof node.properties === "object") {
for (const viewObj of Object.values(node.properties)) {
if (viewObj && typeof viewObj === "object") {
const maybe = findNameInObject(viewObj);
if (maybe) return maybe;
}
}
}
if (node.type?.externalId) {
return node.type.externalId;
}
return node.externalId;
}
// =============================================================================
// Lite Hook API Types
// =============================================================================
/**
* CDF-friendly input configuration for the `useGraphViewer` hook.
*/
export interface UseGraphViewerConfig {
dataModel: {
space: string;
externalId: string;
version: string;
};
instance?: {
space: string;
externalId: string;
};
options?: UseGraphViewerOptions;
}
export interface UseGraphViewerOptions {
maxNodes?: number;
layout?: LayoutType;
whitelistedRelationProps?: string[];
coreReverseQueries?: ReverseRelationQuery[];
viewPriorityConfig?: ViewPriorityConfig;
initialConnectionLimit?: number;
visualConfig?: Partial<GraphVisualConfig>;
themeConfig?: Partial<GraphThemeConfig>;
features?: Partial<LiteFeatureFlags>;
}
export interface LiteFeatureFlags {
enableLegend: boolean;
enableZoomControls: boolean;
enableNodeExpansion: boolean;
}
export const DEFAULT_LITE_FEATURES: LiteFeatureFlags = {
enableLegend: true,
enableZoomControls: true,
enableNodeExpansion: true,
};
export interface GraphStats {
totalNodes: number;
totalConnections: number;
nodeTypes: Record<string, number>;
connectionTypes: Record<string, number>;
}
export interface UseGraphViewerReturn {
GraphCanvas: React.FC<{ className?: string }>;
isLoading: boolean;
error: string | null;
graphData: GraphData;
stats: GraphStats | null;
layout: LayoutType;
setLayout: (layout: LayoutType) => void;
selections: string[];
setSelections: (ids: string[]) => void;
selectedNode: GraphNode | null;
selectedEdge: GraphEdge | null;
expandNode: (nodeId: string) => Promise<void>;
loadInstance: (space: string, externalId: string) => Promise<void>;
fitView: () => void;
zoomIn: () => void;
zoomOut: () => void;
clear: () => void;
graphRef: React.RefObject<GraphCanvasRef | null>;
}
@@ -0,0 +1,30 @@
import { type RefObject, useEffect } from "react";
import type { GraphCanvasRef } from "reagraph";
export function useCanvasResize(
containerRef: RefObject<HTMLDivElement | null>,
graphRef: RefObject<GraphCanvasRef | null>
) {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const triggerResize = () => {
if (graphRef.current) {
requestAnimationFrame(() => {
window.dispatchEvent(new Event("resize"));
});
}
};
const resizeObserver = new ResizeObserver(triggerResize);
resizeObserver.observe(container);
const timeoutId = setTimeout(triggerResize, 100);
return () => {
resizeObserver.disconnect();
clearTimeout(timeoutId);
};
}, [containerRef, graphRef]);
}
@@ -0,0 +1,90 @@
import { useDune } from "@cognite/dune";
import { useEffect, useState } from "react";
import type { DataModelInfo, ViewReference } from "./types";
interface UseDataModelLoaderConfig {
space: string;
externalId: string;
version: string;
}
interface UseDataModelLoaderReturn {
dataModel: DataModelInfo | null;
isLoading: boolean;
error: string | null;
}
export function useDataModelLoader(
config: UseDataModelLoaderConfig
): UseDataModelLoaderReturn {
const { sdk, isLoading: isAuthLoading } = useDune();
const [dataModel, setDataModel] = useState<DataModelInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!sdk || isAuthLoading) return;
let cancelled = false;
async function load() {
try {
setIsLoading(true);
setError(null);
const response = await sdk.dataModels.retrieve([
{
space: config.space,
externalId: config.externalId,
version: config.version,
},
]);
if (cancelled) return;
if (response.items.length === 0) {
throw new Error(
`Data model not found: ${config.space}/${config.externalId} v${config.version}`
);
}
const model = response.items[0];
const views: ViewReference[] = (model.views || []).map(
(v: { space: string; externalId: string; version: string }) => ({
space: v.space,
externalId: v.externalId,
version: v.version,
})
);
setDataModel({
space: model.space,
externalId: model.externalId,
name: model.name,
description: model.description,
version: model.version,
views,
});
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load data model"
);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, [sdk, isAuthLoading, config.space, config.externalId, config.version]);
return { dataModel, isLoading, error };
}
@@ -0,0 +1,101 @@
import { useMemo } from "react";
import type {
CDFEdge,
CDFNode,
GraphData,
GraphEdge,
GraphNode,
GraphVisualConfig,
NodeTypeInfo,
} from "./types";
import { createInstanceId, getNodeLabel } from "./types";
import { getGraphStats } from "./graph-service";
import { transformEdges, transformNodes } from "./graph-config";
interface UseGraphDataPipelineParams {
bufferNodes: CDFNode[];
bufferConnections: CDFEdge[];
visualConfig: GraphVisualConfig;
}
export function useGraphDataPipeline({
bufferNodes,
bufferConnections,
visualConfig,
}: UseGraphDataPipelineParams) {
const graphData: GraphData = useMemo(() => {
const typeMap = new Map<string, NodeTypeInfo>();
const colorMap = new Map<string, string>();
let colorIndex = 0;
const graphNodes: GraphNode[] = bufferNodes.map((node) => {
const typeKey = node.type ? `${node.type.space}:${node.type.externalId}` : "unknown";
if (node.type) {
if (!typeMap.has(typeKey)) {
const color =
visualConfig.nodeTypePalette[colorIndex % visualConfig.nodeTypePalette.length] ||
visualConfig.defaultNodeColor;
typeMap.set(typeKey, {
externalId: node.type.externalId,
space: node.type.space,
color,
count: 1,
});
colorMap.set(typeKey, color);
colorIndex += 1;
} else {
const existing = typeMap.get(typeKey);
if (existing) existing.count += 1;
}
}
const fill = node.type
? (colorMap.get(typeKey) ?? visualConfig.defaultNodeColor)
: visualConfig.defaultNodeColor;
return {
id: createInstanceId(node.space, node.externalId),
label: getNodeLabel(node),
fill,
data: node,
};
});
const graphConnections: GraphEdge[] = bufferConnections.map((edge) => ({
id: createInstanceId(edge.space, edge.externalId),
source: createInstanceId(edge.startNode.space, edge.startNode.externalId),
target: createInstanceId(edge.endNode.space, edge.endNode.externalId),
label: edge.type?.externalId || "",
data: edge,
}));
return {
nodes: graphNodes,
connections: graphConnections,
nodeTypes: Array.from(typeMap.values()),
};
}, [bufferNodes, bufferConnections, visualConfig]);
const reagraphNodes = useMemo(
() => transformNodes(graphData.nodes, visualConfig),
[graphData.nodes, visualConfig]
);
const emptyHighlights = useMemo(() => new Set<string>(), []);
const reagraphEdges = useMemo(
() => transformEdges(graphData.connections, emptyHighlights, visualConfig),
[graphData.connections, emptyHighlights, visualConfig]
);
const displayedStats = useMemo(() => getGraphStats(graphData), [graphData]);
return {
graphData,
displayedGraphData: graphData,
reagraphNodes,
reagraphEdges,
displayedStats,
};
}
@@ -0,0 +1,98 @@
import { useCallback, useMemo, useState } from "react";
import type { GraphEdge, GraphNode, GraphSelection } from "./types";
export interface UseGraphSelectionReturn {
selection: GraphSelection;
selectedNode: GraphNode | null;
selectedEdge: GraphEdge | null;
selectNode: (node: GraphNode | null) => void;
selectEdge: (edge: GraphEdge | null) => void;
clearSelection: () => void;
isNodeSelected: (nodeId: string) => boolean;
isEdgeSelected: (edgeId: string) => boolean;
}
export function useGraphSelection(): UseGraphSelectionReturn {
const [selection, setSelection] = useState<GraphSelection>({
type: null,
id: null,
node: undefined,
edge: undefined,
});
const selectNode = useCallback((node: GraphNode | null) => {
if (node) {
setSelection({
type: "node",
id: node.id,
node,
edge: undefined,
});
} else {
setSelection({
type: null,
id: null,
node: undefined,
edge: undefined,
});
}
}, []);
const selectEdge = useCallback((edge: GraphEdge | null) => {
if (edge) {
setSelection({
type: "edge",
id: edge.id,
node: undefined,
edge,
});
} else {
setSelection({
type: null,
id: null,
node: undefined,
edge: undefined,
});
}
}, []);
const clearSelection = useCallback(() => {
setSelection({
type: null,
id: null,
node: undefined,
edge: undefined,
});
}, []);
const isNodeSelected = useCallback(
(nodeId: string) => selection.type === "node" && selection.id === nodeId,
[selection]
);
const isEdgeSelected = useCallback(
(edgeId: string) => selection.type === "edge" && selection.id === edgeId,
[selection]
);
const selectedNode = useMemo(
() => (selection.type === "node" ? (selection.node ?? null) : null),
[selection]
);
const selectedEdge = useMemo(
() => (selection.type === "edge" ? (selection.edge ?? null) : null),
[selection]
);
return {
selection,
selectedNode,
selectedEdge,
selectNode,
selectEdge,
clearSelection,
isNodeSelected,
isEdgeSelected,
};
}
@@ -0,0 +1,288 @@
import { useDune } from "@cognite/dune";
import { useCallback, useMemo, useRef, useState } from "react";
import type { GraphCanvasRef } from "reagraph";
import { useGraphSelection } from "./useGraphSelection";
import { useNodeBuffer } from "./useNodeBuffer";
import { useGraphDataPipeline } from "./useGraphDataPipeline";
import { buildReagraphTheme, mergeVisualConfig } from "./graph-config";
import { fetchConnectedNodes } from "./graph-service";
import type { GraphNode, GraphEdge, LayoutType } from "./types";
import { createInstanceId, parseInstanceId } from "./types";
import { useDataModelLoader } from "./useDataModelLoader";
import { useSeedNode } from "./useSeedNode";
import { GraphViewerCanvas } from "./GraphViewerCanvas";
import {
DEFAULT_LITE_FEATURES,
type LiteFeatureFlags,
type UseGraphViewerConfig,
type UseGraphViewerReturn,
} from "./types";
/**
* `useGraphViewer` -- the single entry point for embedding a CDF graph viewer.
*
* Returns a self-contained `<GraphCanvas>` component plus state and controls.
*
* @example
* ```tsx
* const { GraphCanvas, isLoading, error } = useGraphViewer({
* dataModel: { space: "my-space", externalId: "my-dm", version: "1" },
* instance: { space: "my-inst-space", externalId: "pump-001" },
* });
*
* return <GraphCanvas className="h-full w-full" />;
* ```
*/
export function useGraphViewer(config: UseGraphViewerConfig): UseGraphViewerReturn {
const { sdk } = useDune();
const opts = config.options ?? {};
const maxNodes = opts.maxNodes ?? 1000;
const initialConnectionLimit = opts.initialConnectionLimit ?? 100;
const features: LiteFeatureFlags = { ...DEFAULT_LITE_FEATURES, ...opts.features };
const whitelistedRelationProps = useMemo(
() =>
opts.whitelistedRelationProps
? new Set(opts.whitelistedRelationProps)
: undefined,
[JSON.stringify(opts.whitelistedRelationProps)]
);
const visualConfig = useMemo(
() => mergeVisualConfig(opts.visualConfig),
[opts.visualConfig]
);
const themeConfig = useMemo(
() => buildReagraphTheme(opts.themeConfig),
[opts.themeConfig]
);
const [layout, setLayout] = useState<LayoutType>(opts.layout ?? "forceDirected2d");
const [selections, setSelections] = useState<string[]>([]);
const [selectedNodeType, setSelectedNodeType] = useState<string | null>(null);
const {
selectedNode,
selectedEdge,
selectNode,
selectEdge,
clearSelection,
} = useGraphSelection();
const {
nodes: bufferNodes,
edges: bufferEdges,
addNodes,
addEdges,
touchNode,
clear: clearBuffer,
} = useNodeBuffer(maxNodes);
const {
dataModel,
isLoading: isDataModelLoading,
error: dataModelError,
} = useDataModelLoader(config.dataModel);
const {
isLoading: isSeedLoading,
error: seedError,
loadInstance,
} = useSeedNode({
dataModel,
initialInstance: config.instance,
addNodes,
addEdges,
clearBuffer,
whitelistedRelationProps,
coreReverseQueries: opts.coreReverseQueries,
viewPriorityConfig: opts.viewPriorityConfig,
initialConnectionLimit,
});
const graphRef = useRef<GraphCanvasRef>(null);
const {
graphData,
displayedGraphData,
reagraphNodes,
reagraphEdges,
displayedStats,
} = useGraphDataPipeline({
bufferNodes,
bufferConnections: bufferEdges,
visualConfig,
});
const [isExpanding, setIsExpanding] = useState(false);
const expandNode = useCallback(
async (nodeId: string) => {
if (!sdk || !dataModel) return;
try {
setIsExpanding(true);
const { space, externalId } = parseInstanceId(nodeId);
const existingIds = new Set(
bufferNodes.map((n) => createInstanceId(n.space, n.externalId))
);
const result = await fetchConnectedNodes(
sdk,
space,
externalId,
existingIds,
dataModel,
initialConnectionLimit,
whitelistedRelationProps,
opts.coreReverseQueries,
opts.viewPriorityConfig
);
addNodes(result.newNodes);
addEdges(result.newEdges);
touchNode(nodeId);
} finally {
setIsExpanding(false);
}
},
[
sdk,
dataModel,
bufferNodes,
addNodes,
addEdges,
touchNode,
initialConnectionLimit,
whitelistedRelationProps,
opts.coreReverseQueries,
opts.viewPriorityConfig,
]
);
const handleNodeClick = useCallback(
(node: GraphNode) => {
selectNode(node);
setSelections([node.id]);
setSelectedNodeType(null);
touchNode(node.id);
},
[selectNode, setSelections, touchNode]
);
const handleEdgeClick = useCallback(
(edge: GraphEdge) => {
selectEdge(edge);
setSelections([edge.id]);
setSelectedNodeType(null);
},
[selectEdge, setSelections]
);
const handleCanvasClick = useCallback(() => {
clearSelection();
setSelections([]);
setSelectedNodeType(null);
}, [clearSelection]);
const handleNodeTypeClick = useCallback(
(typeKey: string) => {
if (selectedNodeType === typeKey) {
setSelectedNodeType(null);
setSelections([]);
clearSelection();
} else {
const nodeIds = displayedGraphData.nodes
.filter((n) => {
const key = n.data?.type
? `${n.data.type.space}:${n.data.type.externalId}`
: "unknown";
return key === typeKey;
})
.map((n) => n.id);
setSelectedNodeType(typeKey);
setSelections(nodeIds);
clearSelection();
}
},
[selectedNodeType, displayedGraphData.nodes, clearSelection]
);
const handleClearNodeTypeSelection = useCallback(() => {
setSelectedNodeType(null);
setSelections([]);
}, []);
const GraphCanvasComponent = useMemo(() => {
const Component: React.FC<{ className?: string }> = ({ className }) => (
<GraphViewerCanvas
reagraphNodes={reagraphNodes}
reagraphEdges={reagraphEdges}
displayedGraphData={displayedGraphData}
layout={layout}
theme={themeConfig}
selections={selections}
selectedNode={selectedNode}
selectedEdge={selectedEdge}
features={features}
selectedNodeType={selectedNodeType}
graphRef={graphRef}
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onCanvasClick={handleCanvasClick}
onExpandNode={expandNode}
onNodeTypeClick={handleNodeTypeClick}
onClearNodeTypeSelection={handleClearNodeTypeSelection}
className={className}
/>
);
Component.displayName = "GraphViewerCanvas";
return Component;
}, [
reagraphNodes,
reagraphEdges,
displayedGraphData,
layout,
themeConfig,
selections,
selectedNode,
selectedEdge,
features,
selectedNodeType,
graphRef,
handleNodeClick,
handleEdgeClick,
handleCanvasClick,
expandNode,
handleNodeTypeClick,
handleClearNodeTypeSelection,
]);
const isLoading = isDataModelLoading || isSeedLoading || isExpanding;
const error = dataModelError || seedError;
return {
GraphCanvas: GraphCanvasComponent,
isLoading,
error,
graphData,
stats: displayedStats,
layout,
setLayout,
selections,
setSelections,
selectedNode,
selectedEdge,
expandNode,
loadInstance,
fitView: () => graphRef.current?.fitNodesInView(),
zoomIn: () => graphRef.current?.zoomIn(),
zoomOut: () => graphRef.current?.zoomOut(),
clear: clearBuffer,
graphRef,
};
}
@@ -0,0 +1,132 @@
import { useEffect, useMemo, useState } from "react";
import { type CDFEdge, type CDFNode, createInstanceId } from "./types";
type BufferedNode = {
node: CDFNode;
lastAccessed: number;
};
type BufferState = {
nodes: Map<string, BufferedNode>;
connections: CDFEdge[];
};
function pruneConnections(connections: CDFEdge[], nodes: Map<string, BufferedNode>) {
const validNodeIds = new Set(Array.from(nodes.keys()));
return connections.filter((connection) => {
const startId = createInstanceId(connection.startNode.space, connection.startNode.externalId);
const endId = createInstanceId(connection.endNode.space, connection.endNode.externalId);
return validNodeIds.has(startId) && validNodeIds.has(endId);
});
}
function evictIfNeeded(state: BufferState, maxSize: number): BufferState {
if (state.nodes.size <= maxSize) {
return {
nodes: state.nodes,
connections: pruneConnections(state.connections, state.nodes),
};
}
const entries = Array.from(state.nodes.entries());
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed);
const toRemove = entries.length - maxSize;
for (let i = 0; i < toRemove; i++) {
state.nodes.delete(entries[i][0]);
}
return {
nodes: state.nodes,
connections: pruneConnections(state.connections, state.nodes),
};
}
export function useNodeBuffer(initialMaxSize = 1000) {
const [maxSize, setMaxSize] = useState(initialMaxSize);
const [state, setState] = useState<BufferState>({
nodes: new Map<string, BufferedNode>(),
connections: [],
});
useEffect(() => {
setState((prev) => evictIfNeeded(prev, maxSize));
}, [maxSize]);
const addNodes = (nodes: CDFNode[]) => {
const now = Date.now();
setState((prev) => {
const nextNodes = new Map(prev.nodes);
nodes.forEach((node) => {
const key = createInstanceId(node.space, node.externalId);
nextNodes.set(key, { node, lastAccessed: now });
});
return evictIfNeeded({ nodes: nextNodes, connections: prev.connections }, maxSize);
});
};
const addEdges = (connections: CDFEdge[]) => {
setState((prev) => {
const existingIds = new Set(
prev.connections.map((c) => createInstanceId(c.space, c.externalId))
);
const merged = [...prev.connections];
connections.forEach((connection) => {
const id = createInstanceId(connection.space, connection.externalId);
if (!existingIds.has(id)) {
merged.push(connection);
}
});
return {
nodes: prev.nodes,
connections: pruneConnections(merged, prev.nodes),
};
});
};
const touchNode = (nodeId: string) => {
setState((prev) => {
const nextNodes = new Map(prev.nodes);
const buffered = nextNodes.get(nodeId);
if (buffered) {
nextNodes.set(nodeId, { ...buffered, lastAccessed: Date.now() });
}
return { nodes: nextNodes, connections: prev.connections };
});
};
const clear = () => {
setState({
nodes: new Map(),
connections: [],
});
};
const setBuffer = (nodes: CDFNode[], connections: CDFEdge[]) => {
const now = Date.now();
const nodesMap = new Map<string, BufferedNode>();
nodes.forEach((node) => {
const key = createInstanceId(node.space, node.externalId);
nodesMap.set(key, { node, lastAccessed: now });
});
const pruned = pruneConnections(connections, nodesMap);
setState(evictIfNeeded({ nodes: nodesMap, connections: pruned }, maxSize));
};
const bufferedNodes = useMemo(
() => Array.from(state.nodes.values()).map((entry) => entry.node),
[state.nodes]
);
return {
nodes: bufferedNodes,
edges: state.connections,
addNodes,
addEdges,
touchNode,
clear,
setBuffer,
maxSize,
setMaxSize,
};
}
@@ -0,0 +1,113 @@
import { useDune } from "@cognite/dune";
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchConnectedNodes, fetchNodeDetails } from "./graph-service";
import type {
CDFEdge,
CDFNode,
DataModelInfo,
ReverseRelationQuery,
ViewPriorityConfig,
} from "./types";
import { createInstanceId } from "./types";
interface UseSeedNodeConfig {
dataModel: DataModelInfo | null;
initialInstance?: { space: string; externalId: string };
addNodes: (nodes: CDFNode[]) => void;
addEdges: (edges: CDFEdge[]) => void;
clearBuffer: () => void;
whitelistedRelationProps?: Set<string>;
coreReverseQueries?: ReverseRelationQuery[];
viewPriorityConfig?: ViewPriorityConfig;
initialConnectionLimit: number;
}
interface UseSeedNodeReturn {
isLoading: boolean;
error: string | null;
loadInstance: (space: string, externalId: string) => Promise<void>;
}
export function useSeedNode({
dataModel,
initialInstance,
addNodes,
addEdges,
clearBuffer,
whitelistedRelationProps,
coreReverseQueries,
viewPriorityConfig,
initialConnectionLimit,
}: UseSeedNodeConfig): UseSeedNodeReturn {
const { sdk } = useDune();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadedRef = useRef(false);
const loadInstance = useCallback(
async (space: string, externalId: string) => {
if (!sdk || !dataModel) {
setError("SDK or data model not ready");
return;
}
try {
setIsLoading(true);
setError(null);
clearBuffer();
const node = await fetchNodeDetails(sdk, space, externalId);
if (!node) {
throw new Error(`Node not found: ${space}/${externalId}`);
}
addNodes([node]);
const seedId = createInstanceId(space, externalId);
const existingIds = new Set([seedId]);
const result = await fetchConnectedNodes(
sdk,
space,
externalId,
existingIds,
dataModel,
initialConnectionLimit,
whitelistedRelationProps,
coreReverseQueries,
viewPriorityConfig
);
addNodes(result.newNodes);
addEdges(result.newEdges);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to load instance"
);
} finally {
setIsLoading(false);
}
},
[
sdk,
dataModel,
addNodes,
addEdges,
clearBuffer,
whitelistedRelationProps,
coreReverseQueries,
viewPriorityConfig,
initialConnectionLimit,
]
);
useEffect(() => {
if (loadedRef.current || !dataModel || !initialInstance || !sdk) return;
loadedRef.current = true;
loadInstance(initialInstance.space, initialInstance.externalId);
}, [dataModel, initialInstance, sdk, loadInstance]);
return { isLoading, error, loadInstance };
}