# @mp70/react-networks

[![npm version](https://img.shields.io/npm/v/@mp70/react-networks.svg)](https://www.npmjs.com/package/@mp70/react-networks)
[![license](https://img.shields.io/npm/l/@mp70/react-networks.svg)](https://www.npmjs.com/package/@mp70/react-networks)
[![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/)

A React library for creating interactive network diagrams with support for rack management, fibre networks, and power distribution. Built on React Flow (`@xyflow/react` v12) with TypeScript support.

## Features

- **Rack Management**: Full support for 19" rack units with exact U positioning
- **Device Placement**: Drag and drop devices with automatic U position snapping
- **Fibre Networks**: Visual representation of fibre connections and patch panels
- **Power Distribution**: Vertical PDU support with power connections
- **Face Switching**: Front/rear face switching for devices and racks
- **Device Images**: Front/rear device images supported (PNG, SVG, JPEG, etc.) via `frontImageUrl` / `rearImageUrl` (`https://`, `blob:`, `data:image/*`, or relative URLs)
- **Rack Alignment**: Align rack bottoms to the same horizontal plane
- **Data Integration**: Built-in converters for generic inventory/CMDB data and NetBox, on server-safe subpath entries
- **Server-safe model entry**: Import the data model, adapters, and handle-id grammar in Node/SSR without pulling in React or React Flow
- **Customizable**: `--rn-*` CSS custom properties and theme class hooks
- **Responsive**: Works on desktop and mobile devices
- **TypeScript**: Full TypeScript support with comprehensive type definitions

## Quick Start

### Installation

Install the library and its peer dependencies:

```bash
npm install @mp70/react-networks react react-dom @xyflow/react
# or
yarn add @mp70/react-networks react react-dom @xyflow/react
# or
pnpm add @mp70/react-networks react react-dom @xyflow/react
```

### Peer dependencies

`@mp70/react-networks` renders on React Flow v12 (`@xyflow/react`), not the
legacy `reactflow` v11 package. The required peers are:

| Peer | Range | Notes |
| --- | --- | --- |
| `react` | `^18 \|\| ^19` | |
| `react-dom` | `^18 \|\| ^19` | |
| `@xyflow/react` | `^12` | The React Flow v12 engine. Do **not** install the old `reactflow` package. |

`@xyflow/system` is **not** a peer dependency: nothing in the shipped code or
type surface references it, and `@xyflow/react` pulls in whatever version it
needs. If a previous version of these instructions had you install it
explicitly, you can safely remove it.

You must import **two** stylesheets — React Flow's own base styles and this
library's styles — or the diagram renders unstyled:

```tsx
import '@xyflow/react/dist/style.css';
import '@mp70/react-networks/index.css';
```

The library's CSS is emitted as a standalone file (it is **not** injected by
importing the JS entry), so the `@mp70/react-networks/index.css` import is
required, not optional.

### Basic Usage

```tsx
import React from 'react';
import '@xyflow/react/dist/style.css';
import '@mp70/react-networks/index.css';
import { NetworkDiagram } from '@mp70/react-networks';
import type { NetworkNode, NetworkEdge } from '@mp70/react-networks';

const nodes: NetworkNode[] = [
  {
    id: 'rack-1',
    type: 'rack',
    position: { x: 100, y: 100 },
    data: {
      label: 'Main Rack',
      uHeight: 42
    }
  },
  {
    id: 'server-1',
    type: 'server',
    // Rack children are placed by `data.uPosition`; the library derives the
    // pixel position inside the rack, so {x: 0, y: 0} is fine here.
    parentId: 'rack-1',
    position: { x: 0, y: 0 },
    data: {
      label: 'Web Server',
      uPosition: 1,
      ports: [
        { id: 'eth0', label: 'eth0', group: 'Network Ports', connected: true, type: 'ethernet' },
        { id: 'eth1', label: 'eth1', group: 'Network Ports', connected: false, type: 'ethernet' }
      ]
    }
  }
];

const edges: NetworkEdge[] = [];

function App() {
  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <NetworkDiagram
        nodes={nodes}
        edges={edges}
        alignRacksToBottom={true}
        onNodeClick={(node) => console.log('Node clicked:', node)}
        onEdgeClick={(edge) => console.log('Edge clicked:', edge)}
      />
    </div>
  );
}

export default App;
```

## Entry points

The package ships six JavaScript entry points. Import from the narrowest one
that fits. The model, NetBox, inventory and validation entries are server-safe
and pull in **zero** React/React Flow runtime code. The root and OTDR entries
contain React client components; the OTDR entry does not pull in React Flow.

| Import | What it is | Server-safe |
| --- | --- | --- |
| `@mp70/react-networks` (`.`) | The React components (`NetworkDiagram`, `FibreFlowMap`, `DiagramErrorBoundary`, `OtdrTraceStrip`), plus the diagram utilities, hooks, and types. | No — pulls React + React Flow. |
| `@mp70/react-networks/otdr` | Just the OTDR loss-budget strip: `OtdrTraceStrip`, its types, and `formatOtdrDistance` / `otdrEventKindLabel` / `otdrTraceStripPalette`. No React Flow. | No — a React client component (but pulls no canvas). |
| `@mp70/react-networks/model` | The pure data model: core types, the handle-id grammar, port-contract, port-position, document adapters, path-layout, and the fibre connection policy. | Yes — zero React/React Flow at runtime. |
| `@mp70/react-networks/netbox` | NetBox → diagram converters (`netboxToDiagramDocument`, `netboxToNetworkDiagram`, …) and the `NetBox*` DTO types. | Yes. |
| `@mp70/react-networks/inventory` | Generic CMDB/inventory converters (`inventoryToDiagramDocument`, `inventoryToNetworkDiagram`, …) and the `Inventory*DTO` types. | Yes. |
| `@mp70/react-networks/validation` | Power validation engine, graph helpers, IEC 60320 tables, and the opt-in `standardPowerRules` preset. | Yes. |
| `@mp70/react-networks/index.css` | The stylesheet (see [Custom styling](#custom-styling)). | n/a |

```ts
// Just the OTDR strip — React, but none of the React Flow canvas.
import { OtdrTraceStrip } from '@mp70/react-networks/otdr';
```

```ts
// In a Node/SSR API route — no React, no React Flow pulled in.
import { networkGraphToDiagramDocument } from '@mp70/react-networks/model';
import { netboxToDiagramDocument } from '@mp70/react-networks/netbox';
import { inventoryToDiagramDocument } from '@mp70/react-networks/inventory';
import { runPowerValidation } from '@mp70/react-networks/validation';
```

## Server-side usage

**Import pure helpers and types from `@mp70/react-networks/model` (or
`/netbox` / `/inventory` / `/validation`), not from the root
`@mp70/react-networks`.**

The root entry carries the `'use client'` directive and pulls in React and the
React Flow runtime. Server code — API routes, SSR loaders, cron jobs, migration
scripts — that only needs the data model, adapters, handle-id/node-id grammar,
or pure geometry should import from the server-safe `/model` subpath so none of
the canvas stack lands in the server bundle. Everything on `/model` is
tree-shakeable and framework-free.

```ts nocheck
// ✅ server-safe — zero React / React Flow at runtime
import {
  networkGraphToDiagramDocument,
  buildPortHandleId,
  handlesMatch,
  calculateSplicePositionFromNumber,
} from '@mp70/react-networks/model';

// ❌ in server code this drags in React + React Flow
import { networkGraphToDiagramDocument } from '@mp70/react-networks';
```

### Enforce it with ESLint

Add a `no-restricted-imports` rule scoped to your server globs so a stray root
import fails lint instead of bloating the server bundle. Copy-paste (flat
config):

```js
// eslint.config.js (flat config)
export default [
  {
    files: ['**/server/**', '**/api/**', '**/*.server.{ts,tsx}'],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          paths: [
            {
              name: '@mp70/react-networks',
              message:
                'Server code must import from @mp70/react-networks/model (or /netbox, /inventory) — the root entry pulls in React + React Flow.',
            },
          ],
        },
      ],
    },
  },
];
```

The same rule in legacy `.eslintrc` form, an `overrides` variant, and a
`typescript-eslint` note live in
[docs/eslint-server-imports.md](./docs/eslint-server-imports.md).

## Documentation

### Data Model

`@mp70/react-networks` supports two control layers:

- `document` / `initialDocument` / `onDocumentChange` use `DiagramDocument`, the
  canonical persisted/editor schema. Prefer this for saved diagrams,
  import/export, and external integrations. `DiagramDocument` now carries a
  required `version: 1` discriminator (see [CHANGELOG](./CHANGELOG.md)).
- `nodes` / `edges` remain the lower-level React Flow-shaped control surface when
  you want direct control of rendered nodes and edges.

For device-style nodes, prefer the flat `ports: DevicePort[]` input. Use
`portGroups` and `rearPortGroups` only when you need explicit grouped front/rear
layout control.

Port placement is optional. Ports without a resolvable position are distributed
automatically across their device face, even when they belong to different
groups. `{ side: 'right' }` fixes the edge and distributes the missing offset
with other right-side ports on that face. Exact image placement requires both
`xPercent` and `yPercent`; a lone coordinate falls back to automatic placement.
Explicit offsets and complete coordinate pairs are used as authored, including
intentional overlaps.
Automatic power connectors stay within the device face. If a short face has
more connectors than can fit without touching, they share the available
vertical span and may overlap rather than overflowing the face.

### Rendering primitives

You render diagrams through `NetworkDiagram` and `FibreFlowMap`. The individual
node/edge components (rack, device, PDU, splice, tube, cable, coupler, patch
panel, closure, fibre-split, fibre edge, power edge, …) are registered
**internally** by those components and are not part of the public surface — do
not mount them directly.

### API Reference

#### Components

- `NetworkDiagram` — main diagram component
- `FibreFlowMap` — standalone fibre-flow diagram (closures, cables, tubes, splices, circuits)
- `DiagramErrorBoundary` — error boundary for diagram errors

#### Utilities (selected)

**Rack schema:**
- `buildNodesFromRackConfig` / `createRackConfigFromNodes`
- `addDeviceToRack` / `removeDeviceFromRack` / `updateDeviceUPosition`
- `validateRackDevicePlacements`

**Splice schema:**
- `buildNodesFromSpliceConfig` / `createSpliceConfigFromNodes`
- `addSpliceToTray` / `removeSpliceFromTray` / `updateSpliceHolderPosition`
- `validateSplicePlacements`

**U positioning:** `snapToUPosition`, `isUPositionAvailable`, `findNextAvailableUPosition`, `calculateDevicePositionFromU`, `validateAndSnapDevice`

**Data integration** (on the server-safe subpaths — see [Entry points](#entry-points)):
- `inventoryToDiagramDocument` / `inventoryToNetworkDiagram` (`/inventory`)
- `netboxToDiagramDocument` / `netboxToNetworkDiagram` (`/netbox`)

**Power:** `getPowerPortStyle`, `getConnectorConfig`, `getDeviceConnectorType`, `getPDUPortType`, `isHighPowerConnector`, `POWER_CONNECTORS`

**Fibre colours:** `FIBRE_COLORS_12`, `baseColorFor`, `isStriped`, `fibreSolidOrStriped`, `getFibreColor`

**Rack geometry:** `getRackBounds`, `isPointInRack`, `findNearestRack`

**Constants:** `U_HEIGHT_PX`, `RACK_HEADER_HEIGHT`, `RACK_WIDTH_PX`, `HANDLE_EXTENSION_PX`

**Status:** `getStatusColor`

**Edges:** `replaceEdge`

**Connection validation** (pure functions): `validateNetworkConnection`, `computeEdgeZIndex`, `createNodeMap`, `getHandleSide`

**Handle & node identity** (pure; on `/model`): `handlesMatch`, `findEdgesForPortHandle`, `encodeHandle` / `decodeHandle` (structured `Handle` type), `buildPortHandleId` / `buildFibreHandleId` / `buildTubeHandleId` / `buildSpliceHandleId` (+ siblings), `buildRackNodeId` / `buildDeviceNodeId` / `parseRackChildNodeId` / `isRackNode` / `isDeviceNode` / `getNodeKind`. See [API.md § Handle & node identity](./docs/API.md#handle--node-identity). `sanitizeHandleIdForMatching` is deprecated for matching — use `handlesMatch`.

**Fibre scene geometry** (pure; on `/model`): `calculateSplicePositionFromNumber` plus the `HOLDER_HEIGHT_PX` / `TRAY_HEADER_HEIGHT` / `TRAY_*` layout constants, for hand-composing closure/tray/splice scenes that line up with rendered nodes.

> The full type-level surface is enforced by API Extractor reports
> (`etc/*.api.md`) generated per entry point. Those reports are the source of
> truth for what is public.

### NetworkDiagram Props

For new code, prefer the document-model props. The graph props and React Flow escape hatches remain available for advanced integrations and compatibility with older code.

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `document` | `DiagramDocument` | - | Controlled canonical diagram document. Takes precedence over `nodes` / `edges`. |
| `initialDocument` | `DiagramDocument` | - | Initial canonical document for uncontrolled document mode. |
| `onDocumentChange` | `(document: DiagramDocument) => void` | - | Callback when the canonical document changes. |
| `alignRacksToBottom` | `boolean` | `false` | Align rack bottoms to baseline |
| `onNodeClick` | `(node: NetworkNode \| null) => void` | - | Node click / selection handler |
| `onEdgeClick` | `(edge: NetworkEdge) => void` | - | Edge click handler |
| `onFaceChange` | `(nodeId: string, face: 'front' \| 'rear') => void` | - | Device face change handler |
| `onRackFaceChange` | `(rackId: string, face: 'front' \| 'rear') => void` | - | Rack face change handler |
| `className` | `string` | - | CSS class name |
| `style` | `React.CSSProperties` | - | Inline styles |
| `readOnly` | `boolean` | `false` | Force lock diagram (no drag/connect/delete/reconnect/pan/zoom) |
| `interaction` | `NetworkDiagramInteractionOptions` | - | Fine-grained interaction controls for editing, pan, zoom, minimap, and controls |
| `actionPanel` | `NetworkDiagramActionPanelOptions` | - | Configure action panel (enabled, showRearToggle, showDeRack, showZoomToEdit). Select a rack and use Zoom to edit for larger U rows; Fit returns to the full scene |
| `nodes` | `NetworkNode[]` | `[]` | Advanced: controlled lower-level graph nodes |
| `edges` | `NetworkEdge[]` | `[]` | Advanced: controlled lower-level graph edges |
| `onNodesChange` | `(nodes: NetworkNode[]) => void` | - | Advanced: callback when lower-level nodes change |
| `onEdgesChange` | `(edges: NetworkEdge[]) => void` | - | Advanced: callback when lower-level edges change |
| `initialNodes` | `NetworkNode[]` | - | Advanced: initial lower-level nodes |
| `initialEdges` | `NetworkEdge[]` | - | Advanced: initial lower-level edges |
| `nodeTypes` | `NodeTypes` | - | Custom React Flow node types |
| `edgeTypes` | `EdgeTypes` | - | Custom React Flow edge types |
| `onConnect` | `(connection: Connection) => void` | - | Connection handler override |
| `onEdgeUpdate` | `(oldEdge: Edge, newConnection: Connection) => void` | - | Edge update (reconnect) override |
| `isValidConnection` | `(connection: Connection) => boolean` | - | Connection validation override |
| `connectionMode` | `ConnectionMode` | - | React Flow connection mode |
| `connectionRadius` | `number` | React Flow default | Pointer snap distance (px) for handles. Raise to make small ports easier to target; lower to avoid mis-targeting on dense devices |
| `onConnectRejected` | `(payload: NetworkConnectionRejection) => void` | - | Fired when a connect/reconnect attempt is rejected, so you can surface feedback instead of failing silently |
| `reAssignable` | `boolean` | `true` | Allow devices to move between racks / ungroup on drag |
| `colorMode` | `'light' \| 'dark' \| 'system'` | - | Diagram color mode |
| `onInit` | `(instance: ReactFlowInstance) => void` | - | Called when React Flow is ready |
| `onFlowMethods` | `(methods: NetworkDiagramFlowMethods) => void` | - | Receive toObject/setViewport/toImage/fitView etc. |
| `fitViewOnInit` | `boolean` | `true` | Fit the complete scene once, after every mounted node has been measured. Pass `false` (or use `initialViewport: { mode: 'restore' }`) to keep a restored viewport. |
| `showDownloadButton` | `boolean` | - | Show export image button |
| `downloadButtonPosition` | `'top-right' \| 'top-left' \| 'bottom-right' \| 'bottom-left'` | - | Download button position |
| `useSmoothstepEdgesForTubes` | `boolean` | `false` | Use smoothstep edges for tube connections |
| `debug` | `boolean` | `false` | Enable debug mode |
| `fitViewOptions` | `FitViewOptions` | - | React Flow fitView options |
| `highlightGroups` | `HighlightGroup[]` | - | Declarative multi-group emphasis: colour sets of node/edge IDs and (with `dimUnhighlighted`) dim the rest. See [Highlight groups](./docs/API.md#highlight-groups) |
| `highlightedNodeIds` | `string[]` | - | Node IDs kept at full opacity when focus dimming is active |
| `highlightedEdgeIds` | `string[]` | - | Edge IDs kept at full opacity when focus dimming is active |
| `dimUnhighlighted` | `boolean` | `false` | Dim nodes/edges not in the focus ID lists |
| `dimOpacity` | `number` | `0.25` | Opacity applied to dimmed elements (0-1) |

### Edge highlighting

Per-edge emphasis uses `NetworkEdge.data`:

```tsx
{
  id: 'edge-1',
  source: 'switch-1',
  target: 'server-1',
  type: 'fiber',
  data: {
    highlight: true,
    highlightColor: '#dc2626',
    highlightWidth: 5,
    label: 'Cable A',
  },
}
```

Diagram-level focus (trace, DRC row click):

```tsx
<NetworkDiagram
  nodes={nodes}
  edges={edges}
  highlightedEdgeIds={focusedEdgeIds}
  highlightedNodeIds={focusedNodeIds}
  dimUnhighlighted
/>
```

For multi-severity emphasis (several colours at once, dim the rest) prefer the
declarative `highlightGroups` prop over hand-maintaining per-edge `highlight*`
data. Pass groups highest-severity first; when an element is in more than one
group the earliest wins:

```tsx
<NetworkDiagram
  nodes={nodes}
  edges={edges}
  dimUnhighlighted
  highlightGroups={[
    { edgeIds: errorEdgeIds, color: '#dc2626', width: 5, label: 'Error' },
    { edgeIds: warnEdgeIds, color: '#d97706', width: 3, label: 'Warning' },
  ]}
/>
```

The library does not define severity enums. Apps pick `highlightColor` / group
`color` hex values. Common conventions:

| Meaning | Suggested `highlightColor` |
|---|---|
| Error | `#dc2626` |
| Warning | `#d97706` |
| Complete | `#16a34a` |
| Installer power step | `#f97316` |
| Installer network step | `#ef4444` |

Focus dimming survives a `diagramDocumentToNetworkGraph` round-trip: the
exported `applyDiagramFocusToNodes` / `applyDiagramFocusToEdges` helpers write
opacity to node/edge `style` and, for edges, to `data.opacity` as well, so a
dimmed edge stays dimmed after conversion. Use the library helpers rather than
app-local copies.

Pass `showDeviceImages={false}` on `NetworkDiagram` to hide rack photos; status border colors from
`getStatusColor(status)` still apply. Use `onPortClick` and `highlightedPortHandles` for port trace UX.

Rack-mounted device images default to `imageFit: 'contain'`, preserving the image's
aspect ratio and keeping authored percentage port positions aligned with the
rendered image. Standalone devices retain the `cover` default. Set `imageFit: 'fill'`
or `stretchToFit: true` explicitly to stretch an image to the device bounds.
An image's bounds may include whitespace or mounting ears; aspect-preserving fit
does not establish its physical chassis or port dimensions.

### NetworkNode Types

```tsx
type NetworkNodeType =
  | 'rack'
  | 'switch'
  | 'router'
  | 'server'
  | 'fiber'
  | 'patch-panel'
  | 'device'
  | 'vertical-pdu'
  | 'splice'
  | 'splice-tray'
  | 'tube'
  | 'cable'
  | 'multi-tube-cable'
  | 'coupler'
  | 'closure'
  | 'fibre-split'
  | 'fibre-flow-cable'
  | 'fibre-flow-closure';
```

> Note: the `'fiber'` node-type string and edge-data keys such as `fiberId` /
> `ribbonFiberIds` are serialized wire values and are deliberately **unchanged**
> by the `Fiber` → `Fibre` symbol rename (see [CHANGELOG](./CHANGELOG.md)).

### NetworkEdge Types

```tsx
type NetworkEdgeType = 'fiber' | 'ethernet' | 'power' | 'smoothstep' | 'step' | 'thick-cable' | 'fibre-flow' | 'fibre-flow-link';
```

## Advanced Usage

### Rack Configuration

```tsx
import { RackConfig, buildNodesFromRackConfig, Width } from '@mp70/react-networks';

const rackSchema: RackConfig[] = [
  {
    id: 'rack-1',
    name: 'Main Rack',
    position: { x: 100, y: 100 },
    units: 42,
    devices: [
      {
        id: 'server-1',
        name: 'Web Server',
        unit: 1,
        height: 1,
        type: 'server',
        width: Width.FULL,
        ports: [
          { id: 'eth0', label: 'eth0', group: 'Network Ports', connected: true, type: 'ethernet' },
          { id: 'eth1', label: 'eth1', group: 'Network Ports', connected: false, type: 'ethernet' }
        ]
      },
      {
        id: 'switch-1',
        name: 'Core Switch',
        unit: 3,
        height: 1,
        type: 'switch',
        width: Width.FULL,
        ports: [
          { id: 'port-1', label: 'port-1', group: 'Uplink Ports', connected: true, type: 'fiber' },
          { id: 'port-2', label: 'port-2', group: 'Uplink Ports', connected: false, type: 'fiber' }
        ]
      }
    ]
  }
];

const nodes = buildNodesFromRackConfig(rackSchema);
```

For the simplest device API, pass a flat `ports: DevicePort[]` array and set `group` / `face` per port. Use `portGroups` and `rearPortGroups` only when you need explicit grouped front/rear control.

Device images (e.g. server front/rear photos or SVGs) use `frontImageUrl` and `rearImageUrl` on each device; supported formats include PNG, SVG, JPEG, GIF, and WebP.

### Inventory Integration

The inventory and NetBox converters live on server-safe subpaths, so you can run
them in a Node/SSR import pipeline without loading React or React Flow:

```tsx
import { inventoryToNetworkDiagram } from '@mp70/react-networks/inventory';

const { nodes, edges } = inventoryToNetworkDiagram({
  racks: [{ id: 1, name: 'Rack A1', u_height: 42 }],
  devices: [
    { id: 10, name: 'web-01', rack: { id: 1 }, position: 10, u_height: 1 },
    { id: 11, name: 'tor-01', rack: { id: 1 }, position: 40, u_height: 1 },
  ],
  cables: [
    {
      id: 100,
      termination_a: { device: { id: 10 }, name: 'eth0' },
      termination_b: { device: { id: 11 }, name: 'xe-0/0/1' },
    },
  ],
});
```

To produce the canonical persisted document instead, use
`inventoryToDiagramDocument` (or `netboxToDiagramDocument` from
`@mp70/react-networks/netbox`).

### NetBox

The NetBox converters target the **NetBox 4.5** REST API. Pass the lists as
NetBox returns them. A device listing only nests a brief device type (no
`u_height`), and interfaces live on their own endpoint, so pass those lists
too to get device heights and ports:

```ts
import { netboxToNetworkDiagram } from '@mp70/react-networks/netbox';

// `token` is a NetBox 4.5 v2 API token (`<key>.<token>`); for a v1 token send
// `Token <token>` instead of `Bearer …`.
export async function loadNetBoxDiagram(baseUrl: string, token: string) {
  const get = async (path: string) => {
    const results = [];
    let url: string | null = `${baseUrl}/api/dcim/${path}/?limit=1000`;
    while (url) {
      const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
      const page = await response.json();
      results.push(...page.results);
      url = page.next; // NetBox paginates; follow `next` until it is null
    }
    return results;
  };

  return netboxToNetworkDiagram(
    {
      racks: await get('racks'),
      devices: await get('devices'),
      cables: await get('cables'),
      deviceTypes: await get('device-types'), // device heights
      interfaces: await get('interfaces'), // ports, with includeInterfacePorts
    },
    { includeInterfacePorts: true },
  );
}
```

Without `deviceTypes` every device is drawn 1U; without interface ports,
cables attach to the device instead of a port.

### Custom styling

The stylesheet is emitted as a separate file (it is not injected by importing
the JS entry), so a consumer **must** import it once, alongside React Flow's own
stylesheet:

```tsx
import '@xyflow/react/dist/style.css';
import '@mp70/react-networks/index.css';
```

Without both imports the diagram renders unstyled.

## Theming

The supported theming API is (1) two theme **class hooks** you place on a wrapper
element and (2) a set of `--rn-*` **CSS custom properties**. These names are
stable and covered by semver from 1.0 onward; nothing else in the stylesheet is
a public contract.

### Theme classes

| Class | Purpose |
| --- | --- |
| `react-flow-dark` | Selects the dark palette for the canvas, controls, and minimap. |
| `react-flow-light` | Selects the light palette. |
| `react-networks-attribution` | Hook for styling the attribution badge. |

Apply `react-flow-dark` or `react-flow-light` to an ancestor of the diagram to
switch palettes; each class sets the `--rn-flow-*` variables below.

### Theme override points

These variables have built-in defaults and are **not** written as inline styles
by the components. `NetworkDiagram` puts the resolved theme class on its outer
wrapper. Place overrides on that same element, using the `className` prop and a
rule loaded after the library stylesheet:

```css
/* Canvas / controls / minimap palette (set by the theme classes; override to
   retint). */
.my-diagram.react-flow-dark,
.my-diagram.react-flow-light {
  --rn-flow-background: #1a1a1a;
  --rn-flow-text: #ffffff;
  --rn-flow-border: #404040;
  --rn-flow-controls-bg: #2d2d2d;
  --rn-flow-controls-border: #404040;
  --rn-flow-minimap-bg: #2d2d2d;
  --rn-flow-minimap-border: #404040;

  /* Keyboard focus ring, drawn on ports, bays, node buttons and splice nodes
     via :focus-visible (keyboard focus only — a mouse click never draws it).
     Light default: #1d4ed8; dark default: #60a5fa. */
  --rn-focus-ring: #60a5fa;
}

/* Device node base colours (override to restyle). */
.my-diagram.react-flow-dark,
.my-diagram.react-flow-light {
  /* UI corners are 1px by default; use 0 for fully square corners. */
  --rn-ui-radius: 1px;
  --rn-device-bg-color: #1f2937;
  --rn-device-text-color: white;
}
```

### Design tokens

Every node type paints its surfaces, borders, text, status and selection
colours through the tokens below. They are declared in **both** theme blocks,
so `.react-flow-light` genuinely yields light node bodies (previously the rack,
PDU, splice-tray and fibre nodes hardcoded dark slabs). Override them on the
theme-bearing diagram element, such as `.my-diagram.react-flow-dark`. A value on
`:root` or an outer ancestor is inherited only until the nearer theme class
declares that token, so it does not override an explicitly themed diagram.

When neither theme class is present the light values apply, and
`@media (prefers-color-scheme: dark)` swaps in the dark values — so an
un-themed or pre-hydration shell follows the OS. An explicit
`.react-flow-light` / `.react-flow-dark` always wins over that fallback.

| Variable | Purpose | Light | Dark |
| --- | --- | --- | --- |
| `--rn-surface` | Default node body (fibre, patch panel, closure, coupler, tray) | `#ffffff` | `#1f2937` |
| `--rn-surface-raised` | Chips, buttons and headers sitting on a node body | `#f3f4f6` | `#374151` |
| `--rn-surface-sunken` | Cable/tube node bodies | `#e5e7eb` | `#111827` |
| `--rn-border` | Default 1px node/chip border | `#d1d5db` | `#4b5563` |
| `--rn-border-strong` | Higher-contrast border (outlet bezels, bay outlines) | `#9ca3af` | `#6b7280` |
| `--rn-border-subtle` | Low-contrast divider | `#e5e7eb` | `#374151` |
| `--rn-text` | Default node text | `#1f2937` | `#e5e7eb` |
| `--rn-text-muted` | Secondary text (manufacturer, U numbers, attribution) | `#6b7280` | `#9ca3af` |
| `--rn-text-strong` | Text on an active/status-filled control | `#111827` | `#ffffff` |
| `--rn-text-on-accent` | Text printed on a bright port/outlet colour | `#1a1a1a` | `#1a1a1a` |
| `--rn-accent` | Accent (drag-over glow) | `#2563eb` | `#3b82f6` |
| `--rn-accent-selected` | Accent variant reserved for selected chrome | `#2563eb` | `#3b82f6` |
| `--rn-selection-ring` | The 2px ring drawn by `.rn-node--selected` | `#2563eb` | `#3b82f6` |
| `--rn-status-ok` | `active` status | `#059669` | `#10b981` |
| `--rn-status-ok-border` | Border pairing for `--rn-status-ok` | `#047857` | `#059669` |
| `--rn-status-warn` | `maintenance` status | `#d97706` | `#f59e0b` |
| `--rn-status-error` | `inactive` status | `#dc2626` | `#ef4444` |
| `--rn-status-unknown` | Unset/other status, disconnected ports | `#6b7280` | `#6b7280` |
| `--rn-rack-body` | Rack header/chassis fill | `#e5e7eb` | `#1f2937` |
| `--rn-rack-rail` | Rack U-marker rail and header buttons | `#e5e7eb` | `#374151` |
| `--rn-rack-accent` | Custom rack header text | `#b45309` | `#fbbf24` |
| `--rn-device-body` | Device node body (also the split-face fill) | `#f9fafb` | `#1f2937` |
| `--rn-device-label` | Device node label text | `#1f2937` | `#ffffff` |
| `--rn-pdu-body` | PDU management-unit body | `#e5e7eb` | `#1a1a1a` |
| `--rn-pdu-chassis-highlight` | PDU chassis gradient highlight stop | `#f3f4f6` | `#4a5568` |
| `--rn-pdu-chassis-mid` | PDU chassis gradient mid stop | `#e5e7eb` | `#2d3748` |
| `--rn-pdu-chassis-shadow` | PDU chassis gradient shadow stop | `#d1d5db` | `#1a202c` |
| `--rn-pdu-outlet` | PDU outlet face | `#ffffff` | `#ffffff` |
| `--rn-pdu-outlet-border` | PDU outlet bezel | `#9ca3af` | `#6b7280` |
| `--rn-pdu-header-text` | PDU header label | `#1f2937` | `#e2e8f0` |

`getStatusColor(status)` returns the matching `var(--rn-status-*, <hex>)`
string, so status borders follow the theme wherever they are applied as CSS.
Use `getStatusColorHex(status)` when you need a parseable hex (canvas, SVG
export, colour maths).

### Selection

Selected nodes carry the `rn-node--selected` class, which draws a single 2px
`--rn-selection-ring` ring for every node type. Restyle selection once by
overriding that variable, or by targeting `.rn-node--selected`.

### Set by the components — not `:root` override points

The following variables are written **unconditionally as inline styles** by the
components (per device, per edge, or per handle) from node/edge data. An inline
custom property shadows any `:root` declaration, so overriding these in a
`:root` block has **no effect** — style the source data (device `status`,
images, edge `highlight*`, etc.) or target the elements directly instead:

| Variable(s) | Set by | Driven from |
| --- | --- | --- |
| `--rn-device-bg-image`, `--rn-device-bg-position`, `--rn-device-bg-repeat`, `--rn-device-bg-size` | Device node | `frontImageUrl` / `rearImageUrl` + `imageFit`/`imagePosition`/`imageRepeat` |
| `--rn-device-border-color`, `--rn-device-status-color` | Device node | `getStatusColor(status)` |
| `--rn-device-box-shadow` | Device node | ambient device shadow (selection now comes from `.rn-node--selected`) |
| `--rn-edge-highlight-from`, `--rn-edge-highlight-to` | Edge components | per-edge highlight stroke width |
| `--rn-hit-expand-top` / `-right` / `-bottom` / `-left` | Device node handles | per-port hit-area expansion |
| `--rn-fibre-bg`, `--rn-fibre-bg-image` | Fibre/tube handles | per-handle cable/tube fibre colour |

## Performance

- **Large canvases: enable `onlyRenderVisibleElements`.** React Flow viewport
  culling keeps multi-rack floor and ODF-scale surfaces responsive. It is safe
  to leave on for image export — the library force-disables culling for the
  duration of a `toImage` render so off-screen nodes still appear in the PNG,
  then restores the setting (see
  [Export-safe culling](./docs/API.md#export-safe-culling)). Leave it off for a
  single-rack editor, where everything is on-screen anyway.
- **Document-mode drag cost is memoized.** When you drive `NetworkDiagram` with
  `document` / `onDocumentChange`, endpoint derivation is WeakMap-memoized on
  `node.data` identity, so a position-only change during a drag frame skips
  re-deriving (and deep-cloning) ports. If your host already holds
  `NetworkNode[]` / `NetworkEdge[]`, you can also render `nodes` / `edges` with
  `onNodesChange` directly and skip the document round-trip entirely.

## Public API & stability

From 1.0 the following are the public, semver-covered surface:

- **JavaScript / TypeScript exports** of the six entry points: `.`
  (components + diagram utilities), `./otdr`, `./model`, `./netbox`, `./inventory`, and
  `./validation`.
  What each exports is pinned by committed API Extractor reports (`etc/*.api.md`).
- **CSS hooks**: the `--rn-*` custom properties (including the `--rn-otdr-*`
  OTDR strip tokens) and the `react-flow-dark`, `react-flow-light`,
  `rn-otdr-dark`, `rn-otdr-light`, and `react-networks-attribution` class
  names.
- **Handle-id strings** are a **frozen wire format** (used inside React Flow
  handles). Build/parse them only through the handle-id grammar exports
  (`buildPortHandleId`, `parseHandleId`, `encodeHandle` / `decodeHandle`, …); do
  not hand-construct or hand-parse them. To test whether two handle strings
  denote the same handle, use `handlesMatch` (or `findEdgesForPortHandle` for the
  edge lookup); `sanitizeHandleIdForMatching` is deprecated for matching,
  intentionally many-to-one, and must never be persisted. For persisted identity,
  reference `DiagramEndpoint.id` (an opaque, relabel-stable key) — the handle
  string is derived from the endpoint at the rendering boundary, so persist the
  endpoint id, not the handle.

Anything not listed above (internal node/edge components, other CSS selectors,
DOM structure) is an implementation detail and may change without a major bump.

## Development

### Prerequisites

- Node.js 22+
- npm, yarn, or pnpm

### Setup

```bash
# Clone the repository, then:
cd react-networks/packages/network-diagrams
npm install
```

### Build

```bash
npm run build
```

### Development

```bash
npm run dev
```

### Testing

```bash
npm test
```

### Linting

```bash
npm run lint
```

## Distribution

The library is distributed as:

- **ES Modules**: `dist/index.mjs`
- **CommonJS**: `dist/index.js`
- **TypeScript**: `dist/index.d.ts` (and `.d.mts` for ESM)
- **CSS**: `dist/index.css` (imported via `@mp70/react-networks/index.css`)

The `./otdr` client entry and the server-safe subpath entries (`./model`,
`./netbox`, `./inventory`, `./validation`) ship the same `.mjs` / `.js` / `.d.mts` / `.d.ts`
set.

## Contributing

We welcome contributions! See `CONTRIBUTING.md` for details.

### Development Workflow

1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Run the test suite
6. Submit a pull request

## License

Dual-licensed under:

- **AGPL-3.0-only** (Free) - For open source, personal, and educational use. Copyleft applies.
- **Commercial** (`LicenseRef-Commercial`) - For proprietary/commercial use without copyleft obligations.

The SPDX license expression is `AGPL-3.0-only OR LicenseRef-Commercial`. See
`../../LICENSE` for details. By installing, you agree to use under either license.

## Acknowledgments

- React Flow for the diagram foundation

---

Made by [matt](https://github.com/MP70) from [rackout.net](https://rackout.net).
