Diagrams
Dependency-free, token-themed relationship diagrams — flow, network, org chart, indented tree, mind map and fishbone.
Preview
A dependency-free SVG network graph. Pass `:nodes` (`id`, `label`, normalised `x`/`y` 0..1, optional `group`) and `:links` (`source`, `target`) to plot your own graph; node radius scales with degree. `layout="force"` settles the positions live and lets you drag nodes, `layout="3d"` orbits the graph in perspective.
<div class="space-y-4">
<x-ui.network-graph :height="360" label="{{ __('Service dependency graph') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free SVG network graph. Pass `:nodes` (`id`, `label`, normalised `x`/`y` 0..1, optional `group`) and `:links` (`source`, `target`) to plot your own graph; node radius scales with degree. `layout="force"` settles the positions live and lets you drag nodes, `layout="3d"` orbits the graph in perspective.') }}
</p>
</div>
Installation
php artisan ui:add network-graph
Note
This component ships an Alpine behavior module at
resources/js/ui/network-graph.js. Import it once from your bundle so it registers on alpine:init:
import './network-graph.js';
Registry contract
php artisan ui:add network-graph
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/network-graph.blade.php -
resources/js/ui/network-graph.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Network Graph (`network-graph`)
A dependency-free, token-themed SVG network graph that plots nodes at precomputed positions with degree-scaled radii, group-coloured clusters, and hover tooltips — no force simulation.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add network-graph
```
## Usage
```blade
<div class="space-y-4">
<x-ui.network-graph :height="360" label="{{ __('Service dependency graph') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free SVG network graph. Pass `:nodes` (`id`, `label`, normalised `x`/`y` 0..1, optional `group`) and `:links` (`source`, `target`) to plot your own graph; node radius scales with degree. `layout="force"` settles the positions live and lets you drag nodes, `layout="3d"` orbits the graph in perspective.') }}
</p>
</div>
```
## Props
- `nodes` (mixed|null, default `null`) — Nodes to plot: each an object with id, label, x/y (0..1, used in static layout), and optional group (0-based, for cluster colouring) and size. Defaults to a demo service dependency graph.
- `links` (mixed|null, default `null`) — Connections between nodes: each an object with source and target node ids.
- `height` (int, default `360`) — SVG height in pixels; width fills the container.
- `label` (string, default `Network graph`) — Accessible name for the figure.
- `layout` (string, default `static`) — static uses the precomputed x/y positions as given; force runs a live simulation that settles the layout and lets nodes be dragged; 3d runs the same simulation in three dimensions, projected with perspective and slowly orbiting, draggable to turn it.
## Use when
- Use when hierarchy, causality, network structure, or process flow is the information itself.
- Visualising relationships between entities with no strict hierarchy, such as a service dependency map.
- Wanting a live force-directed or draggable 3D layout, or a precomputed static layout, from the same component.
## Avoid when
- Do not use specialized diagrams when a list, table, or short paragraph would be easier to interpret.
- The relationships are a strict one-parent tree; use org-chart or mind-map instead.
- The connections flow through staged columns rather than a free graph; use flow-diagram or chart-sankey instead.
## Anti-patterns
- Using a specialized diagram when linear content is clearer
## Rules
- Use the `<brok:network-graph>` tag (or `<x-ui.network-graph>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/network-graph
- Registry JSON (files, props, contract): https://brokui.dev/r/open/network-graph.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<x-ui.network-graph layout="force" :height="360" label="{{ __('Service dependency graph, force-directed') }}" />
{{-- The default call graph framed by chart-card with a node-count badge. --}}
<x-ui.chart-card heading="Service dependencies" subheading="Inter-service call graph" trend="12 nodes" trend-variant="neutral">
<x-ui.network-graph :height="360" label="{{ __('Service dependencies') }}" />
</x-ui.chart-card>
<x-ui.network-graph layout="3d" :height="360" label="{{ __('Service dependency graph in three dimensions') }}" />
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| nodes | mixed | null | null | Nodes to plot: each an object with id, label, x/y (0..1, used in static layout), and optional group (0-based, for cluster colouring) and size. Defaults to a demo service dependency graph. |
| links | mixed | null | null | Connections between nodes: each an object with source and target node ids. |
| height | int | 360 | SVG height in pixels; width fills the container. |
| label | string | Network graph | Accessible name for the figure. |
| layout | string | static | static uses the precomputed x/y positions as given; force runs a live simulation that settles the layout and lets nodes be dragged; 3d runs the same simulation in three dimensions, projected with perspective and slowly orbiting, draggable to turn it. |
Slots
default— Unused; the graph renders from the nodes and links props only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders without arguments using built-in demo data, so it previews immediately.
- layout="static" plots nodes at their given positions with degree-scaled radii and group-coloured clusters, with no simulation running.
- layout="force" and layout="3d" run a live simulation via the uiNetworkGraph Alpine behaviour; nodes can be dragged, and 3d also orbits slowly and responds to drag rotation.
- Hovering a node shows a chart-tooltip with its label.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Explain hierarchy, causality, network structure, or flow.
Use when
- Use when hierarchy, causality, network structure, or process flow is the information itself.
- Visualising relationships between entities with no strict hierarchy, such as a service dependency map.
- Wanting a live force-directed or draggable 3D layout, or a precomputed static layout, from the same component.
Avoid when
- Do not use specialized diagrams when a list, table, or short paragraph would be easier to interpret.
- The relationships are a strict one-parent tree; use org-chart or mind-map instead.
- The connections flow through staged columns rather than a free graph; use flow-diagram or chart-sankey instead.
Use instead
- List, table, or prose explanation
Anti-patterns
- Using a specialized diagram when linear content is clearer
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The figure carries role="img" with an aria-label from label, so a screen reader hears a named summary instead of parsing raw SVG.
- Node labels render as visible SVG text, so identity is not locked inside hover-only tooltips even in the force and 3d layouts.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
<div class="space-y-4">
<x-ui.network-graph :height="360" label="{{ __('Service dependency graph') }}" />
<p class="text-sm text-muted-foreground">
{{ __('A dependency-free SVG network graph. Pass `:nodes` (`id`, `label`, normalised `x`/`y` 0..1, optional `group`) and `:links` (`source`, `target`) to plot your own graph; node radius scales with degree. `layout="force"` settles the positions live and lets you drag nodes, `layout="3d"` orbits the graph in perspective.') }}
</p>
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// Nodes: each ['id' => string, 'label' => string, 'x' => float 0..1, 'y' => float 0..1,
// 'group' => int (0-based, optional), 'size' => int (optional)].
'nodes' => null,
// Links: each ['source' => nodeId, 'target' => nodeId].
'links' => null,
'height' => 360,
'label' => 'Network graph',
// static: the precomputed positions as given. force: a live force
// simulation settles the layout and nodes can be dragged. 3d: the same
// simulation in three dimensions, projected with perspective and slowly
// orbiting (drag to turn it).
'layout' => 'static',
])
@php
// ── Demo data (a service dependency graph) so it renders without args. ──
// Positions are PRECOMPUTED and normalised (0..1); there is no force sim.
// Three visible clusters: gateway/edge (0), core services (1), data stores (2).
$nodes ??= [
// Cluster 0 — edge / gateway (upper-left)
['id' => 'gateway', 'label' => __('API Gateway'), 'x' => 0.16, 'y' => 0.20, 'group' => 0],
['id' => 'web', 'label' => __('Web App'), 'x' => 0.08, 'y' => 0.42, 'group' => 0],
['id' => 'mobile', 'label' => __('Mobile API'), 'x' => 0.26, 'y' => 0.40, 'group' => 0],
// Cluster 1 — core services (centre)
['id' => 'auth', 'label' => __('Auth Service'), 'x' => 0.46, 'y' => 0.22, 'group' => 1],
['id' => 'orders', 'label' => __('Orders'), 'x' => 0.52, 'y' => 0.50, 'group' => 1],
['id' => 'billing', 'label' => __('Billing'), 'x' => 0.40, 'y' => 0.66, 'group' => 1],
['id' => 'search', 'label' => __('Search'), 'x' => 0.60, 'y' => 0.74, 'group' => 1],
// Cluster 2 — data stores (right)
['id' => 'users-db', 'label' => __('Users DB'), 'x' => 0.84, 'y' => 0.24, 'group' => 2],
['id' => 'orders-db', 'label' => __('Orders DB'), 'x' => 0.90, 'y' => 0.52, 'group' => 2],
['id' => 'cache', 'label' => __('Cache'), 'x' => 0.78, 'y' => 0.70, 'group' => 2],
['id' => 'queue', 'label' => __('Queue'), 'x' => 0.92, 'y' => 0.82, 'group' => 2],
['id' => 'index', 'label' => __('Index'), 'x' => 0.72, 'y' => 0.90, 'group' => 2],
];
$links ??= [
// Within cluster 0
['source' => 'web', 'target' => 'gateway'],
['source' => 'mobile', 'target' => 'gateway'],
// Bridges 0 → 1
['source' => 'gateway', 'target' => 'auth'],
['source' => 'gateway', 'target' => 'orders'],
// Within cluster 1
['source' => 'auth', 'target' => 'orders'],
['source' => 'orders', 'target' => 'billing'],
['source' => 'orders', 'target' => 'search'],
// Bridges 1 → 2
['source' => 'auth', 'target' => 'users-db'],
['source' => 'orders', 'target' => 'orders-db'],
['source' => 'billing', 'target' => 'orders-db'],
['source' => 'search', 'target' => 'index'],
// Within cluster 2
['source' => 'orders-db', 'target' => 'cache'],
['source' => 'orders-db', 'target' => 'queue'],
['source' => 'index', 'target' => 'cache'],
];
// ── Geometry ───────────────────────────────────────────────────────────
$W = 760;
$H = max(180, (int) $height);
$pad = 40; // viewBox padding so nodes/labels never clip
// Cycle the neutral chart palette by group (`text-chart-N` utilities read
// the hue-spaced `--chart-1..5` tokens), so a fifth group never reads as
// destructive.
$palette = ['text-chart-1', 'text-chart-2', 'text-chart-3', 'text-chart-4', 'text-chart-5'];
// Index helpers + computed degree (number of incident links) per node.
$degree = [];
foreach ($nodes as $n) {
$degree[$n['id']] = 0;
}
foreach ($links as $l) {
if (isset($degree[$l['source']])) {
$degree[$l['source']]++;
}
if (isset($degree[$l['target']])) {
$degree[$l['target']]++;
}
}
// Pixel position + radius for each node. Radius scales with explicit 'size'
// or, failing that, the computed degree.
$maxDeg = max(1, max($degree));
$pos = []; // id => ['cx' =>, 'cy' =>, 'r' =>, 'color' =>, 'label' =>, 'deg' =>]
foreach (array_values($nodes) as $i => $n) {
$cx = $pad + (float) $n['x'] * ($W - 2 * $pad);
$cy = $pad + (float) $n['y'] * ($H - 2 * $pad);
$deg = $degree[$n['id']];
$r = isset($n['size'])
? max(5, (int) $n['size'])
: 7 + ($deg / $maxDeg) * 11; // 7..18px
$group = isset($n['group']) ? (int) $n['group'] : $i;
$pos[$n['id']] = [
'cx' => round($cx, 2),
'cy' => round($cy, 2),
'r' => round($r, 2),
'color' => $palette[$group % count($palette)],
'label' => $n['label'],
'deg' => $deg,
];
}
// Resolve edges to endpoint coordinates (skip dangling references).
$edges = [];
foreach ($links as $l) {
if (! isset($pos[$l['source']], $pos[$l['target']])) {
continue;
}
$edges[] = [
'source' => $l['source'],
'target' => $l['target'],
'x1' => $pos[$l['source']]['cx'],
'y1' => $pos[$l['source']]['cy'],
'x2' => $pos[$l['target']]['cx'],
'y2' => $pos[$l['target']]['cy'],
];
}
// Only label the larger / better-connected nodes to avoid clutter.
$labelThreshold = $maxDeg >= 3 ? 2 : 1;
$layout = in_array($layout, ['static', 'force', '3d'], true) ? $layout : 'static';
@endphp
<div
x-data="uiNetworkGraph({ layout: @js($layout), width: {{ $W }}, height: {{ $H }}, pad: {{ $pad }} })"
data-slot="network-graph"
data-layout="{{ $layout }}"
role="img"
aria-label="{{ $label }}"
{{ $attributes->merge(['class' => 'relative w-full text-foreground']) }}
>
{{-- Pointer tooltip; reads the data-* attributes the nodes carry. --}}
<x-ui.chart-tooltip
x-show="open"
x-cloak
class="absolute -translate-x-1/2 -translate-y-full -mt-2 whitespace-nowrap"
::style="`inset-inline-start:${x}px; top:${y}px`"
>
<div data-slot="chart-tooltip-row" class="flex items-center justify-between gap-4">
<span class="text-muted-foreground" x-text="label"></span>
<span class="font-medium tabular-nums" x-text="value"></span>
</div>
</x-ui.chart-tooltip>
<svg
viewBox="0 0 {{ $W }} {{ $H }}"
width="100%"
height="{{ $H }}"
preserveAspectRatio="xMidYMid meet"
class="h-auto w-full"
fill="none"
aria-hidden="true"
>
{{-- Edges first, behind the nodes. --}}
@foreach ($edges as $edge)
<line
data-slot="network-graph-edge"
data-source="{{ $edge['source'] }}"
data-target="{{ $edge['target'] }}"
class="stroke-border"
x1="{{ $edge['x1'] }}"
y1="{{ $edge['y1'] }}"
x2="{{ $edge['x2'] }}"
y2="{{ $edge['y2'] }}"
stroke="currentColor"
stroke-width="1.5"
stroke-opacity="0.7"
/>
@endforeach
{{-- Nodes. --}}
@foreach ($nodes as $n)
@php($p = $pos[$n['id']])
<circle
data-slot="network-graph-node"
data-id="{{ $n['id'] }}"
class="{{ $p['color'] }} {{ $layout === 'static' ? 'transition-opacity duration-200 hover:opacity-80' : 'cursor-grab active:cursor-grabbing' }}"
cx="{{ $p['cx'] }}"
cy="{{ $p['cy'] }}"
r="{{ $p['r'] }}"
fill="currentColor"
data-label="{{ $n['label'] }}"
data-degree="{{ $p['deg'] }}"
/>
@endforeach
{{-- Labels for the larger / better-connected nodes only. --}}
@foreach ($nodes as $n)
@php($p = $pos[$n['id']])
@if ($p['deg'] >= $labelThreshold)
<text
data-slot="network-graph-label"
data-for="{{ $n['id'] }}"
class="pointer-events-none fill-foreground stroke-background text-2xs font-medium [paint-order:stroke] [stroke-linejoin:round]"
stroke-width="3"
x="{{ $p['cx'] }}"
y="{{ round($p['cy'] + $p['r'] + 12, 2) }}"
text-anchor="middle"
dominant-baseline="auto"
>{{ $n['label'] }}</text>
@endif
@endforeach
</svg>
</div>
/**
* Network graph behaviour: hover tooltips, an optional force layout and an
* optional 3D projection.
*
* The Blade renders the graph as static SVG with positions precomputed, and
* stamps `data-id` on every node, `data-source`/`data-target` on every edge
* and `data-for` on every label. `layout: 'static'` (default) only adds the
* pointer tooltip. `layout: 'force'` reads those positions as the starting
* state of a small force simulation (link springs, charge repulsion, a soft
* pull to the centre), animates it until it cools, and lets the visitor drag
* nodes (pointer events, so touch works). `layout: '3d'` runs the same
* simulation in three dimensions, projects every node with a perspective
* camera, orbits slowly and turns with a drag; depth drives radius and
* opacity so the far side reads as far.
*
* Reduced motion: the simulation still runs but settles synchronously before
* the first paint (no animated settle, no orbit); dragging still works.
* Every listener, RAF loop and observer is torn down in destroy().
*
* Self-registers on `alpine:init` so import order does not matter — import this
* file once from your bundle (e.g. resources/js/ui/index.js).
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiNetworkGraph', (config = {}) => ({
open: false,
label: '',
value: '',
x: 0,
y: 0,
svg: null,
onMove: null,
onLeave: null,
layout: config.layout ?? 'static',
width: config.width ?? 760,
height: config.height ?? 360,
pad: config.pad ?? 40,
_nodes: [],
_links: [],
_raf: 0,
_alpha: 1,
_angle: 0,
_drag: null,
_reduced: false,
_visible: true,
_io: null,
_pointer: null,
init() {
this.svg = this.$root.querySelector('svg');
if (!this.svg) {
return;
}
this.onMove = (event) => {
const target = event.target.closest('[data-slot="network-graph-node"]');
if (!target) {
this.open = false;
return;
}
const rect = this.$root.getBoundingClientRect();
this.x = event.clientX - rect.left;
this.y = event.clientY - rect.top;
this.label = target.dataset.label ?? '';
const degree = target.dataset.degree ?? '';
this.value = degree === '' ? '' : `${degree} links`;
this.open = true;
};
this.onLeave = () => {
this.open = false;
};
this.svg.addEventListener('mousemove', this.onMove);
this.svg.addEventListener('mouseleave', this.onLeave);
if (this.layout === 'force' || this.layout === '3d') {
this.setupSimulation();
}
},
/* ---- simulation ------------------------------------------------- */
setupSimulation() {
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const byId = new Map();
this.svg.querySelectorAll('[data-slot="network-graph-node"]').forEach((el, i) => {
const node = {
id: el.dataset.id,
el,
label: this.svg.querySelector(`[data-slot="network-graph-label"][data-for="${CSS.escape(el.dataset.id)}"]`),
x: parseFloat(el.getAttribute('cx')),
y: parseFloat(el.getAttribute('cy')),
// Deterministic depth spread so a 3D graph starts as a cloud,
// not a plane; the simulation then finds its own shape.
z: this.layout === '3d' ? Math.sin(i * 2.399) * this.height * 0.35 : 0,
r: parseFloat(el.getAttribute('r')),
vx: 0,
vy: 0,
vz: 0,
fixed: false,
};
byId.set(node.id, node);
this._nodes.push(node);
});
this.svg.querySelectorAll('[data-slot="network-graph-edge"]').forEach((el) => {
const source = byId.get(el.dataset.source);
const target = byId.get(el.dataset.target);
if (source && target) {
this._links.push({ el, source, target });
}
});
this._pointer = {
down: (e) => this.onPointerDown(e),
move: (e) => this.onPointerMove(e),
up: (e) => this.onPointerUp(e),
};
this.svg.addEventListener('pointerdown', this._pointer.down);
window.addEventListener('pointermove', this._pointer.move);
window.addEventListener('pointerup', this._pointer.up);
window.addEventListener('pointercancel', this._pointer.up);
// Pause the loop while the graph is scrolled out of view.
if ('IntersectionObserver' in window) {
this._io = new IntersectionObserver(([entry]) => {
this._visible = entry.isIntersecting;
if (this._visible) this.schedule();
});
this._io.observe(this.$root);
}
if (this._reduced) {
// Settle before paint: no animated layout for reduced motion.
for (let i = 0; i < 300 && this._alpha > 0.005; i++) this.tick();
this.paint();
} else {
this.schedule();
}
},
schedule() {
if (this._raf || !this._visible) return;
this._raf = requestAnimationFrame(() => {
this._raf = 0;
this.frame();
});
},
frame() {
const settling = this._alpha > 0.005;
if (settling) this.tick();
if (this.layout === '3d' && !this._drag && !this._reduced) this._angle += 0.004;
this.paint();
// Keep looping while the layout settles, a drag is in flight, or a 3D
// graph is orbiting.
if (settling || this._drag || (this.layout === '3d' && !this._reduced)) this.schedule();
},
// One Verlet-ish integration step: spring links towards a rest length,
// repel every pair with an inverse-square charge, pull softly to the
// centre, then damp. `alpha` cools the whole system so it comes to rest.
tick() {
const nodes = this._nodes;
const is3d = this.layout === '3d';
const cx = this.width / 2;
const cy = this.height / 2;
const rest = Math.min(this.width, this.height) * (is3d ? 0.5 : 0.36);
const alpha = this._alpha;
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i];
const b = nodes[j];
let dx = b.x - a.x;
let dy = b.y - a.y;
let dz = is3d ? b.z - a.z : 0;
let d2 = dx * dx + dy * dy + dz * dz;
if (d2 < 1) {
dx = (i - j) * 0.5;
dy = 0.5;
d2 = 1;
}
const d = Math.sqrt(d2);
const f = ((is3d ? 7000 : 3800) * alpha) / d2;
const fx = (dx / d) * f;
const fy = (dy / d) * f;
const fz = (dz / d) * f;
a.vx -= fx;
a.vy -= fy;
a.vz -= fz;
b.vx += fx;
b.vy += fy;
b.vz += fz;
}
}
for (const { source: a, target: b } of this._links) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const dz = is3d ? b.z - a.z : 0;
const d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1;
const f = ((d - rest) / d) * 0.05 * alpha;
a.vx += dx * f;
a.vy += dy * f;
a.vz += dz * f;
b.vx -= dx * f;
b.vy -= dy * f;
b.vz -= dz * f;
}
for (const n of nodes) {
if (n.fixed) {
n.vx = n.vy = n.vz = 0;
continue;
}
n.vx += (cx - n.x) * 0.006 * alpha;
n.vy += (cy - n.y) * 0.009 * alpha;
n.vz += (0 - n.z) * 0.006 * alpha;
n.vx *= 0.82;
n.vy *= 0.82;
n.vz *= 0.82;
n.x += n.vx;
n.y += n.vy;
n.z += n.vz;
if (!is3d) {
n.x = Math.min(this.width - this.pad, Math.max(this.pad, n.x));
n.y = Math.min(this.height - this.pad, Math.max(this.pad, n.y));
}
}
this._alpha *= this._drag ? 1 : 0.985;
},
// Project every node (perspective for 3D) and write the SVG attributes.
paint() {
const is3d = this.layout === '3d';
const cx = this.width / 2;
const cy = this.height / 2;
const focal = 900;
const cos = Math.cos(this._angle);
const sin = Math.sin(this._angle);
for (const n of this._nodes) {
if (is3d) {
// Orbit around the vertical axis through the centre.
const rx = (n.x - cx) * cos - n.z * sin;
const rz = (n.x - cx) * sin + n.z * cos;
const scale = focal / (focal + rz);
n.px = cx + rx * scale;
n.py = cy + (n.y - cy) * scale;
n.pr = n.r * scale;
n.depth = rz;
n.el.setAttribute('opacity', (0.45 + 0.55 * Math.min(1, Math.max(0, scale - 0.55) / 0.7)).toFixed(2));
} else {
n.px = n.x;
n.py = n.y;
n.pr = n.r;
}
n.el.setAttribute('cx', n.px.toFixed(1));
n.el.setAttribute('cy', n.py.toFixed(1));
n.el.setAttribute('r', n.pr.toFixed(1));
if (n.label) {
n.label.setAttribute('x', n.px.toFixed(1));
n.label.setAttribute('y', (n.py + n.pr + 12).toFixed(1));
if (is3d) n.label.setAttribute('opacity', n.el.getAttribute('opacity'));
}
}
for (const { el, source, target } of this._links) {
el.setAttribute('x1', source.px.toFixed(1));
el.setAttribute('y1', source.py.toFixed(1));
el.setAttribute('x2', target.px.toFixed(1));
el.setAttribute('y2', target.py.toFixed(1));
if (is3d) {
el.setAttribute('stroke-opacity', (0.15 + 0.55 * Math.min(source.el.getAttribute('opacity'), target.el.getAttribute('opacity'))).toFixed(2));
}
}
// Far nodes are painted first so near ones sit on top.
if (is3d) {
const parent = this._nodes[0]?.el.parentNode;
if (parent) {
[...this._nodes]
.sort((a, b) => b.depth - a.depth)
.forEach((n) => parent.appendChild(n.el));
this._nodes.forEach((n) => n.label && parent.appendChild(n.label));
}
}
},
/* ---- pointer: drag a node, or turn a 3D graph ------------------- */
svgPoint(event) {
const rect = this.svg.getBoundingClientRect();
return {
x: ((event.clientX - rect.left) / rect.width) * this.width,
y: ((event.clientY - rect.top) / rect.height) * this.height,
};
},
onPointerDown(event) {
const target = event.target.closest('[data-slot="network-graph-node"]');
const node = target ? this._nodes.find((n) => n.el === target) : null;
if (node) {
node.fixed = true;
this._drag = { node, id: event.pointerId };
this._alpha = Math.max(this._alpha, 0.3);
event.preventDefault();
} else if (this.layout === '3d') {
this._drag = { spin: true, id: event.pointerId, lastX: event.clientX };
} else {
return;
}
this.open = false;
this.schedule();
},
onPointerMove(event) {
if (!this._drag || event.pointerId !== this._drag.id) return;
if (this._drag.spin) {
this._angle += (event.clientX - this._drag.lastX) * 0.008;
this._drag.lastX = event.clientX;
} else {
const p = this.svgPoint(event);
const node = this._drag.node;
if (this.layout === '3d') {
// Un-rotate the pointer into model space at the node's depth.
const cx = this.width / 2;
const cos = Math.cos(-this._angle);
const sin = Math.sin(-this._angle);
const rx = p.x - cx;
node.x = cx + rx * cos - node.z * sin;
node.y = p.y;
} else {
node.x = p.x;
node.y = p.y;
}
this._alpha = Math.max(this._alpha, 0.1);
}
this.schedule();
},
onPointerUp(event) {
if (!this._drag || event.pointerId !== this._drag.id) return;
if (this._drag.node) this._drag.node.fixed = false;
this._drag = null;
this.schedule();
},
destroy() {
if (this._raf) cancelAnimationFrame(this._raf);
this._raf = 0;
if (this._io) this._io.disconnect();
if (this._pointer && this.svg) {
this.svg.removeEventListener('pointerdown', this._pointer.down);
window.removeEventListener('pointermove', this._pointer.move);
window.removeEventListener('pointerup', this._pointer.up);
window.removeEventListener('pointercancel', this._pointer.up);
}
if (!this.svg) {
return;
}
this.svg.removeEventListener('mousemove', this.onMove);
this.svg.removeEventListener('mouseleave', this.onLeave);
this.svg = null;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.1 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0