Skip to content

Bidirectional SignalR

H.P. Gansevoort edited this page Sep 12, 2026 · 10 revisions

Bidirectional SignalR Interactive Charts

A browser interaction does one of three things. A mutation event changes the server-side Figure, which is the authoritative copy. A brush-select event goes to a handler you wrote. A hover event queries the server for a response sent back to that caller only. Mutation events shipped in v1.2.0. Notification events shipped in v1.2.2, with the first per-caller response mechanism in the library. Everything is .NET, there is no JavaScript charting library, and the server stays the source of truth.

Why

Earlier versions handled browser gestures (wheel-zoom, drag-pan, legend click) in the browser: an IIFE manipulated the SVG, so the server never knew the zoom level. Every gesture now invokes a ChartHub method with a typed payload. The server mutates the authoritative Figure and pushes the updated SVG back through the existing publish fan-out. Several viewers stay in sync without extra work, because ChartHub.Subscribe already puts each connection in a SignalR group.

Architecture at a glance

┌─ browser ─────────────────────┐           ┌─ .NET server ─────────────────────────┐
│ SVG with embedded IIFE       │           │  ChartHub  ·  FigureRegistry            │
│ SvgSignalRInteractionScript  │           │  ┌──────────────────────────────────┐  │
│   wheel → OnZoom ────────────┼─── JS SR ──┼─→│ Publish(chartId, evt)            │  │
│   drag  → OnPan              │           │  │ ↓ Channel<FigureInteractionEvent>│  │
│   Home  → OnReset            │           │  │ ↓                                │  │
│   click → OnLegendToggle     │           │  │ ChartSession.DrainAsync (1 task) │  │
│                              │           │  │ ↓ evt.ApplyTo(figure)            │  │
│ DOM swap ← UpdateChartSvg ◄──┼──── SR ────┼──│ ↓ PublishSvgAsync                │  │
│                              │           │  └──────────────────────────────────┘  │
└──────────────────────────────┘           └───────────────────────────────────────┘

Each chart has one channel and one reader task, so mutations are serial by construction. There is no SemaphoreSlim, no lock and no ConcurrentBag. A hub method takes no longer than the channel write, and rendering happens off the hub call stack. A burst of 50 wheel events in one reader tick coalesces into one re-render.

Event hierarchy

The events are records that apply themselves, in three tiers of stacked inheritance. To add an interaction type, write a new subclass. There is no switch, no visitor and no dispatcher registry.

namespace MatPlotLibNet.Interaction;

// Tier 1 — root. Carries ChartId + AxesIndex, exposes an abstract mutator.
public abstract record FigureInteractionEvent(string ChartId, int AxesIndex)
{
    public abstract void ApplyTo(Figure figure);
    protected Axes TargetAxes(Figure figure) => figure.SubPlots[AxesIndex];
}

// Tier 2 — events that directly overwrite axis limits.
// Factored out because ZoomEvent and ResetEvent share the exact same mutation.
// ApplyTo is sealed so subclasses cannot accidentally diverge.
public abstract record AxisRangeEvent(
    string ChartId, int AxesIndex,
    double XMin, double XMax, double YMin, double YMax)
    : FigureInteractionEvent(ChartId, AxesIndex)
{
    public sealed override void ApplyTo(Figure figure)
    {
        var ax = TargetAxes(figure);
        ax.XAxis.Min = XMin; ax.XAxis.Max = XMax;
        ax.YAxis.Min = YMin; ax.YAxis.Max = YMax;
    }
}

// Tier 3 — concrete events.
public sealed record ZoomEvent(/* … */) : AxisRangeEvent(/* … */);
public sealed record ResetEvent(/* … */) : AxisRangeEvent(/* … */);

