Connection lost. Reconnecting… attempt 1 of 8
Paused. Your work is held on the server.
Could not reconnect.
This session has expired on the server.
An unhandled error has occurred.
Sedna.UI
v0.31.0 · main

Graph in Blazor

tier 3 — a surface

A graph in a component is the same markup as on any other page. ISednaGraphs hands it records and changes what it shows, and what the reader does comes back as @onsedna-graph-* events carrying plain data. The component keeps the records; the drawing follows them.

Register once, import the namespace, call after the first render builder.Services.AddSednaUi() registers ISednaGraphs with the rest of the C# surface, and @using Sedna.UI in _Imports.razor is what makes @onsedna-graph-select an attribute the compiler knows. Every member is one JavaScript call, so none of them can run while the page prerenders: call them from an event handler, or from OnAfterRenderAsync(firstRender: true), once the [data-graph] element is in the page. Each takes that element's id.

Records from C#

data-graph-deferred keeps the graph behind its .graph-wait until the first SetDataAsync, instead of drawing an empty state while the circuit connects. OnAfterRenderAsync(firstRender) is the first render with interop behind it, so that is where the records are sent.

SednaGraphNode and SednaGraphEdge are the markup's attributes, typed: Tone takes a SednaGraphTone, Shape and Line are enums, and whatever is left unset does not cross the circuit. Every call answers with a SednaGraphStats of what is on screen afterwards, or null when no graph has that id — never an exception into the circuit.

Checkout services Loading…
@inject ISednaGraphs Graphs

<div class="card">
    <div class="card-head">
        <strong>Checkout services</strong>
        <span class="form-hint">@(_shown is null ? "Loading…" : $"{_shown.Nodes} services, {_shown.Edges} calls")</span>
    </div>
    <div class="graph graph--sm" data-graph data-graph-deferred data-graph-arrows="target"
         id="checkout-services" aria-label="Checkout services and what each one calls">
        <div class="graph-canvas"></div>
        <div class="graph-wait skeleton skeleton-block"></div>
    </div>
</div>

@code {
    private SednaGraphStats? _shown;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        // The first render with a circuit behind it: the element exists and interop works.
        // Until the records arrive, data-graph-deferred keeps .graph-wait up.
        if (!firstRender) return;

        _shown = await Graphs.SetDataAsync("checkout-services", new SednaGraphData(Services, Calls));
        StateHasChanged();
    }

    private static readonly SednaGraphNode[] Services =
    [
        new("storefront", "Storefront") { Kind = "web", Icon = "ri-store-2-line", Root = true },
        new("orders", "Orders API") { Kind = "service", Tone = SednaGraphTone.Series(2), Icon = "ri-server-line", Meta = "orders-console-01" },
        new("payments", "Payments") { Kind = "service", Tone = SednaGraphTone.Series(2), Icon = "ri-bank-card-line" },
        new("search", "Search") { Kind = "service", Tone = SednaGraphTone.Series(2), Icon = "ri-search-line" },
        new("orders-db", "Orders database") { Kind = "store", Tone = SednaGraphTone.Series(4), Shape = SednaGraphShape.Square, Icon = "ri-database-2-line" },
        new("search-index", "Search index") { Kind = "store", Tone = SednaGraphTone.Series(4), Shape = SednaGraphShape.Square, Icon = "ri-database-2-line" },
        new("courier", "Courier gateway") { Kind = "external", Tone = SednaGraphTone.Muted, Shape = SednaGraphShape.Diamond, Icon = "ri-truck-line" },
    ];

    private static readonly SednaGraphEdge[] Calls =
    [
        new("storefront", "orders") { Label = "places orders" },
        new("storefront", "search") { Label = "searches" },
        new("orders", "payments") { Label = "charges" },
        new("orders", "orders-db") { Label = "writes" },
        new("search", "search-index") { Label = "reads" },
        new("orders", "courier") { Label = "books", Line = SednaGraphLine.Dashed },
        new("orders", "search-index") { Label = "reindexes", Line = SednaGraphLine.Dotted },
    ];
}

The reader's clicks as events

Live — click a service, then double-click one. @onsedna-graph-select hands the handler a SednaGraphNodeEventArgs: the record's id, label, kind, meta and tags, and its own fields. Markup fields arrive under the browser's own names for them, so data-on-call is Fields["onCall"]. The panel beside the graph is the component's markup, rendered from that; --graph-height on the .graph-frame sizes the two together. Id is null when the selection is cleared — by the reader, or by SelectAsync with a null id, which is what Clear calls, so the panel only ever follows the graph.

@onsedna-graph-open fires on a double-click or Enter. Cancelled with :preventDefault, the record's data-href is not followed, and opening means whatever the app decides — a page, a drawer, or here a toast. Keyboard says whether Enter opened it.

The menu's open item sends the same event. It hides itself on a record with no data-href unless it carries data-graph-always, which is right for an app that opens records itself: the courier gateway has no page, and still opens.

  • Storefront
  • Orders API
  • Payments
  • Orders database
  • Courier gateway
  • The storefront places orders with the orders API
  • The orders API charges payments
  • The orders API writes the orders database
  • The orders API books parcels with the courier gateway
@inject ISednaUi Ui
@inject ISednaGraphs Graphs

