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
+103
View File
@@ -0,0 +1,103 @@
---
name: graph-viewer
description: Integrate the reusable CDF graph viewer (useGraphViewer) into a Flows app by copying the local code bundle. Use when embedding a graph visualization, adding a knowledge graph, or showing CDF data model relationships and instances.
---
# Graph Viewer
## Use This When
The user wants to embed an interactive graph of a CDF data model — nodes, direct relations, edges, and reverse relations — inside a Flows app.
Do **not** use this skill for static diagrams, pure dataflow visualizations, or non-CDF graphs.
## Prerequisites
- The app is wrapped in `@cognite/dune`'s `<DuneProvider>` so `useDune()` returns an authenticated SDK.
- The target data model exists in CDF and you know its `space`, `externalId`, and `version`.
- The app uses React 18+ and TypeScript.
## Integration Workflow
Follow these steps in order. Adapt to the target repo's conventions instead of inventing new ones.
1. **Inspect the target app.** Read `package.json` and look at the existing folder structure (e.g. `src/features/*`, `src/components/*`, path aliases like `@/*`).
2. **Install missing dependencies** with the app's package manager (`npm`, `pnpm`, `yarn`, …). See the [Dependencies](#dependencies) table below for purposes and suggested versions. Reuse the React version already pinned by the app rather than upgrading it, and prefer any versions the repo already pins over the suggestions here.
3. **Copy the bundle into the app.** Copy every file from `skills/graph-viewer/code/` into an app-local feature folder, for example:
```text
src/features/graph-viewer/
```
If the repo already has a different feature/components layout or alias, mirror it.
4. **Import from the local folder**, never from `@skills/...`. With a typical `@/*` alias:
```tsx
import { useGraphViewer } from "@/features/graph-viewer";
```
5. **Render `GraphCanvas` inside a container with explicit dimensions** (height is required — see the minimal example below).
6. **Run typecheck and build** (`tsc --noEmit`, `npm run build`, etc.) and fix any path or type issues introduced by the copy.
## Minimal Example
```tsx
import { useGraphViewer } from "@/features/graph-viewer";
export function GraphPanel() {
const { GraphCanvas, isLoading, error } = useGraphViewer({
dataModel: { space: "my-space", externalId: "my-data-model", version: "1" },
instance: { space: "my-instance-space", externalId: "pump-001" },
});
if (isLoading) return <div>Loading graph…</div>;
if (error) return <div>Error: {error}</div>;
return <GraphCanvas className="h-[600px] w-full" />;
}
```
## Dependencies
Suggested versions reflect the latest published majors at the time of writing. They are starting points — if the target app already pins different versions, defer to the app.
| Package | Suggested version | Purpose |
| --------------- | ----------------- | ---------------------------------------------- |
| `react` | `^18.2.0` | UI framework (peer; reuse the app's version) |
| `@cognite/sdk` | `^10.10.0` | CDF API client (instances, data models) |
| `@cognite/dune` | `^2.1.0` | Provides the authenticated SDK via `useDune()` |
| `reagraph` | `^4.30.8` | WebGL graph rendering engine |
| `lucide-react` | `^1.14.0` | Icon set used by the node-type legend |
Example install (npm; adapt to the app's package manager):
```bash
npm install @cognite/sdk@^10.10.0 @cognite/dune@^2.1.0 reagraph@^4.30.8 lucide-react@^1.14.0
```
## CDF Cost & Performance
Graph expansion can issue many CDF requests, especially with reverse relations. For large or unfamiliar data models, be conservative:
- Set `whitelistedRelationProps` to the few properties the app actually needs to traverse.
- Lower `initialConnectionLimit` (it is a **hard maximum** of connections fetched per expansion).
- Lower `maxNodes` to bound the in-memory LRU buffer.
- Only declare `coreReverseQueries` for relations the app must surface; each entry adds an extra query per expansion.
Tuples in `coreReverseQueries` are **version-aware**:
`[space, viewExternalId, viewVersion, propertyName, isList]`.
## Advanced Reference
For full configuration tables, return-value docs, layouts, theming, and richer examples, read `code/README.md`.
For implementation details, inspect the source files under `code/`.
## Verification Checklist
- [ ] The app is wrapped in `<DuneProvider>`.
- [ ] All files from `skills/graph-viewer/code/` were copied into an app-local folder.
- [ ] Imports point to the app-local folder (e.g. `@/features/graph-viewer`), not `@skills/...`.
- [ ] `@cognite/dune`, `@cognite/sdk`, `reagraph`, and `lucide-react` are present in `package.json`.
- [ ] The container that renders `<GraphCanvas>` has an explicit height.
- [ ] `tsc --noEmit` and the app's build both pass.
- [ ] No references to `dune-industrial-components` were introduced.
@@ -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 };
}