// Delta-based — different shape, sibling of AxisRangeEvent.
public sealed record PanEvent(string ChartId, int AxesIndex, double DxData, double DyData)
    : FigureInteractionEvent(ChartId, AxesIndex)
{
    public override void ApplyTo(Figure figure)
    {
        var ax = TargetAxes(figure);
        if (ax.XAxis.Min is double xMin && ax.XAxis.Max is double xMax)
        { ax.XAxis.Min = xMin + DxData; ax.XAxis.Max = xMax + DxData; }
        // … same for Y …
    }
}

public sealed record LegendToggleEvent(string ChartId, int AxesIndex, int SeriesIndex)
    : FigureInteractionEvent(ChartId, AxesIndex)
{
    public override void ApplyTo(Figure figure)
    {
        var ax = TargetAxes(figure);
        if (ax.Series[SeriesIndex] is ChartSeries s)
            s.Visible = !s.Visible;
    }
}

Why self-applying records beat a static mutator or a visitor

  • SOLID-O: a new event type is a new subclass, and no existing code changes.
  • SOLID-L: AxisRangeEvent.ApplyTo is sealed override, so ZoomEvent and ResetEvent cannot diverge from axis-range semantics.
  • DRY: the axis-limit mutation lives in exactly one place.
  • No statics: every event carries instance state, so you can test it against a throwaway Figure without a DI container.

Server side

This code lives in MatPlotLibNet.AspNetCore.

FigureRegistry (public)

public sealed class FigureRegistry
{
    public FigureRegistry(IChartPublisher publisher);
    public void Register(string chartId, Figure figure);
    public ValueTask UnregisterAsync(string chartId);
    public bool Publish(string chartId, FigureInteractionEvent evt);
}
  • It is a concrete class with no interface. An interface is not worth adding until a second implementation needs one.
  • Inside, it holds a ConcurrentDictionary<string, ChartSession>.
  • No TryGet(out Figure): external callers cannot reach the raw figure or race the reader task. Publish is the only path that mutates the figure.
  • services.AddMatPlotLibNetSignalR() registers it as a DI singleton.

ChartSession (internal)

There is one session per registered chart. It owns an unbounded Channel<FigureInteractionEvent> with SingleReader = true, and a background reader task that drains the channel:

while (await reader.WaitToReadAsync(ct))
{
    var dirty = false;
    while (reader.TryRead(out var evt))
    {
        evt.ApplyTo(Figure);
        dirty = true;
    }
    if (dirty)
        await publisher.PublishSvgAsync(ChartId, Figure, ct);
}

The outer WaitToReadAsync wakes once per batch. The inner TryRead drains that batch. PublishSvgAsync fires once per batch, and that is what coalesces the events. DisposeAsync completes the writer, cancels the reader and awaits shutdown.

Hub methods

public sealed class ChartHub : Hub<IChartHubClient>
{
    private readonly FigureRegistry _registry;
    public ChartHub(FigureRegistry registry) => _registry = registry;

    public Task Subscribe(string chartId)   => Groups.AddToGroupAsync(Context.ConnectionId, chartId);
    public Task Unsubscribe(string chartId) => Groups.RemoveFromGroupAsync(Context.ConnectionId, chartId);

    public void OnZoom(ZoomEvent evt)                 => _registry.Publish(evt.ChartId, evt);
    public void OnPan(PanEvent evt)                   => _registry.Publish(evt.ChartId, evt);
    public void OnReset(ResetEvent evt)               => _registry.Publish(evt.ChartId, evt);
    public void OnLegendToggle(LegendToggleEvent evt) => _registry.Publish(evt.ChartId, evt);
}

The four new methods return void instead of async Task. The channel write is synchronous, so the hub call never awaits rendering. Subscribe and Unsubscribe are unchanged from v1.1.x.

Notification events (v1.2.2)

The events in v1.2.0 and v1.2.1 mutate the figure, which triggers a re-render and a broadcast. v1.2.2 adds two events that do not. BrushSelectEvent observes a Shift+drag rubber-band selection. HoverEvent queries the server for tooltip content at a cursor position. Both go to handlers you register on ChartSession, and neither re-renders.