<div class="graph-frame" style="--graph-height: 18rem">
    <div class="sedna-split sedna-split--sm">
        <div class="card">
            <div class="graph" data-graph id="owned-services" aria-label="Services and who owns them"
                 @onsedna-graph-select="Selected"
                 @onsedna-graph-open="Opened" @onsedna-graph-open:preventDefault>
                <ul class="graph-data" data-graph-data>
                    <li data-node="storefront" data-kind="Web app" data-icon="ri-store-2-line"
                        data-owner="Alex Fischer" data-on-call="Priya Nair" data-href="/services/storefront">Storefront</li>
                    <li data-node="orders" data-kind="Service" data-tone="2" data-icon="ri-server-line" data-meta="orders-console-01"
                        data-owner="Priya Nair" data-on-call="Jordan Weiss" data-href="/services/orders">Orders API</li>
                    <li data-node="payments" data-kind="Service" data-tone="2" data-icon="ri-bank-card-line"
                        data-owner="Jordan Weiss" data-on-call="Alex Fischer" data-href="/services/payments">Payments</li>
                    <li data-node="orders-db" data-kind="Database" data-tone="4" data-shape="square" data-icon="ri-database-2-line"
                        data-meta="src-db-14" data-owner="Rin Nakamura" data-on-call="Priya Nair" data-href="/services/orders-db">Orders database</li>
                    <li data-node="courier" data-kind="Outside service" data-tone="muted" data-shape="hexagon" data-icon="ri-truck-line"
                        data-owner="Jordan Weiss" data-on-call="Rin Nakamura">Courier gateway</li>
                    <li data-edge data-source="storefront" data-target="orders" data-label="places orders">The storefront places orders with the orders API</li>
                    <li data-edge data-source="orders" data-target="payments" data-label="charges">The orders API charges payments</li>
                    <li data-edge data-source="orders" data-target="orders-db" data-label="writes">The orders API writes the orders database</li>
                    <li data-edge data-source="orders" data-target="courier" data-label="books">The orders API books parcels with the courier gateway</li>
                </ul>
                <div class="graph-canvas"></div>
                @* data-graph-always keeps Open for the courier gateway, which has no data-href:
                   opening is the app's, below, whatever the record carries. *@
                <div class="menu graph-menu" data-graph-menu role="menu" hidden>
                    <span class="menu-label" data-graph-field="label"></span>
                    <button class="menu-item" role="menuitem" type="button" data-graph-action="select"><i class="ri-user-line"></i> <span>Who owns it</span></button>
                    <button class="menu-item" role="menuitem" type="button" data-graph-action="open" data-graph-always><i class="ri-external-link-line"></i> <span>Open</span></button>
                </div>
                <div class="graph-wait skeleton skeleton-block"></div>
            </div>
        </div>
        <aside class="card card--fill sedna-split-aside graph-detail" aria-live="polite">
            @if (_selected is { } service)
            {
                <div class="card-head">
                    <strong>@service.Label</strong>
                    <span class="badge">@service.Kind</span>
                </div>
                <div class="card-body">
                    <div class="kv"><span class="k">Owner</span><span>@service.Fields.GetValueOrDefault("owner")</span></div>
                    @* data-on-call arrives under the browser's own name for it: onCall. *@
                    <div class="kv"><span class="k">On call</span><span>@service.Fields.GetValueOrDefault("onCall")</span></div>
                    <div class="kv"><span class="k">Host</span><span class="v">@(service.Meta ?? "—")</span></div>
                </div>
                <div class="card-foot">
                    <button class="btn btn--sm" type="button" @onclick="Clear">Clear</button>
                </div>
            }
            else
            {
                <div class="card-body">
                    <div class="empty-state empty-state--bare">
                        <i class="ri-cursor-line state-icon state-icon--sm" aria-hidden="true"></i>
                        <span class="empty-state-title">No service selected</span>
                        <p>Click one to see who owns it. Double-click or right-click opens it.</p>
                    </div>
                </div>
            }
        </aside>
    </div>
</div>

@code {
    private SednaGraphNodeEventArgs? _selected;

    // Id is null when the selection is cleared: Escape, a click on the background, or Clear.
    private void Selected(SednaGraphNodeEventArgs e) => _selected = e.Id is null ? null : e;

    // The graph clears its own selection and says so through @onsedna-graph-select, so the
    // panel follows the graph rather than being cleared here as well.
    private Task Clear() => Graphs.SelectAsync("owned-services", null);

    // Cancelled by :preventDefault above, so the record's data-href is not followed and the
    // app decides what opening means — its own page, a drawer, or here, a toast.
    private Task Opened(SednaGraphNodeEventArgs e) => Ui.ToastAsync(
        $"{e.Label} would open {e.Href ?? "its supplier record"}", ToastKind.Info,
        title: e.Keyboard ? "Opened with Enter" : "Opened");
}

Live updates

Live — drag the Orders API somewhere, then open a warehouse. The component owns the two lists; each control changes them and sends them whole. The script works out the difference: what changed is restyled where it stands, what left goes, and a new record is put beside what it links to, with only its neighbours moving to make room — everything else stays where the reader left it, so a change made while they are looking does not rearrange the drawing under them.

A layout whose shape is the point, such as dagre or grid, runs again instead. relayout: true lays out the whole drawing again in any layout. data-graph-arrive="pop" swells one halo around each record that arrives, so a graph that grows on a screen across the room is seen growing, and data-graph-announce-arrive says it in the live region.

Loading…
@inject ISednaGraphs Graphs

<div class="card">
    <div class="graph graph--sm" data-graph data-graph-deferred data-graph-arrive="pop" id="fulfilment"
         aria-label="Warehouses, and the services they stock from and ship through">
        <div class="graph-canvas"></div>
        <div class="graph-wait skeleton skeleton-block"></div>
        <p class="visually-hidden" data-graph-live aria-live="polite" data-graph-announce-arrive="{label} opened."></p>
    </div>
    <div class="card-foot card-foot--start">
        <button class="btn btn--sm" type="button" @onclick="OpenWarehouse" disabled="@(_opened.Count == Sites.Length)">
            <i class="ri-add-line"></i> <span>Open a warehouse</span>
        </button>
        <button class="btn btn--sm" type="button" @onclick="CloseNewest" disabled="@(_opened.Count == 0)">
            Close the newest
        </button>
        <label class="switch"><input type="checkbox" @bind="_fault" @bind:after="MarkFault" /> <span>Stock fault</span></label>
        <span class="form-hint sedna-push">@_status</span>
    </div>
</div>

