Skip to content
Brok UI

Loading…

No results

Diagrams

Open source

Dependency-free, token-themed relationship diagrams — flow, network, org chart, indented tree, mind map and fishbone.

Version
v1.1.1
Stability
stable
License
MIT
Related
Organization Chart
Mind Map
Chart Tooltip
2 of 6

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.

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.

previews.components.network-graph.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/network-graph.blade.php
  • js resources/js/ui/network-graph.js
Registry dependencies
chart-tooltip
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

network-graph.md
# 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

force.blade.php Blade
<x-ui.network-graph layout="force" :height="360" label="{{ __('Service dependency graph, force-directed') }}" />
services.blade.php Blade
{{-- 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>
three-d.blade.php Blade
<x-ui.network-graph layout="3d" :height="360" label="{{ __('Service dependency graph in three dimensions') }}" />

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

chart-tooltip-row network-graph network-graph-edge network-graph-label network-graph-node

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

Diagram and relationship view

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
root network-graph-node network-graph-edge network-graph-label
Theming hooks
chart-tooltip-row network-graph network-graph-edge network-graph-label network-graph-node

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/network-graph.blade.php Blade
@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>
resources/js/ui/network-graph.js JS
/**
 * 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