Skip to content
Brok UI

Loading…

No results

Charts

Open source

Token-themed data visualisations — the core multi-series chart plus dependency-free SVG variants for sankey, word-cloud, venn and violin plots.

Version
v1.2.1
Stability
stable
License
MIT
Related
Chart
Chart Tooltip
Flow Diagram
Network Graph
4 of 7

Sankey Chart — A dependency-free, token-themed SVG Sankey diagram that visualises weighted flow between staged nodes, with hover tooltips on flows and nodes.

Preview

A dependency-free SVG Sankey. Pass `:nodes` (with a 0-based `column`) and `:links` (`source`, `target`, `value`) to chart your own staged flow.

previews.components.chart-sankey.default.blade.php Blade
<div class="space-y-4">
    <x-ui.chart-sankey :height="320" label="{{ __('Acquisition funnel') }}" />

    <p class="text-sm text-muted-foreground">
        {{ __('A dependency-free SVG Sankey. Pass `:nodes` (with a 0-based `column`) and `:links` (`source`, `target`, `value`) to chart your own staged flow.') }}
    </p>
</div>

Installation

terminal
php artisan ui:add chart-sankey

Note

This component ships an Alpine behavior module at resources/js/ui/chart-sankey.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './chart-sankey.js';

Registry contract

php artisan ui:add chart-sankey 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/chart-sankey.blade.php
  • js resources/js/ui/chart-sankey.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.

chart-sankey.md
# Brok UI: Sankey Chart (`chart-sankey`)

A dependency-free, token-themed SVG Sankey diagram that visualises weighted flow between staged nodes, with hover tooltips on flows and nodes.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add chart-sankey
```

## Usage

```blade
<div class="space-y-4">
    <x-ui.chart-sankey :height="320" label="{{ __('Acquisition funnel') }}" />

    <p class="text-sm text-muted-foreground">
        {{ __('A dependency-free SVG Sankey. Pass `:nodes` (with a 0-based `column`) and `:links` (`source`, `target`, `value`) to chart your own staged flow.') }}
    </p>