@code {
    private const string Id = "fulfilment";
    private static readonly string[] Sites = ["East", "Harbour", "Airport"];

    private readonly List<SednaGraphNode> _nodes =
    [
        new("inventory", "Inventory") { Kind = "service", Icon = "ri-stack-line", Root = true },
        new("orders", "Orders API") { Kind = "service", Tone = SednaGraphTone.Series(2), Icon = "ri-server-line" },
        new("courier", "Courier gateway") { Kind = "external", Tone = SednaGraphTone.Muted, Shape = SednaGraphShape.Diamond, Icon = "ri-truck-line" },
        Warehouse("North"),
        Warehouse("South"),
    ];

    private readonly List<SednaGraphEdge> _edges =
    [
        new("orders", "inventory") { Label = "reserves stock" },
        .. Links("North"),
        .. Links("South"),
    ];

    private readonly Stack<string> _opened = new();
    private bool _fault;
    private string _status = "Loading…";

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
        await Send();
        StateHasChanged();
    }

    private Task OpenWarehouse()
    {
        var site = Sites[_opened.Count];
        _opened.Push(site);
        _nodes.Add(Warehouse(site));
        _edges.AddRange(Links(site));
        return Send();
    }

    private Task CloseNewest()
    {
        var id = IdOf(_opened.Pop());
        _nodes.RemoveAll(n => n.Id == id);
        _edges.RemoveAll(e => e.Source == id || e.Target == id);
        return Send();
    }

    private Task MarkFault()
    {
        // Same id, new values: the record is restyled where it stands.
        var i = _nodes.FindIndex(n => n.Id == "inventory");
        _nodes[i] = _nodes[i] with
        {
            Tone = _fault ? SednaGraphTone.Danger : null,
            Meta = _fault ? "Counts are 20 minutes behind" : null,
        };
        return Send();
    }

    // Every record and link, every time. The script works out the difference: what stayed
    // keeps its place, and a new warehouse is put beside what it links to.
    private async Task Send()
    {
        var shown = await Graphs.SetDataAsync(Id, new SednaGraphData(_nodes, _edges));
        _status = shown is null ? "" : $"{shown.Nodes} records, {shown.Edges} links";
    }

    private static string IdOf(string site) => "warehouse-" + site.ToLowerInvariant();

    private static SednaGraphNode Warehouse(string site) =>
        new(IdOf(site), $"Warehouse {site}") { Kind = "warehouse", Tone = SednaGraphTone.Series(3), Icon = "ri-building-4-line" };

    private static SednaGraphEdge[] Links(string site) =>
    [
        new("inventory", IdOf(site)) { Label = "stocks" },
        new(IdOf(site), "courier") { Label = "ships with", Line = SednaGraphLine.Dashed },
    ];
}

A run, step by step

Live — press Step, or Run. The component keeps where the run has got to and sends every record again with its State, a SednaGraphState; the links carry none and are drawn in their targets' states. Only the records whose state changed, and the links into them, are redrawn, and nothing is laid out again, so a step the reader is watching stays where it is.

A record with no State is a step the run has not reached, and is sent without one. The record in @onsedna-graph-select carries its State back.

Loading…
@inject ISednaGraphs Graphs
@implements IDisposable

<div class="card">
    <div class="graph graph--sm" data-graph data-graph-deferred id="order-run" data-graph-layout="dagre" data-graph-direction="LR"
         data-graph-nodes="box" data-graph-arrows="target" data-graph-edge-labels="none" data-graph-drag="false"
         aria-label="An order's approval run, step by step">
        <div class="graph-canvas"></div>
        <div class="graph-wait skeleton skeleton-block"></div>
    </div>
    <div class="card-foot card-foot--start">
        <button class="btn btn--sm btn--brand btn--solid" type="button" @onclick="Run" disabled="@(_run is not null || Finished)">
            <i class="ri-play-line"></i> <span>Run</span>
        </button>
        <button class="btn btn--sm" type="button" @onclick="Step" disabled="@(_run is not null || Finished)">
            <i class="ri-skip-forward-line"></i> <span>Step</span>
        </button>
        <button class="btn btn--sm" type="button" @onclick="Reset" disabled="@(_step == 0 && _run is null)">
            <i class="ri-restart-line"></i> <span>Reset</span>
        </button>
        <span class="form-hint sedna-push" role="status">@_status</span>
    </div>
</div>

@code {
    private const string Id = "order-run";

    private static readonly SednaGraphNode[] Steps =
    [
        new("received", "Order received") { Kind = "trigger", Icon = "ri-shopping-cart-2-line" },
        new("stock", "Check stock") { Kind = "check", Tone = SednaGraphTone.Series(2), Icon = "ri-stack-line" },
        new("credit", "Check credit") { Kind = "check", Tone = SednaGraphTone.Series(2), Icon = "ri-bank-card-line" },
        new("in-stock", "In stock?") { Kind = "decision", Tone = SednaGraphTone.Series(6), Icon = "ri-route-line" },
        new("approval", "Manager approval") { Kind = "person", Tone = SednaGraphTone.Series(5), Icon = "ri-user-follow-line" },
        new("reserve", "Reserve stock") { Kind = "action", Tone = SednaGraphTone.Series(3), Icon = "ri-archive-line" },
        new("back-order", "Back-order") { Kind = "action", Tone = SednaGraphTone.Series(3), Icon = "ri-time-line" },
        new("confirm", "Confirm the order") { Kind = "action", Tone = SednaGraphTone.Series(3), Icon = "ri-mail-send-line" },
    ];

    // No state on a link: each one is drawn in its target's.
    private static readonly SednaGraphEdge[] Links =
    [
        new("received", "stock"),
        new("received", "credit"),
        new("stock", "in-stock"),
        new("credit", "approval"),
        new("in-stock", "reserve") { Label = "yes" },
        new("in-stock", "back-order") { Label = "no" },
        new("reserve", "confirm"),
        new("approval", "confirm"),
    ];

    // What each step of the run changes. A record keeps its state until a later step changes it.
    private static readonly Dictionary<string, SednaGraphState>[] Changes =
    [
        new() { ["received"] = SednaGraphState.Next },
        new() { ["received"] = SednaGraphState.Running },
        new() { ["received"] = SednaGraphState.Done, ["stock"] = SednaGraphState.Running, ["credit"] = SednaGraphState.Running },
        new() { ["stock"] = SednaGraphState.Done, ["in-stock"] = SednaGraphState.Running },
        new()
        {
            ["credit"] = SednaGraphState.Done, ["approval"] = SednaGraphState.Waiting, ["in-stock"] = SednaGraphState.Done,
            ["reserve"] = SednaGraphState.Running, ["back-order"] = SednaGraphState.Skipped,
        },
        new() { ["reserve"] = SednaGraphState.Done, ["confirm"] = SednaGraphState.Next },
        new() { ["approval"] = SednaGraphState.Done, ["confirm"] = SednaGraphState.Running },
        new() { ["confirm"] = SednaGraphState.Done },
    ];

    private readonly Dictionary<string, SednaGraphState> _states = [];
    private int _step;
    private CancellationTokenSource? _run;
    private string _status = "Loading…";

    private bool Finished => _step == Changes.Length - 1;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
        await Show(0);
        StateHasChanged();
    }

    private Task Step() => Show(_step + 1);

    // Steps on its own until the run is finished, or Reset stops it.
    private async Task Run()
    {
        using var run = new CancellationTokenSource();
        _run = run;
        try
        {
            while (!Finished && await Delay(1200, run.Token))
            {
                await Show(_step + 1);
                StateHasChanged();
            }
        }
        finally
        {
            _run = null;
        }
    }

    // Waits, and answers false rather than throwing once the wait is cancelled.
    private static async Task<bool> Delay(int milliseconds, CancellationToken token)
    {
        await Task.Delay(milliseconds, token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
        return !token.IsCancellationRequested;
    }

    private Task Reset()
    {
        _run?.Cancel();
        return Show(0);
    }

    // Every record and link again, each record with its state. Only what changed is redrawn, where it stands.
    private async Task Show(int step)
    {
        if (step == 0) _states.Clear();
        _step = step;
        foreach (var (id, state) in Changes[step]) _states[id] = state;

        var nodes = Steps.Select(s => s with { State = _states.TryGetValue(s.Id, out var state) ? state : null }).ToList();
        await Graphs.SetDataAsync(Id, new SednaGraphData(nodes, Links));

        var done = _states.Count(s => s.Value == SednaGraphState.Done);
        var skipped = _states.Count(s => s.Value == SednaGraphState.Skipped);
        var waiting = Steps.Where(s => _states.TryGetValue(s.Id, out var state) && state == SednaGraphState.Waiting).Select(s => s.Label).ToList();
        _status = $"{done} of {Steps.Length - skipped} steps done" + (waiting.Count > 0 ? $" · waiting on {string.Join(", ", waiting)}" : "");
    }

    public void Dispose() => _run?.Cancel();
}