Stacked hierarchy — the tier-2 split

A tier-2 abstract record groups the non-mutating events, in the same way AxisRangeEvent groups the mutation events:

public abstract record FigureNotificationEvent(string ChartId, int AxesIndex)
    : FigureInteractionEvent(ChartId, AxesIndex)
{
    public sealed override void ApplyTo(Figure figure) { /* intentionally empty */ }
}

public sealed record BrushSelectEvent(
    string ChartId, int AxesIndex,
    double X1, double Y1, double X2, double Y2)
    : FigureNotificationEvent(ChartId, AxesIndex);

public sealed record HoverEvent(
    string ChartId, int AxesIndex,
    double X, double Y,
    string? CallerConnectionId = null)
    : FigureNotificationEvent(ChartId, AxesIndex);

The sealed override matters: notification events cannot override ApplyTo to mutate the figure. ChartSession.DrainAsync branches on a single is FigureNotificationEvent test. Mutation events apply their change and publish; notification events only run their handler.

Handler registration

A Register overload takes a ChartSessionOptions builder:

var registry = app.Services.GetRequiredService<FigureRegistry>();
registry.Register("live-1", figure, opts => opts
    .OnBrushSelect(async evt =>
    {
        // Fire-and-forget observation — log, filter, trigger downstream work.
        Console.WriteLine($"User selected [{evt.X1}..{evt.X2}] × [{evt.Y1}..{evt.Y2}]");
    })
    .OnHover(async evt =>
    {
        // Request-response — return HTML fragment, delivered to originating caller only.
        var value = await LookupValueAtAsync(evt.X, evt.Y);
        return $"<b>{value:F3}</b> at ({evt.X:F1}, {evt.Y:F1})";
    })
    .OnDataCursor(async evt =>
    {
        // The reader clicked a point. Fire-and-forget, like brush-select: a click asks about the data.
        await OpenDetailPanelAsync(evt.Annotation.SeriesLabel, evt.Annotation.DataX, evt.Annotation.DataY);
    }));

The click has to be opted into on the figure as well, because a branch nobody asked for is weight in every chart that does not want it:

figure.WithServerInteraction("live-1", i => i.EnableZoom().EnablePan().EnableDataCursor());

All three handlers run on the session's drain task, so the same single-reader guarantee holds and no locking is needed. The brush-select handler is a Func<BrushSelectEvent, ValueTask> and returns no value; so is the data-cursor handler, a Func<DataCursorEvent, ValueTask>. The hover handler is a Func<HoverEvent, ValueTask<string?>>; return null when there is no tooltip.

A click from the browser names the series exactly — it is read from the group the marker is drawn in — and reports where on the axes the reader clicked, resolved the same way a hover is. A native control reports the same event with the exact data point instead, because it runs the nearest-point search in process.

Caller-only response — ICallerPublisher

A hover response targets a single client. Earlier versions broadcast everything via Clients.Group(chartId). ICallerPublisher.SendTooltipAsync(connectionId, chartId, html) uses Clients.Client(connectionId).ReceiveTooltipContent(...) instead, which keeps ChartSession independent of IHubContext. The server stamps HoverEvent.CallerConnectionId itself, from Context.ConnectionId, so clients cannot spoof another user's connection.

public void OnHover(HoverEventPayload payload)
{
    var evt = new HoverEvent(
        payload.ChartId, payload.AxesIndex, payload.X, payload.Y,
        CallerConnectionId: Context.ConnectionId);
    _registry.Publish(evt.ChartId, evt);
}

The test SignalRInteractionTestsV122.OnHover_ReturnsTooltip_ToCallerOnly_NotToOtherSubscribers verifies this: it connects two clients, and only the hovering one gets ReceiveTooltipContent.


Fluent opt-in

var figure = Plt.Create()
    .WithTitle("Bidirectional demo")
    .Plot(xs, ys)
    .WithServerInteraction("live-1", i => i
        .EnableZoom()
        .EnablePan()
        .EnableReset()
        .EnableLegendToggle())
    .Build();