</div>
```

## Props

- `nodes` (mixed|null, default `null`) — Stage nodes: each an object with id, label and a 0-based column index. Defaults to a demo acquisition funnel.
- `links` (mixed|null, default `null`) — Flows between nodes: each an object with source, target and a numeric value.
- `height` (int, default `340`) — SVG height in pixels; width fills the container.
- `label` (string, default `Sankey flow diagram`) — Accessible name for the figure.
- `showValues` (bool, default `true`) — Shows each node's total next to its label.
- `linkColor` (string, default `source`) — Ribbon colouring: source (one solid colour per origin), gradient (blends source into target), or muted (a single neutral so the nodes carry the hue).
- `unit` (string, default ``) — Suffix appended to node totals and tooltip values, such as "k" or " users".

## Use when

- Use when trend, distribution, flow, intensity, or relationship questions are easier to answer visually than in raw numbers.
- Visualising weighted flow between staged categories, such as an acquisition funnel or a budget breakdown across stages.
- Needing to show how a total splits and re-combines across two or more sequential columns.

## Avoid when

- Do not use a chart when users mainly need exact lookup or when a single metric would be clearer as text or a stat.
- The flow is a simple linear funnel with no branching between stages; a bar chart reads it more simply.
- The relationships are a general graph with no staged columns; use network-graph instead.

## Anti-patterns

- Decorative charts without a meaningful question

## Rules

- Use the `<brok:chart-sankey>` tag (or `<x-ui.chart-sankey>`) 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/chart-sankey
- Registry JSON (files, props, contract): https://brokui.dev/r/open/chart-sankey.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

funnel.blade.php Blade
{{-- A staged acquisition funnel framed by chart-card; the period badge is
     neutral because it is a label, not a delta. --}}
<x-ui.chart-card heading="Acquisition funnel" subheading="How visitors flow from source to outcome" trend="Last 30 days" trend-variant="neutral">
    <x-ui.chart-sankey
        :height="320"
        :nodes="[
            ['id' => 'organic', 'label' => __('Organic'), 'column' => 0],
            ['id' => 'paid', 'label' => __('Paid'), 'column' => 0],
            ['id' => 'referral', 'label' => __('Referral'), 'column' => 0],
            ['id' => 'signups', 'label' => __('Signups'), 'column' => 1],
            ['id' => 'bounced', 'label' => __('Bounced'), 'column' => 1],
            ['id' => 'activated', 'label' => __('Activated'), 'column' => 2],
            ['id' => 'churned', 'label' => __('Churned'), 'column' => 2],
        ]"
        :links="[
            ['source' => 'organic', 'target' => 'signups', 'value' => 35],
            ['source' => 'organic', 'target' => 'bounced', 'value' => 15],
            ['source' => 'paid', 'target' => 'signups', 'value' => 18],
            ['source' => 'paid', 'target' => 'bounced', 'value' => 12],
            ['source' => 'referral', 'target' => 'signups', 'value' => 15],
            ['source' => 'referral', 'target' => 'bounced', 'value' => 5],
            ['source' => 'signups', 'target' => 'activated', 'value' => 50],
            ['source' => 'signups', 'target' => 'churned', 'value' => 18],
            ['source' => 'bounced', 'target' => 'churned', 'value' => 32],
        ]"
        label="{{ __('Acquisition funnel from source to outcome') }}"
    />
</x-ui.chart-card>

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 Stage nodes: each an object with id, label and a 0-based column index. Defaults to a demo acquisition funnel.
links mixed | null null Flows between nodes: each an object with source, target and a numeric value.
height int 340 SVG height in pixels; width fills the container.
label string Sankey flow diagram Accessible name for the figure.
showValues bool true Shows each node's total next to its label.
linkColor string source Ribbon colouring: source (one solid colour per origin), gradient (blends source into target), or muted (a single neutral so the nodes carry the hue).
unit string Suffix appended to node totals and tooltip values, such as "k" or " users".

Slots

  • default — Unused; the diagram renders from the nodes and links props only.

Data slots

Stable hooks for CSS overrides and browser tests.

chart-sankey chart-sankey-label chart-sankey-link chart-sankey-links chart-sankey-node chart-sankey-node-group chart-sankey-value chart-tooltip-row

Behavior

  • Renders without arguments using built-in demo data, so it previews immediately.
  • Hovering a node or a link ribbon shows a chart-tooltip with that item's label and value.
  • Dependency-free SVG: no chart library, computed and laid out entirely in Blade.
  • 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

Chart and visual analytics

Reveal a visual trend, distribution, flow, or relationship.

Use when

  • Use when trend, distribution, flow, intensity, or relationship questions are easier to answer visually than in raw numbers.
  • Visualising weighted flow between staged categories, such as an acquisition funnel or a budget breakdown across stages.
  • Needing to show how a total splits and re-combines across two or more sequential columns.

Avoid when

  • Do not use a chart when users mainly need exact lookup or when a single metric would be clearer as text or a stat.
  • The flow is a simple linear funnel with no branching between stages; a bar chart reads it more simply.
  • The relationships are a general graph with no staged columns; use network-graph instead.

Use instead

  • Table for exact lookup
  • Stat for a single value

Anti-patterns

  • Decorative charts without a meaningful question
Anatomy
root chart-sankey-links chart-sankey-link chart-sankey-node-group chart-sankey-node chart-sankey-label chart-sankey-value
Theming hooks
chart-sankey chart-sankey-label chart-sankey-link chart-sankey-links chart-sankey-node chart-sankey-node-group chart-sankey-value chart-tooltip-row

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
managed
  • 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 totals render as visible text next to each label, so the flow's numbers are not locked inside hover-only tooltips.
  • 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:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="chart-sankey-{{ $record->id }}">
    <div class="space-y-4">
        <x-ui.chart-sankey :height="320" label="{{ __('Acquisition funnel') }}" />
    
        <p class="text-sm text-muted-foreground">
            {{ __('A dependency-free SVG Sankey. Pass `:nodes` (with a 0-based `column`) and `:links` (`source`, `target`, `value`) to chart your own staged flow.') }}
        </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/chart-sankey.blade.php Blade
@props([
    // Nodes: each ['id' => string, 'label' => string, 'column' => int (0-based stage)].
    'nodes' => null,
    // Links: each ['source' => nodeId, 'target' => nodeId, 'value' => number].
    'links' => null,
    'height' => 340,
    'label' => 'Sankey flow diagram',
    // Show each node's total next to its label.
    'showValues' => true,
    // Ribbon colouring: `source` (one solid colour per origin), `gradient`
    // (blends source → target) or `muted` (a single neutral so the nodes carry
    // the hue).
    'linkColor' => 'source',
    // Optional unit appended to node totals + tooltip values (e.g. "k", " users").
    'unit' => '',
])

@php
    // ── Demo data (a marketing acquisition funnel) so it renders without args ──
    $nodes ??= [
        ['id' => 'organic', 'label' => __('Organic'), 'column' => 0],
        ['id' => 'paid', 'label' => __('Paid'), 'column' => 0],
        ['id' => 'referral', 'label' => __('Referral'), 'column' => 0],
        ['id' => 'signups', 'label' => __('Signups'), 'column' => 1],
        ['id' => 'bounced', 'label' => __('Bounced'), 'column' => 1],
        ['id' => 'activated', 'label' => __('Activated'), 'column' => 2],
        ['id' => 'churned', 'label' => __('Churned'), 'column' => 2],
    ];

    $links ??= [
        ['source' => 'organic', 'target' => 'signups', 'value' => 35],
        ['source' => 'organic', 'target' => 'bounced', 'value' => 15],
        ['source' => 'paid', 'target' => 'signups', 'value' => 18],
        ['source' => 'paid', 'target' => 'bounced', 'value' => 12],
        ['source' => 'referral', 'target' => 'signups', 'value' => 15],
        ['source' => 'referral', 'target' => 'bounced', 'value' => 5],
        ['source' => 'signups', 'target' => 'activated', 'value' => 50],
        ['source' => 'signups', 'target' => 'churned', 'value' => 18],
        ['source' => 'bounced', 'target' => 'churned', 'value' => 32],
    ];

    $showValues = filter_var($showValues, FILTER_VALIDATE_BOOLEAN);
    $linkColor = in_array($linkColor, ['source', 'gradient', 'muted'], true) ? $linkColor : 'source';

    // ── Geometry ───────────────────────────────────────────────────────────
    $W = 760;
    $H = max(160, (int) $height);
    $nodeW = 12;
    $labelW = 96;        // room reserved outside the first/last column for labels
    $top = 12;
    $bottom = $H - 12;

    // The neutral chart palette cycles per node; `text-chart-N` utilities read
    // the hue-spaced `--chart-1..5` tokens (light + dark), so five sources never
    // collide with a status colour.
    $palette = ['text-chart-1', 'text-chart-2', 'text-chart-3', 'text-chart-4', 'text-chart-5'];

    $colOf = $colorOf = $labelOf = [];
    foreach (array_values($nodes) as $i => $n) {
        $colOf[$n['id']] = (int) $n['column'];
        $colorOf[$n['id']] = $palette[$i % count($palette)];
        $labelOf[$n['id']] = $n['label'];
    }

    // Incoming / outgoing totals per node; a node's weight is the larger side.
    $inSum = $outSum = [];
    foreach ($links as $l) {
        $outSum[$l['source']] = ($outSum[$l['source']] ?? 0) + $l['value'];
        $inSum[$l['target']] = ($inSum[$l['target']] ?? 0) + $l['value'];
    }
    $weight = [];
    foreach ($nodes as $n) {
        $weight[$n['id']] = max($inSum[$n['id']] ?? 0, $outSum[$n['id']] ?? 0);
    }

    $columns = [];
    foreach ($nodes as $n) {
        $columns[(int) $n['column']][] = $n['id'];
    }
    ksort($columns);
    $maxCol = max(array_keys($columns));

    // Column x positions leave label gutters on both edges.
    $plotX0 = $labelW;
    $plotX1 = $W - $labelW - $nodeW;
    $colX = [];
    foreach (array_keys($columns) as $c) {
        $colX[$c] = $maxCol > 0 ? $plotX0 + ($c / $maxCol) * ($plotX1 - $plotX0) : $plotX0;
    }

    // One vertical scale for every column (px per unit), fixed by the heaviest
    // column, so a stage that loses flow visibly shrinks instead of being
    // stretched to full height. The gap between stacked nodes is 8% of the
    // plot, split evenly.
    $vGap = max(8, round(($bottom - $top) * 0.08));
    $scale = INF;
    foreach ($columns as $ids) {
        $total = array_sum(array_map(fn ($id) => $weight[$id], $ids)) ?: 1;
        $avail = ($bottom - $top) - (count($ids) - 1) * $vGap;
        $scale = min($scale, $avail / $total);
    }

    $geom = []; // id => ['y' => , 'h' => ]
    foreach ($columns as $c => $ids) {
        $total = array_sum(array_map(fn ($id) => $weight[$id], $ids));
        $used = $total * $scale + (count($ids) - 1) * $vGap;
        $y = $top + (($bottom - $top) - $used) / 2;   // centre the column vertically
        foreach ($ids as $id) {
            $h = max(3, $weight[$id] * $scale);
            $geom[$id] = ['y' => $y, 'h' => $h];
            $y += $h + $vGap;
        }
    }

    // Order each node's outgoing bands by the target's vertical position (and
    // incoming bands by the source's) so ribbons leave and arrive in the same
    // order they are stacked — fewer crossings, no ribbon folding over itself.
    $ordered = array_values($links);
    usort($ordered, fn ($a, $b) => ($geom[$a['source']]['y'] <=> $geom[$b['source']]['y']) ?: ($geom[$a['target']]['y'] <=> $geom[$b['target']]['y']));

    $outOff = $inOff = [];
    $outOrder = $ordered;
    usort($outOrder, fn ($a, $b) => $geom[$a['target']]['y'] <=> $geom[$b['target']]['y']);
    $inOrder = $ordered;
    usort($inOrder, fn ($a, $b) => $geom[$a['source']]['y'] <=> $geom[$b['source']]['y']);

    $bandStart = []; // link key => [sy, ty]
    $key = fn ($l) => $l['source'].'→'.$l['target'];
    foreach ($outOrder as $l) {
        $s = $l['source'];
        $bandStart[$key($l)]['sy'] = $geom[$s]['y'] + ($outOff[$s] ?? 0);
        $outOff[$s] = ($outOff[$s] ?? 0) + ($l['value'] / ($outSum[$s] ?: 1)) * $geom[$s]['h'];
    }
    foreach ($inOrder as $l) {
        $t = $l['target'];
        $bandStart[$key($l)]['ty'] = $geom[$t]['y'] + ($inOff[$t] ?? 0);
        $inOff[$t] = ($inOff[$t] ?? 0) + ($l['value'] / ($inSum[$t] ?: 1)) * $geom[$t]['h'];
    }

    $uid = substr(md5($label.json_encode($nodes)), 0, 6);
    $ribbons = [];
    foreach ($ordered as $i => $l) {
        $s = $l['source'];
        $t = $l['target'];
        $sBand = ($l['value'] / ($outSum[$s] ?: 1)) * $geom[$s]['h'];
        $tBand = ($l['value'] / ($inSum[$t] ?: 1)) * $geom[$t]['h'];
        $sy = $bandStart[$key($l)]['sy'];
        $ty = $bandStart[$key($l)]['ty'];

        $x0 = $colX[$colOf[$s]] + $nodeW;
        $x1 = $colX[$colOf[$t]];
        $mx = ($x0 + $x1) / 2;

        $ribbons[] = [
            'id' => "sankey-{$uid}-{$i}",
            'source' => $s,
            'target' => $t,
            'from' => $labelOf[$s],
            'to' => $labelOf[$t],
            'value' => $l['value'],
            'x0' => $x0,
            'x1' => $x1,
            'd' => sprintf(
                'M%.2f %.2f C%.2f %.2f %.2f %.2f %.2f %.2f L%.2f %.2f C%.2f %.2f %.2f %.2f %.2f %.2f Z',
                $x0, $sy, $mx, $sy, $mx, $ty, $x1, $ty,
                $x1, $ty + $tBand, $mx, $ty + $tBand, $mx, $sy + $sBand, $x0, $sy + $sBand,
            ),
        ];
    }

    $format = fn ($v) => (is_float($v) && floor($v) !== $v ? number_format($v, 1) : number_format((float) $v)).$unit;
@endphp

<div
    x-data="uiChartSankey()"
    data-slot="chart-sankey"
    data-link-color="{{ $linkColor }}"
    role="img"
    aria-label="{{ $label }}"
    {{ $attributes->merge(['class' => 'relative w-full text-foreground']) }}
>
    {{-- Pointer tooltip; reads the data-* attributes the ribbons/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"
    >
        @if ($linkColor === 'gradient')
            <defs>
                @foreach ($ribbons as $ribbon)
                    {{-- Each stop takes its colour from a token utility on the stop
                         itself (currentColor), so the gradient stays theme-bound. --}}
                    <linearGradient id="{{ $ribbon['id'] }}" gradientUnits="userSpaceOnUse" x1="{{ round($ribbon['x0'], 2) }}" x2="{{ round($ribbon['x1'], 2) }}" y1="0" y2="0">
                        <stop offset="0" class="{{ $colorOf[$ribbon['source']] }}" stop-color="currentColor" />
                        <stop offset="1" class="{{ $colorOf[$ribbon['target']] }}" stop-color="currentColor" />
                    </linearGradient>
                @endforeach
            </defs>
        @endif

        {{-- Flow ribbons (behind the nodes). Hovering one lifts it and fades the
             rest; hovering a node lifts every ribbon touching it (ui.css). --}}
        <g data-slot="chart-sankey-links">
            @foreach ($ribbons as $ribbon)
                <path
                    data-slot="chart-sankey-link"
                    data-source="{{ $ribbon['source'] }}"
                    data-target="{{ $ribbon['target'] }}"
                    @class([
                        $colorOf[$ribbon['source']] => $linkColor === 'source',
                        'text-muted-foreground' => $linkColor === 'muted',
                    ])
                    d="{{ $ribbon['d'] }}"
                    fill="{{ $linkColor === 'gradient' ? 'url(#'.$ribbon['id'].')' : 'currentColor' }}"
                    data-from="{{ $ribbon['from'] }}"
                    data-to="{{ $ribbon['to'] }}"
                    data-value="{{ $format($ribbon['value']) }}"
                />
            @endforeach
        </g>

        {{-- Nodes + labels. The first column's labels sit in the left gutter;
             every other label reads to the right of its node on a page-coloured
             halo so it stays legible over a ribbon. --}}
        @foreach ($nodes as $n)
            @php($g = $geom[$n['id']])
            @php($col = $colOf[$n['id']])
            @php($nx = $colX[$col])
            @php($isFirst = $col === 0)
            @php($lx = $isFirst ? $nx - 8 : $nx + $nodeW + 8)
            @php($ly = $g['y'] + $g['h'] / 2)
            <g data-slot="chart-sankey-node-group" data-node="{{ $n['id'] }}">
                <rect
                    data-slot="chart-sankey-node"
                    class="{{ $colorOf[$n['id']] }}"
                    x="{{ round($nx, 2) }}"
                    y="{{ round($g['y'], 2) }}"
                    width="{{ $nodeW }}"
                    height="{{ round($g['h'], 2) }}"
                    rx="3"
                    fill="currentColor"
                    data-label="{{ $n['label'] }}"
                    data-total="{{ $format($weight[$n['id']]) }}"
                />
                <text
                    data-slot="chart-sankey-label"
                    class="pointer-events-none fill-foreground stroke-background text-2xs font-medium [paint-order:stroke] [stroke-linejoin:round]"
                    stroke-width="3"
                    x="{{ round($lx, 2) }}"
                    y="{{ round($showValues ? $ly - 1 : $ly, 2) }}"
                    text-anchor="{{ $isFirst ? 'end' : 'start' }}"
                    dominant-baseline="{{ $showValues ? 'auto' : 'middle' }}"
                >{{ $n['label'] }}</text>
                @if ($showValues)
                    <text
                        data-slot="chart-sankey-value"
                        class="pointer-events-none fill-muted-foreground stroke-background text-2xs tabular-nums [paint-order:stroke] [stroke-linejoin:round]"
                        stroke-width="3"
                        x="{{ round($lx, 2) }}"
                        y="{{ round($ly + 3, 2) }}"
                        text-anchor="{{ $isFirst ? 'end' : 'start' }}"
                        dominant-baseline="hanging"
                    >{{ $format($weight[$n['id']]) }}</text>
                @endif
            </g>
        @endforeach
    </svg>
</div>
resources/js/ui/chart-sankey.js JS
/**
 * Sankey chart hover tooltips.
 *
 * The Sankey itself is dependency-free static SVG; this adds a lightweight
 * pointer tooltip that reads the data-* attributes the Blade stamps onto each
 * flow ribbon (data-from / data-to / data-value) and node (data-label /
 * data-total). A single delegated listener on the SVG drives it, and both
 * listeners are removed on teardown so unmounting mid-hover can't leak.
 *
 * 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('uiChartSankey', () => ({
        open: false,
        label: '',
        value: '',
        x: 0,
        y: 0,
        svg: null,
        onMove: null,
        onLeave: null,

        init() {
            this.svg = this.$root.querySelector('svg');
            if (!this.svg) {
                return;
            }

            this.onMove = (event) => {
                const target = event.target.closest(
                    '[data-slot="chart-sankey-link"], [data-slot="chart-sankey-node"]',
                );

                if (!target) {
                    this.open = false;

                    return;
                }

                const rect = this.$root.getBoundingClientRect();
                this.x = event.clientX - rect.left;
                this.y = event.clientY - rect.top;

                if (target.dataset.from) {
                    this.label = `${target.dataset.from} → ${target.dataset.to}`;
                    this.value = target.dataset.value;
                    this.focus(null, target);
                } else {
                    this.label = target.dataset.label ?? '';
                    this.value = target.dataset.total ?? '';
                    this.focus(target.closest('[data-node]')?.dataset.node ?? null, null);
                }

                this.open = true;
            };

            this.onLeave = () => {
                this.open = false;
                this.focus(null, null);
            };

            this.svg.addEventListener('mousemove', this.onMove);
            this.svg.addEventListener('mouseleave', this.onLeave);
        },

        // Stamp `data-active` on the hovered ribbon (or every ribbon touching
        // the hovered node) and `data-dimmed` on the host; ui.css fades the
        // rest. Cheap enough to run on every mousemove: a Sankey has tens of
        // ribbons, not thousands.
        focus(nodeId, link) {
            const links = this.svg.querySelectorAll('[data-slot="chart-sankey-link"]');
            let any = false;

            links.forEach((el) => {
                const active = link ? el === link : nodeId !== null && (el.dataset.source === nodeId || el.dataset.target === nodeId);
                el.toggleAttribute('data-active', active);
                any = any || active;
            });

            this.$root.toggleAttribute('data-dimmed', any);
        },

        destroy() {
            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.2.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