Markup rendered by Razor

Live — switch a stage on. The records are li[data-node] and li[data-edge] written by a @foreach, and there is no interop call at all: the graph reads its list again after every render that changes it, so a switch is a re-render and nothing else. @key keeps each item the same element from one render to the next.

A link's text is the sentence a screen reader reads from the list; data-graph-edge-labels="none" keeps it off the canvas.

Release pipeline
  • Build
  • Unit tests
  • Security scan
  • Package
  • Deploy to staging
  • Smoke tests
  • Deploy to production
  • Build, then Unit tests
  • Build, then Security scan
  • Unit tests, then Package
  • Security scan, then Package
  • Package, then Deploy to staging
  • Deploy to staging, then Smoke tests
  • Smoke tests, then Deploy to production
<div class="card">
    <div class="card-head">
        <strong>Release pipeline</strong>
        <div class="card-head-right">
            <label class="switch"><input type="checkbox" @bind="_scan" /> <span>Security scan</span></label>
            <label class="switch"><input type="checkbox" @bind="_loadTest" /> <span>Load test</span></label>
        </div>
    </div>
    <div class="graph graph--sm" data-graph id="release-pipeline" aria-label="The stages of a release"
         data-graph-layout="dagre" data-graph-direction="LR" data-graph-nodes="box"
         data-graph-arrows="target" data-graph-edge-labels="none">
        <ul class="graph-data" data-graph-data>
            @foreach (var stage in Stages)
            {
                <li @key="stage.Id" data-node="@stage.Id" data-tone="@stage.Tone" data-icon="@stage.Icon">@stage.Name</li>
            }
            @foreach (var (from, to) in Flow.Where(f => Stages.Any(s => s.Id == f.From) && Stages.Any(s => s.Id == f.To)))
            {
                <li @key="(from, to)" data-edge data-source="@from" data-target="@to">@Name(from), then @Name(to)</li>
            }
        </ul>
        <div class="graph-canvas"></div>
        <div class="graph-wait skeleton skeleton-block"></div>
    </div>
</div>

@code {
    private bool _scan = true;
    private bool _loadTest;

    private sealed record Stage(string Id, string Name, string Tone, string Icon);

    private static readonly Stage[] All =
    [
        new("build", "Build", "1", "ri-hammer-line"),
        new("unit", "Unit tests", "3", "ri-test-tube-line"),
        new("scan", "Security scan", "3", "ri-shield-check-line"),
        new("package", "Package", "1", "ri-box-3-line"),
        new("staging", "Deploy to staging", "2", "ri-rocket-line"),
        new("smoke", "Smoke tests", "3", "ri-test-tube-line"),
        new("load", "Load test", "3", "ri-pulse-line"),
        new("production", "Deploy to production", "go", "ri-rocket-line"),
    ];

    private static readonly (string From, string To)[] Flow =
    [
        ("build", "unit"), ("build", "scan"), ("unit", "package"), ("scan", "package"),
        ("package", "staging"), ("staging", "smoke"), ("staging", "load"),
        ("smoke", "production"), ("load", "production"),
    ];

    // The optional stages are in the list only while their switch is on. The graph reads
    // the list again after every render that changes it.
    private IEnumerable<Stage> Stages => All.Where(s => s.Id switch
    {
        "scan" => _scan,
        "load" => _loadTest,
        _ => true,
    });

    private static string Name(string id) => All.First(s => s.Id == id).Name;
}

Live — switch on Draw links, then drag from one service to another. SetDrawingAsync switches drawing on and off; data-graph-connect on the element starts with it on. A link drawn arrives as @onsedna-graph-connect with Source and Target, and the line the reader drew is gone again at once: the link is the app's to add, as a drop in a drag-and-drop list is.

The handler adds it to the component's list and sends the list again. Removing a chip takes the link out the same way, so the drawing and the list cannot disagree.

Which service calls which
Switch on Draw links, then drag between two services.
@inject ISednaGraphs Graphs

<div class="card">
    <div class="card-head">
        <strong>Which service calls which</strong>
        <label class="switch"><input type="checkbox" @bind="_drawing" @bind:after="Draw" /> <span>Draw links</span></label>
    </div>
    <div class="graph graph--sm" data-graph data-graph-deferred id="drawn-calls" data-graph-arrows="target"
         aria-label="Services, and the calls drawn between them" @onsedna-graph-connect="Connected">
        <div class="graph-canvas"></div>
        <div class="graph-wait skeleton skeleton-block"></div>
    </div>
    <div class="card-foot card-foot--start">
        @if (_drawn.Count == 0)
        {
            <span class="form-hint">@(_drawing ? "Drag from one service to another." : "Switch on Draw links, then drag between two services.")</span>
        }
        <div class="chip-set">
            @foreach (var link in _drawn)
            {
                <span class="chip chip--active" @key="link">
                    <span class="chip-label">@Name(link.Source) calls @Name(link.Target)</span>
                    <button class="chip-dismiss" type="button" aria-label="Remove the link from @Name(link.Source) to @Name(link.Target)"
                            @onclick="() => Remove(link)">
                        <i class="ri-close-line"></i>
                    </button>
                </span>
            }
        </div>
    </div>