// equivalent, shorter:
var figure = Plt.Create()
    .Plot(xs, ys)
    .WithServerInteraction("live-1", i => i.All())
    .Build();

What WithServerInteraction does:

  1. It sets Figure.ChartId = "live-1".
  2. It sets Figure.ServerInteraction = true.
  3. For each event type you opted in to, it flips the matching EnableZoomPan or EnableLegendToggle flag, so existing flag-checking code keeps working.
  4. SvgTransform.Render branches on ServerInteraction. When it is true, it emits SvgSignalRInteractionScript and skips the local SvgInteractivityScript and SvgLegendToggleScript. The two paths are mutually exclusive, so nothing is handled twice.

ServerInteractionBuilder is a small public class with fluent EnableZoom() / EnablePan() / EnableReset() / EnableLegendToggle() / All() methods that return this.

DI registration

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMatPlotLibNetSignalR();   // v1.2.0: now also registers FigureRegistry

var app = builder.Build();
app.MapChartHub();                             // unchanged

// register a live figure
var registry = app.Services.GetRequiredService<FigureRegistry>();
registry.Register("live-1", figure);

app.Run();

Browser-side dispatcher script

When Figure.ServerInteraction == true, SvgTransform emits SvgSignalRInteractionScript into the <svg> output. The script is a single IIFE with the marker token mplSignalRInteraction. It does three things:

  1. It finds the JS-side HubConnection via window.__mpl_signalr_connection. If there is none, the script does nothing and raises no error.
  2. It reads data-chart-id off the root <svg> element.
  3. It wires these listeners:
    • On wheel, it computes new x and y limits relative to the cursor and calls connection.invoke('OnZoom', { chartId, axesIndex: 0, xMin, xMax, yMin, yMax }).
    • On pointerdown, pointermove and pointerup, it computes a delta in data space and calls connection.invoke('OnPan', { chartId, axesIndex: 0, dxData, dyData }).
    • On keydown with Home, it calls connection.invoke('OnReset', { chartId, axesIndex: 0, xMin, xMax, yMin, yMax }), using the initial limits baked in at render time.
    • On click on any ancestor with data-series-index, it calls connection.invoke('OnLegendToggle', { chartId, axesIndex: 0, seriesIndex }).

The host page creates the HubConnection and exposes it as window.__mpl_signalr_connection:

<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.0/signalr.min.js"></script>
<script>
    const conn = new signalR.HubConnectionBuilder()
        .withUrl('/charts-hub')
        .withAutomaticReconnect()
        .build();
    window.__mpl_signalr_connection = conn;
    conn.on('UpdateChartSvg', (id, svg) => {
        if (id === 'live-1') document.getElementById('host').innerHTML = svg;
    });
    conn.start().then(() => conn.invoke('Subscribe', 'live-1'));
</script>

If you replace the SVG DOM wholesale, re-expose the connection on the global in your UpdateChartSvg callback. The script re-runs on every mount.

Samples

Two runnable samples ship with v1.2.0:

Samples/MatPlotLibNet.Samples.AspNetCore

A bare ASP.NET Core app with a static HTML page. It builds a 200-point sinusoid figure, calls .WithServerInteraction("live-1", i => i.All()), registers the figure and serves /api/chart/live.svg. The page loads @microsoft/signalr from a CDN. The sample is about 150 lines of code in total and has no Blazor or Razor dependency.

dotnet run --project Samples/MatPlotLibNet.Samples.AspNetCore
# → http://localhost:5000

Samples/MatPlotLibNet.Samples.Blazor/Components/Pages/Interactive.razor

The Blazor equivalent, at route /interactive. It uses a raw <div>, a <script> block and JS-side SignalR rather than MplLiveChart, because the bidirectional path needs a shared window.__mpl_signalr_connection. It also shows Dispose implemented via UnregisterAsync.