</div>

@code {
    private const string Id = "drawn-calls";
    private bool _drawing;
    private readonly List<SednaGraphEdge> _drawn = [];

    private static readonly SednaGraphNode[] Services =
    [
        new("storefront", "Storefront") { Icon = "ri-store-2-line" },
        new("basket", "Basket") { Tone = SednaGraphTone.Series(2), Icon = "ri-shopping-cart-2-line" },
        new("orders", "Orders API") { Tone = SednaGraphTone.Series(2), Icon = "ri-server-line" },
        new("payments", "Payments") { Tone = SednaGraphTone.Series(2), Icon = "ri-bank-card-line" },
        new("mail", "Order e-mails") { Tone = SednaGraphTone.Series(5), Icon = "ri-mail-send-line" },
    ];

    private static readonly SednaGraphEdge[] Known =
    [
        new("storefront", "basket") { Label = "calls" },
        new("basket", "orders") { Label = "calls" },
    ];

    protected override Task OnAfterRenderAsync(bool firstRender) => firstRender ? Send() : Task.CompletedTask;

    private Task Draw() => Graphs.SetDrawingAsync(Id, _drawing);

    // The line the reader drew is gone again by the time this runs: a link is the app's to
    // add, as a drop is. Add it to the data, send the data, and the graph draws it.
    private async Task Connected(SednaGraphConnectEventArgs e)
    {
        if (Known.Concat(_drawn).Any(l => l.Source == e.Source && l.Target == e.Target)) return;
        _drawn.Add(new SednaGraphEdge(e.Source, e.Target) { Label = "calls", Tone = SednaGraphTone.Accent });
        await Send();
    }

    private Task Remove(SednaGraphEdge link)
    {
        _drawn.Remove(link);
        return Send();
    }

    private Task Send() => Graphs.SetDataAsync(Id, new SednaGraphData(Services, [.. Known, .. _drawn]));

    private static string Name(string id) => Services.First(s => s.Id == id).Label;
}

Filters from C#

Live — choose a team, then switch a kind off. Controls bound with @bind and @bind:after call FilterAsync, which answers what is left on screen. The two halves of a SednaGraphFilter ask different questions. Nodes is a choice: pick a team, and a service in no team is not what was picked, so Sign-in goes too. Except is a switch turned off: it hides what it names and nothing else, so Sign-in, which has no kind, stays.

A field is a record's property — kind, group, tone, tags — or one of its Fields. Each call replaces the whole filter, its Focus included, and controls with data-graph-filter in the page apply on top of it.

Nothing matches these filters
Loading…
@inject ISednaGraphs Graphs

<div class="card">
    <div class="card-head">
        <div class="graph-legend">
            @foreach (var kind in Kinds)
            {
                <label class="form-check">
                    <input type="checkbox" @bind="_kinds[kind.Id]" @bind:after="Apply" />
                    <span class="graph-key"><span class="graph-swatch @kind.Swatch"></span><span>@kind.Name</span></span>
                </label>
            }
            <label class="switch"><input type="checkbox" @bind="_retired" @bind:after="Apply" /> <span>Retired</span></label>
        </div>
        <select class="form-input form-select form-select--sm sedna-w-fit" aria-label="Team"
                @bind="_team" @bind:after="Apply">
            <option value="">Every team</option>
            <option value="checkout">Checkout</option>
            <option value="catalogue">Catalogue</option>
            <option value="fulfilment">Fulfilment</option>
        </select>
    </div>
    <div class="graph graph--sm" data-graph data-graph-deferred id="filtered-services" data-graph-layout="islands"
         aria-label="Services by team">
        <div class="graph-canvas"></div>
        <div class="graph-wait skeleton skeleton-block"></div>
        <div class="graph-empty graph-empty--filtered empty-state empty-state--bare">
            <span class="empty-state-title">Nothing matches these filters</span>
        </div>
    </div>
    <div class="card-foot card-foot--start">
        <span class="form-hint">@(_stats is null ? "Loading…" : $"Showing {_stats.Nodes} of {_stats.TotalNodes} services")</span>
    </div>
</div>

@code {
    private const string Id = "filtered-services";

    private sealed record Kind(string Id, string Name, string Swatch);

    private static readonly Kind[] Kinds =
    [
        new("service", "Services", "series-2"),
        new("store", "Stores", "series-4 graph-swatch--square"),
        new("queue", "Queues", "series-5 graph-swatch--diamond"),
    ];

    private readonly Dictionary<string, bool> _kinds = Kinds.ToDictionary(k => k.Id, _ => true);
    private bool _retired = true;
    private string _team = "";
    private SednaGraphStats? _stats;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
        _stats = await Graphs.SetDataAsync(Id, Data);
        StateHasChanged();
    }

    private async Task Apply() => _stats = await Graphs.FilterAsync(Id, new SednaGraphFilter
    {
        // A choice: a record in no team is not the team chosen, so it goes too.
        Nodes = _team == "" ? null : new Dictionary<string, IReadOnlyList<string>> { ["group"] = [_team] },
        // Switches turned off: they hide what they name, and a record with no kind stays.
        Except = new Dictionary<string, IReadOnlyList<string>>
        {
            ["kind"] = [.. Kinds.Where(k => !_kinds[k.Id]).Select(k => k.Id)],
        },
        Muted = _retired,
    });

    private static readonly SednaGraphData Data = new(
    [
        Service("storefront", "Storefront", "checkout"),
        Service("basket", "Basket", "checkout"),
        Service("payments", "Payments", "checkout"),
        Store("orders-db", "Orders database", "checkout"),
        Service("search", "Search", "catalogue"),
        Service("prices", "Prices", "catalogue"),
        Store("search-index", "Search index", "catalogue"),
        Service("legacy-prices", "Old price list", "catalogue") with { Muted = true },
        Service("inventory", "Inventory", "fulfilment"),
        Queue("picking", "Picking queue", "fulfilment"),
        Store("stock-db", "Stock database", "fulfilment"),
        new("identity", "Sign-in") { Icon = "ri-key-2-line", Meta = "Shared by every team" },
    ],
    [
        new("storefront", "basket"), new("basket", "payments"), new("basket", "orders-db"),
        new("storefront", "search"), new("search", "search-index"), new("search", "prices"),
        new("legacy-prices", "prices"), new("basket", "inventory"), new("inventory", "stock-db"),
        new("inventory", "picking"), new("storefront", "identity"), new("payments", "identity"),
    ]);

    private static SednaGraphNode Service(string id, string label, string team) =>
        new(id, label) { Kind = "service", Group = team, Tone = SednaGraphTone.Series(2), Icon = "ri-server-line" };

    private static SednaGraphNode Store(string id, string label, string team) =>
        new(id, label) { Kind = "store", Group = team, Tone = SednaGraphTone.Series(4), Shape = SednaGraphShape.Square, Icon = "ri-database-2-line" };

    private static SednaGraphNode Queue(string id, string label, string team) =>
        new(id, label) { Kind = "queue", Group = team, Tone = SednaGraphTone.Series(5), Shape = SednaGraphShape.Diamond, Icon = "ri-inbox-line" };
}

Search, focus and export

SearchAsync marks the records whose name, id, meta, kind or tags contain every word, and answers how many; a search marks rather than hides, because where a match sits is the point. FocusAsync shows one record and its neighbours to a depth, and everything again with a null id.

ExportAsync answers with the whole drawing, not only the part in view, as a data: URL. The answer comes back through the circuit, and a SignalR message is limited to 32 KB unless the app raises MaximumReceiveMessageSize: a PNG, or the SVG of more than a small graph, is over it and drops the connection. Keep it for a small picture the page shows. DownloadAsync saves a file of any size in the browser, and only its name is sent.

@inject ISednaGraphs Graphs

<div class="card">
    <div class="card-head">
        <div class="toolbar-filters">
            <input class="form-input form-input--sm toolbar-input" type="search" placeholder="Find a service"
                   aria-label="Find a service" @bind="_query" @bind:event="oninput" @bind:after="Search" />
            <select class="form-input form-select form-select--sm sedna-w-fit" aria-label="Show around"
                    @bind="_focus" @bind:after="Focus">
                <option value="">Everything</option>
                @foreach (var service in Services)
                {
                    <option value="@service.Id">Around @service.Label</option>
                }
            </select>
        </div>
        <span class="form-hint text-nowrap">@_status</span>
    </div>
    <div class="graph graph--sm" data-graph data-graph-deferred id="explored-services" aria-label="Checkout services">
        <div class="graph-canvas"></div>
        <div class="graph-wait skeleton skeleton-block"></div>
    </div>
    <div class="card-foot card-foot--start">
        <button class="btn btn--sm" type="button" @onclick="Export"><i class="ri-image-line"></i> <span>Picture</span></button>
        <button class="btn btn--sm" type="button" @onclick="Download"><i class="ri-download-2-line"></i> <span>Download PNG</span></button>
        @if (_picture is not null)
        {
            <img class="sedna-push" src="@_picture" alt="The checkout services, as a picture" width="160" />
        }
    </div>
</div>

@code {
    private const string Id = "explored-services";
    private string _query = "";
    private string _focus = "";
    private string? _picture;
    private string _status = "";

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
        await Graphs.SetDataAsync(Id, new SednaGraphData(Services, Calls));
    }

    // Marks rather than hides: where a match sits among the others is the point.
    private async Task Search()
    {
        var shown = await Graphs.SearchAsync(Id, _query);
        _status = _query.Trim() == "" || shown is null ? "" : $"{shown.Matches} found";
    }

    // One record and what it links to, or everything again.
    private async Task Focus()
    {
        var shown = await Graphs.FocusAsync(Id, _focus == "" ? null : _focus, depth: 1);
        _status = shown is null ? "" : $"{shown.Nodes} of {shown.TotalNodes} services shown";
    }

    // The whole drawing, not only the part in view, as the file's bytes — streamed from the
    // browser, so no circuit message size applies. Shown here; saved, it is DownloadAsync.
    private async Task Export()
    {
        var svg = await Graphs.ExportAsync(Id, SednaGraphExport.Svg);
        _picture = svg is null ? null : "data:image/svg+xml;base64," + Convert.ToBase64String(svg);
    }

    // A file is the browser's to save: nothing but the name crosses the circuit.
    private Task Download() => Graphs.DownloadAsync(Id, SednaGraphExport.Png, "checkout-services");

    private static readonly SednaGraphNode[] Services =
    [
        new("storefront", "Storefront") { Kind = "web", Icon = "ri-store-2-line", Tags = ["public"] },
        new("basket", "Basket") { Kind = "service", Tone = SednaGraphTone.Series(2), Icon = "ri-shopping-cart-2-line" },
        new("orders", "Orders API") { Kind = "service", Tone = SednaGraphTone.Series(2), Icon = "ri-server-line", Meta = "orders-console-01" },
        new("payments", "Payments") { Kind = "service", Tone = SednaGraphTone.Series(2), Icon = "ri-bank-card-line", Tags = ["pci"] },
        new("orders-db", "Orders database") { Kind = "store", Tone = SednaGraphTone.Series(4), Shape = SednaGraphShape.Square, Icon = "ri-database-2-line", Meta = "src-db-14" },
        new("mail", "Order e-mails") { Kind = "service", Tone = SednaGraphTone.Series(5), Icon = "ri-mail-send-line" },
        new("courier", "Courier gateway") { Kind = "external", Tone = SednaGraphTone.Muted, Shape = SednaGraphShape.Diamond, Icon = "ri-truck-line", Tags = ["public"] },
    ];

    private static readonly SednaGraphEdge[] Calls =
    [
        new("storefront", "basket") { Label = "calls" },
        new("basket", "orders") { Label = "places" },
        new("orders", "payments") { Label = "charges" },
        new("orders", "orders-db") { Label = "writes" },
        new("orders", "mail") { Label = "notifies" },
        new("orders", "courier") { Label = "books", Line = SednaGraphLine.Dashed },
    ];
}

The view, from C#

Live — change the layout, colour by health, then fold every team. What a data-graph-option control or a data-graph-action button does without a line of C#, a component does with a call when its own state decides it. LayoutAsync takes a SednaGraphLayout and, for dagre and tree, a direction. SetOptionAsync changes one drawing option by the name its control uses — here colour, which is tone or the name of one of each record's Tones. SetHullsAsync outlines every Group, which is not a container: card data runs through two teams and one outside service.

CollapseAsync and ExpandAsync fold and open the group named, or every group with null, and answer what is on screen afterwards; FitAsync after a fold fills the frame with what is left. ZoomAsync takes a factor about the middle. A group the reader folds with its own cue — data-graph-collapse — arrives as @onsedna-graph-collapse, and StatsAsync asks what is on screen now.