ChartSubscriptionClient lifecycle (v1.13.0 fix)

ChartSubscriptionClient in MatPlotLibNet.Blazor wraps a SignalR HubConnection and subscribes to UpdateChartSvg and UpdateChart. MplChart and MplLiveChart use it under the hood. v1.13.0 fixed two lifecycle bugs and clarified a third:

  • Double-connect is now safe. A second ConnectAsync call used to orphan the prior HubConnection without any warning, and with WithAutomaticReconnect that orphan kept reconnecting forever. ConnectAsync now tears down any existing connection first, through DisposeCurrentHubAsync.
  • Subscription tokens are tracked instead of discarded. The client captures On(...) handler registrations as IDisposable tokens and disposes them on teardown.
  • Teardown races are reported instead of swallowed. If HubConnection.DisposeAsync() throws during a race, the client now catches it and reports it via ChartDiagnostics.Emit(...). Dispose and DisposeAsync still never throw.
  • Shared-client note — MplLiveChart no longer disposes an injected client. The optional Client parameter (IChartSubscriptionClient?) lets MplLiveChart.DisposeAsync() dispose only the client it created itself. Pass your own instance to share one connection across components.

Testing

  • Tst/MatPlotLibNet/Interaction/FigureInteractionEventTests.cs has 13 tests. They cover each event's ApplyTo, the abstractness guards, the inheritance shape, record equality, and the PanEvent null-limit no-op.
  • Tst/MatPlotLibNet.AspNetCore/FigureRegistryTests.cs has 7 tests that use a fake IChartPublisher. They cover a publish to an unknown chart returning false, a single-event mutation and publish, burst coalescing (50 events produce a bounded number of publishes), mixed event order, UnregisterAsync shutdown, and the legend-toggle flip.
  • Tst/MatPlotLibNet.AspNetCore/SignalRInteractionTests.cs has 4 real-SignalR round-trip tests, one per hub method. They use TestServer and HubConnectionBuilder, with no mocks.
  • Tst/MatPlotLibNet/Rendering/Svg/SvgSignalRInteractionScriptTests.cs has 6 tests. They cover the script emission toggle, the data-chart-id attribute, and the local scripts being suppressed when the dispatcher takes over.
  • Tst/MatPlotLibNet/Builders/FigureBuilderServerInteractionTests.cs has 9 tests. They cover flag routing, chaining, the default off-state, and the return-this semantics of ServerInteractionBuilder.

Test totals as of v1.3.0: 4 028 tests pass across 11 test projects: core 3 647, Fidelity 146, Skia 40, Avalonia 8, Uno 8, MAUI 27, Blazor 22, AspNetCore 36, Interactive 28, GraphQL 12, DataFrame 54.

What shipped after v1.2.0

Item Status Version
Brush-select + hover round-trip Done v1.2.2 — see Notification events above
Clicked data point round-trip Done 1.17.1 — EnableDataCursor() on the figure, OnDataCursor on the session
Native controls (Avalonia 12 + Uno) Done v1.3.0 — see Interactive Controls
3-D round 2 (6 new series) Done v1.3.0 — see Chart Types
MathText completion Done v1.3.0 — see Advanced
Server-mode adapter for native controls Done v1.3.0 — .WithServerInteraction(hubConnection)
Legend toggle in native controls Done v1.3.0 — LegendToggleModifier hit-testing
Rubber-band selection visual Done v1.3.0 — BrushSelectState overlay
Hover tooltip in native controls Done v1.3.0 — NearestPointFinder local lookup

Still deferred

  • Pluggable IFigureInteractionHandler: a per-chart handler interface beyond the six built-in IInteractionModifier implementations. v1.2.2 added per-chart callbacks via ChartSessionOptions instead.
  • Multi-viewer sync as a designed feature: today it is a side-effect of SignalR group fan-out, with last-writer-wins. A designed feature would need per-viewer interaction cursors and conflict resolution.

See Roadmap for the full list.

Clone this wiki locally