Loading…
@inject ISednaGraphs Graphs

<div class="card">
    <div class="card-head">
        <div class="toolbar-filters">
            <select class="form-input form-select form-select--sm sedna-w-fit" aria-label="Layout"
                    @bind="_layout" @bind:after="Relayout">
                @foreach (var choice in Layouts)
                {
                    <option value="@choice.Key">@choice.Value.Label</option>
                }
            </select>
            <select class="form-input form-select form-select--sm sedna-w-fit" aria-label="Colour by"
                    @bind="_colour" @bind:after="Recolour">
                <option value="tone">Colour by team</option>
                <option value="health">Colour by health</option>
            </select>
            <label class="switch"><input type="checkbox" @bind="_outlines" @bind:after="Outline" /> <span>Card data</span></label>
        </div>
        <span class="form-hint text-nowrap">@Shown</span>
    </div>
    <div class="graph graph--sm" data-graph data-graph-deferred data-graph-collapse data-graph-layout="fcose"
         data-graph-arrows="target" id="release-map" aria-label="The shop's services, by team"
         @onsedna-graph-collapse="Recount" @onsedna-graph-expand="Recount">
        <div class="graph-canvas"></div>
        <div class="graph-wait skeleton skeleton-block"></div>
    </div>
    <div class="card-foot card-foot--start">
        <button class="btn btn--sm" type="button" @onclick="FoldAll"><i class="ri-checkbox-indeterminate-line"></i> <span>Fold every team</span></button>
        <button class="btn btn--sm" type="button" @onclick="OpenAll"><i class="ri-add-box-line"></i> <span>Open every team</span></button>
        <div class="btn-group sedna-push" role="group" aria-label="Zoom">
            <button class="btn btn--sm btn--icon" type="button" aria-label="Zoom out" @onclick="() => Graphs.ZoomAsync(Id, 0.8)"><i class="ri-zoom-out-line"></i></button>
            <button class="btn btn--sm btn--icon" type="button" aria-label="Zoom in" @onclick="() => Graphs.ZoomAsync(Id, 1.25)"><i class="ri-zoom-in-line"></i></button>
            <button class="btn btn--sm btn--icon" type="button" aria-label="Fit" @onclick="() => Graphs.FitAsync(Id)"><i class="ri-focus-3-line"></i></button>
        </div>
    </div>
</div>

@code {
    private const string Id = "release-map";

    // A layout and, for dagre and tree, its direction; null keeps what the layout does by default.
    private static readonly Dictionary<string, (string Label, string Layout, string? Direction)> Layouts = new()
    {
        ["fcose"] = ("By team", SednaGraphLayout.Fcose, null),
        ["dagre-lr"] = ("Left to right", SednaGraphLayout.Dagre, "LR"),
        ["dagre-tb"] = ("Top to bottom", SednaGraphLayout.Dagre, "TB"),
    };

    private string _layout = "fcose";
    private string _colour = "tone";
    private bool _outlines;
    private SednaGraphStats? _shown;

    private string Shown => _shown is null ? "Loading…" : $"{_shown.Nodes} records, {_shown.Edges} links";

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
        _shown = await Graphs.SetDataAsync(Id, new SednaGraphData(Services, Calls));
        StateHasChanged();
    }

    private Task Relayout()
    {
        var chosen = Layouts[_layout];
        return Graphs.LayoutAsync(Id, chosen.Layout, chosen.Direction);
    }

    // "tone" is each record's own Tone; any other value names one of its Tones.
    private Task Recolour() => Graphs.SetOptionAsync(Id, "colour", _colour);

    // The outline goes around every record of one Group: here, what handles card data.
    private Task Outline() => Graphs.SetHullsAsync(Id, _outlines);

    // A null group folds or opens every group, and answers what is on screen afterwards.
    private async Task FoldAll()
    {
        _shown = await Graphs.CollapseAsync(Id);
        await Graphs.FitAsync(Id);
    }

    private async Task OpenAll() => _shown = await Graphs.ExpandAsync(Id);

    // A group the reader folds with its own cue says so as an event; the count asks again.
    private async Task Recount() => _shown = await Graphs.StatsAsync(Id);

    private static Dictionary<string, string> Health(string tone) => new() { ["health"] = tone };

    private static readonly SednaGraphNode[] Services =
    [
        new("checkout", "Checkout") { Tone = SednaGraphTone.Series(2) },
        new("storefront", "Storefront") { Parent = "checkout", Tone = SednaGraphTone.Series(2), Icon = "ri-store-2-line", Tones = Health(SednaGraphTone.Go) },
        new("basket", "Basket") { Parent = "checkout", Tone = SednaGraphTone.Series(2), Icon = "ri-shopping-cart-2-line", Tones = Health(SednaGraphTone.Go) },
        new("payments", "Payments") { Parent = "checkout", Tone = SednaGraphTone.Series(2), Icon = "ri-bank-card-line", Group = "card-data", Tones = Health(SednaGraphTone.Warn) },

        new("fulfilment", "Fulfilment") { Tone = SednaGraphTone.Series(3) },
        new("orders", "Orders API") { Parent = "fulfilment", Tone = SednaGraphTone.Series(3), Icon = "ri-server-line", Meta = "orders-console-01", Tones = Health(SednaGraphTone.Go) },
        new("orders-db", "Orders database") { Parent = "fulfilment", Tone = SednaGraphTone.Series(3), Shape = SednaGraphShape.Square, Icon = "ri-database-2-line", Meta = "src-db-14", Group = "card-data", Tones = Health(SednaGraphTone.Go) },
        new("shipping", "Shipping") { Parent = "fulfilment", Tone = SednaGraphTone.Series(3), Icon = "ri-truck-line", Tones = Health(SednaGraphTone.Danger) },

        new("platform", "Platform") { Tone = SednaGraphTone.Series(5) },
        new("events", "Order events") { Parent = "platform", Tone = SednaGraphTone.Series(5), Shape = SednaGraphShape.Diamond, Icon = "ri-stack-line", Tones = Health(SednaGraphTone.Go) },
        new("mail", "Order e-mails") { Parent = "platform", Tone = SednaGraphTone.Series(5), Icon = "ri-mail-send-line", Tones = Health(SednaGraphTone.Warn) },

        new("card-processor", "Card processor") { Tone = SednaGraphTone.Muted, Shape = SednaGraphShape.Hexagon, Icon = "ri-global-line", Group = "card-data", Tones = Health(SednaGraphTone.Go) },
    ];

    private static readonly SednaGraphEdge[] Calls =
    [
        new("storefront", "basket") { Label = "calls" },
        new("basket", "orders") { Label = "places" },
        new("orders", "payments") { Label = "charges" },
        new("payments", "card-processor") { Label = "authorises" },
        new("orders", "orders-db") { Label = "writes" },
        new("orders", "events") { Label = "publishes" },
        new("events", "mail") { Label = "feeds" },
        new("events", "shipping") { Label = "feeds" },
    ];
}

A large graph over a circuit

A list rendered by Razor lives in the circuit's render tree: every record is held in server memory for as long as the reader stays, and every change is diffed and sent through it. That suits the few dozen records a component edits. For more, send them with SetDataAsync, which crosses once per change and leaves nothing in the render tree. For a graph the component only shows, let the browser fetch it: data-graph-src names a URL that answers { nodes, edges }, the same SednaGraphData, and the records never pass through the circuit at all. ReloadAsync fetches it again when the component learns the data changed, and the drawing keeps its place as it does for SetDataAsync.

Records the browser fetches

The endpoint is ordinary ASP.NET Core, so it is authorised like any other; the browser sends the page's cookies with the request. Until the records arrive the wait shows, and if they cannot be read the graph shows its .graph-empty--error. Refresh calls ReloadAsync, which fetches the URL again and answers what is on screen afterwards; the records still never cross the circuit.

// Program.cs — the records as JSON, the same SednaGraphData a call would send.
// JsonSerializerOptions.Web writes the camelCase names the script reads, whatever the
// app has done to its own JSON options.
app.MapGet("/api/service-map", async (IServiceCatalog catalog) =>
{
    var services = await catalog.ListAsync();
    return TypedResults.Json(new SednaGraphData(
        [.. services.Select(s => new SednaGraphNode(s.Id, s.Name) { Kind = s.Kind, Group = s.Team })],
        [.. services.SelectMany(s => s.Calls, (s, callee) => new SednaGraphEdge(s.Id, callee))]),
        JsonSerializerOptions.Web);
}).RequireAuthorization();

@* ServiceMap.razor — the browser fetches the records itself, with the page's cookies,
   and not one of them passes through the circuit. *@
@inject ISednaGraphs Graphs
@inject NavigationManager Nav

<div class="toolbar">
    <span class="toolbar-count" data-graph-for="service-map" data-graph-stats="{nodes} services"></span>
    <button class="btn btn--sm" type="button" @onclick="Refresh"><i class="ri-refresh-line"></i> <span>Refresh</span></button>
</div>
<div class="graph graph--viewport" data-graph data-graph-src="/api/service-map"
     data-graph-layout="islands" id="service-map" aria-label="Every service, by team"
     @onsedna-graph-open="Open" @onsedna-graph-open:preventDefault>
    <div class="graph-canvas"></div>
    <div class="graph-wait skeleton skeleton-block"></div>
    <div class="graph-empty graph-empty--error empty-state">
        <span class="empty-state-title">The service map could not be loaded</span>
    </div>
</div>

@code {
    // When the component learns the map changed — a deploy finished, a message on the app's
    // own hub — the browser fetches it again and shows what changed, as SetDataAsync does.
    private Task Refresh() => Graphs.ReloadAsync("service-map");

    private void Open(SednaGraphNodeEventArgs e) => Nav.NavigateTo($"/services/{e.Id}");
}

The service

MemberDoesAnswers
SetDataAsync(id, data, relayout)Replaces every record and link; the script works out what changed.stats
FilterAsync(id, filter)Narrows what is shown, replacing the last filter.stats
SearchAsync(id, text)Marks the matches and brings them into view; null or empty clears.stats
SelectAsync(id, nodeId)Selects and centres a record, or clears with null. Dispatches sedna-graph-select.—
FocusAsync(id, nodeId, depth)A record and its neighbours, 1 to 6 hops; everything again with null.stats
LayoutAsync(id, layout, direction)Lays out again, in a SednaGraphLayout.—
SetOptionAsync(id, option, value)labels, edge-labels, colour, nodes, curve, arrows, spacing, direction, layout, depth.—
FitAsync(id), ZoomAsync(id, factor)The view: everything in the frame, or a zoom about the middle.—
ExportAsync(id, format)The whole drawing as SVG or PNG, as a data: URL.string
DownloadAsync(id, format, fileName)Saves it as a file, in the browser.—
CollapseAsync(id, groupId), ExpandAsync(id, groupId)Folds or unfolds a group, or every group with null.stats
SetDrawingAsync(id, on)Drawing links by dragging between records.—
SetHullsAsync(id, on)The outlines around each Group.—
ReloadAsync(id)Fetches data-graph-src again and shows what changed.stats
StatsAsync(id)What is on screen now.stats

Stats are a SednaGraphStats: records and links on screen, search matches, records and links in the data, and the selected record's id. Every member takes a CancellationToken, so a layout of a large graph can outlast Blazor's one-minute default.

Events

AttributeArgumentsWhen
@onsedna-graph-readySednaGraphEventArgsOnce, when the graph has started — for a deferred graph, before its records arrive. The first SetDataAsync completes once they are drawn.
@onsedna-graph-changeSednaGraphEventArgsAfter new data, a filter, a search or a fold.
@onsedna-graph-selectSednaGraphNodeEventArgsA record selected; Id null when the selection is cleared.
@onsedna-graph-openSednaGraphNodeEventArgsA double-click or Enter. :preventDefault keeps data-href from being followed.
@onsedna-graph-hoverSednaGraphNodeEventArgsA record pointed at, or reached with the arrow keys.
@onsedna-graph-contextSednaGraphContextEventArgsA right click, a long press or the context-menu key, with X and Y. :preventDefault keeps the app's [data-graph-menu] shut.
@onsedna-graph-connectSednaGraphConnectEventArgsA link drawn, from Source to Target.
@onsedna-graph-expand, -collapseSednaGraphNodeEventArgsA group unfolded or folded.

Every event carries plain values copied out of the record — strings, numbers and a dictionary of its fields — and nothing that points back into the browser, so a handler can keep it, compare it and render from it. The events are ordinary bubbling DOM events: one handler on a container hears every graph inside it. Each is a message through the circuit, so bind -hover only where the server needs to know what the pointer is over.