Skip to content

Bidirectional SignalR

xkqg edited this page May 16, 2026 · 6 revisions

Bidirectional SignalR Interactive Charts

Introduced in v1.2.0 (mutation events — zoom, pan, reset, legend-toggle). Refined in v1.2.1 (legend clipping fix via ThemedFontProvider consolidation). Extended in v1.2.2 (notification events — brush-select, hover — with the first per-caller response mechanism in the library). Turns MatPlotLibNet's one-way SignalR push pipeline into a full bidirectional system: browser interactions mutate the authoritative server-side Figure (mutation events), observe via user handlers (brush-select), or query the server for caller-specific responses (hover). Pure .NET, no JavaScript charting library, server stays the source of truth.

Why

v1.1.x shipped ChartHub + IChartPublisher so the server could push SVG updates to subscribed browsers. That half of the bridge is unchanged in v1.2.0. What was missing was the return channel: when a user wheel-zoomed, drag-panned, or clicked a legend entry in the browser, those events were handled client-side by an IIFE script manipulating the SVG viewBox or element opacity — the server never knew. Anything that depended on the server knowing the zoom level (LTTB downsampling at the new extent, rich server-computed tooltips, persistent legend toggles that survive a reload, cross-client synchronisation) simply could not be implemented.

v1.2.0 closes the loop. Every relevant browser 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. Multi-viewer sync becomes a free side-effect of the SignalR group membership ChartHub.Subscribe already installs.

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                │  │
│                              │           │  └──────────────────────────────────┘  │
└──────────────────────────────┘           └───────────────────────────────────────┘

One channel per chart, one reader task per chart, structurally serial mutation. No SemaphoreSlim, no lock, no ConcurrentBag (wrong ordering). Hub method latency is bounded by the channel write (microseconds) — rendering happens asynchronously off the hub call stack. A burst of 50 wheel events that arrive in one reader tick is coalesced into exactly one re-render.

Event hierarchy

Self-applying records with three tiers of stacked inheritance. Adding a new interaction type means adding a new subclass — no switch, no visitor, no dispatcher registry. SOLID-OCP clean.

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: adding a new event type means adding a new subclass. No existing code changes.
  • SOLID-L: AxisRangeEvent.ApplyTo is sealed overrideZoomEvent and ResetEvent cannot accidentally diverge from axis-range semantics.
  • DRY: the axis-limit mutation lives in exactly one place.
  • No statics: everything is instance state. Testable against a throwaway Figure with no DI container.
  • No assumptions: every property reference (SubPlots, Axes, Series, Visible, Min, Max) matches the verified shape of the core model (Figure.SubPlots — capital P, capital S; ChartSeries.Visible — not IsVisible).

Server side

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);
}
  • Concrete class. No interface. YAGNI until a second implementation is needed.
  • ConcurrentDictionary<string, ChartSession> inside.
  • No TryGet(out Figure) — external callers cannot reach the raw figure, and therefore cannot race the reader task. Publish is the only mutation path.
  • Registered as a DI singleton by services.AddMatPlotLibNetSignalR().

ChartSession (internal)

One per registered chart. Owns an unbounded Channel<FigureInteractionEvent> with SingleReader = true and a single background reader task that drains it:

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 two nested loops are the natural coalescing: the outer WaitToReadAsync wakes once per batch of writes, the inner TryRead drains the whole batch, and PublishSvgAsync fires once per drained batch. Wheel-zoom spamming 50 events in one frame produces exactly one re-render.

DisposeAsync completes the writer, cancels the reader, and awaits graceful 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 are void, not async Task — the channel write is synchronous and the hub method never awaits rendering. Subscribe/Unsubscribe are unchanged from v1.1.x. The existing group fan-out from Subscribe is what lets PublishSvgAsync reach every viewer of a chart id, so multi-viewer sync is a free side-effect rather than a designed feature.

Notification events (v1.2.2)

v1.2.0 / v1.2.1 events all mutate the figure — they rewrite axis limits or series visibility and trigger a re-render + broadcast. v1.2.2 introduces the first two events that don't: BrushSelectEvent observes a Shift+drag rubber-band selection, HoverEvent queries the server for rich tooltip content at a cursor position. Both are routed to user-registered handlers on the ChartSession; the figure is never re-rendered by a notification event.

Stacked hierarchy — the tier-2 split

The v1.2.0 events all stacked directly under FigureInteractionEvent with abstract ApplyTo. v1.2.2 adds a new tier-2 abstract record for non-mutating events, mirroring how AxisRangeEvent groups 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 is load-bearing: concrete notification events cannot override ApplyTo to mutate the figure, because the tier-2 record has already sealed it. That's a structural guarantee, not a code-review convention.

ChartSession.DrainAsync branches on event type via a single is FigureNotificationEvent test — mutation events apply and trigger a publish, notification events run their handler and skip the publish.

Handler registration

v1.2.2 adds a Register overload that 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})";
    }));

Both handlers run on the session's drain task — same single-reader guarantee as mutation events, no locking needed. Brush-select is Func<BrushSelectEvent, ValueTask> (no return value); hover is Func<HoverEvent, ValueTask<string?>> — return null for "no tooltip".

Caller-only response — ICallerPublisher

Hover is the first hub method in the library that targets a single client. v1.2.0 / v1.2.1 broadcast everything via Clients.Group(chartId); v1.2.2 introduces ICallerPublisher.SendTooltipAsync(connectionId, chartId, html) which uses Clients.Client(connectionId).ReceiveTooltipContent(...). The abstraction keeps ChartSession independent of IHubContext and unit-testable with a recording double.

HoverEvent carries a CallerConnectionId field that's not client-supplied — the hub stamps Context.ConnectionId into it server-side after receiving the client's HoverEventPayload DTO. 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);
}

Verified end-to-end by SignalRInteractionTestsV122.OnHover_ReturnsTooltip_ToCallerOnly_NotToOtherSubscribers — a test with TWO connected clients asserting client A receives the ReceiveTooltipContent callback and client B does not.


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. Sets Figure.ChartId = "live-1".
  2. Sets Figure.ServerInteraction = true.
  3. For each opted-in event type, flips the matching existing EnableZoomPan / EnableLegendToggle flag — so Figure.HasInteractivity already sees the chart as interactive and existing renderers that check those flags keep working.
  4. SvgTransform.Render branches on ServerInteraction: when true it emits the new SvgSignalRInteractionScript and skips the local SvgInteractivityScript and SvgLegendToggleScript — the two scripts are mutually exclusive, never both, so there is no double-handling of wheel / click events.

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();

AddMatPlotLibNetSignalR adds FigureRegistry as a singleton alongside the existing IChartPublisher, ISvgRenderer, and IChartSerializer singletons. No other changes to the DI surface.

Browser-side dispatcher script

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

  1. Discovers the JS-side HubConnection via window.__mpl_signalr_connection. If not found, it's a graceful no-op — the SVG still renders, no JS errors in the console.
  2. Reads data-chart-id off the root <svg> element (SvgTransform emits it when ServerInteraction is on, otherwise the default path is byte-identical to v1.1.4).
  3. Wires listeners:
    • wheel → compute new x/y limits relative to cursor → connection.invoke('OnZoom', { chartId, axesIndex: 0, xMin, xMax, yMin, yMax })
    • pointerdown / pointermove / pointerup → compute data-space delta → connection.invoke('OnPan', { chartId, axesIndex: 0, dxData, dyData })
    • keydown Homeconnection.invoke('OnReset', { chartId, axesIndex: 0, xMin, xMax, yMin, yMax }) with initial limits baked in at render time
    • click on any ancestor with data-series-indexconnection.invoke('OnLegendToggle', { chartId, axesIndex: 0, seriesIndex })

The host page is responsible for creating the HubConnection and exposing it as window.__mpl_signalr_connection. Typically:

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

Re-expose the connection on the global in your UpdateChartSvg callback if you replace the SVG DOM wholesale — the script re-runs on every SVG mount and will look up the global again.

Samples

Two runnable samples ship with v1.2.0:

Samples/MatPlotLibNet.Samples.AspNetCore

Bare minimum ASP.NET Core + static HTML page. Builds a 200-point sinusoid figure, .WithServerInteraction("live-1", i => i.All()), registers it, serves /api/chart/live.svg, hosts a small HTML page that loads @microsoft/signalr from CDN and connects. ~150 LOC of user code across Program.cs + wwwroot/index.html. The fastest way to see the full round-trip — no Blazor dependency, no Razor, no framework layer.

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

Scroll-wheel the chart, drag to pan, click it and press Home to reset. The server log prints every event as it arrives; the browser receives the updated SVG within one RTT.

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

Blazor equivalent at route /interactive. Uses the same pattern (raw <div> + <script> block + JS-side SignalR connection) rather than the MplLiveChart component, because the bidirectional path benefits from a shared window.__mpl_signalr_connection that any embedded dispatcher script on the page can find. Demonstrates Dispose via UnregisterAsync for session cleanup.

Testing

  • Tst/MatPlotLibNet/Interaction/FigureInteractionEventTests.cs — 13 tests covering the event hierarchy: each concrete event's ApplyTo, abstractness guards, inheritance shape, record value equality, null-limit no-op for PanEvent.
  • Tst/MatPlotLibNet.AspNetCore/FigureRegistryTests.cs — 7 tests with a recording fake IChartPublisher: unknown-chart publish returns false, single-event mutation + publish, burst coalescing (50 events → bounded publishes, last-write-wins), mixed event types in order, clean shutdown via UnregisterAsync, LegendToggleEvent visibility flip.
  • Tst/MatPlotLibNet.AspNetCore/SignalRInteractionTests.cs — 4 real-SignalR end-to-end round-trip tests using Microsoft.AspNetCore.TestHost.TestServer + HubConnectionBuilder — no mocks. One test per hub method: connect, subscribe, invoke, wait for UpdateChartSvg callback, assert the figure is mutated and a new SVG is received.
  • Tst/MatPlotLibNet/Rendering/Svg/SvgSignalRInteractionScriptTests.cs — 6 tests verifying the script emission toggle, data-chart-id attribute on the root, and that the local SvgInteractivityScript / SvgLegendToggleScript are suppressed when the dispatcher takes over.
  • Tst/MatPlotLibNet/Builders/FigureBuilderServerInteractionTests.cs — 9 tests for the fluent builder's flag routing, chaining, default off-state, and ServerInteractionBuilder return-this semantics.

Test totals as of v1.3.0: 4 028 green 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

Items originally deferred from v1.2.0 and their landing versions:

Item Status Version
Brush-select + hover round-trip Done v1.2.2 — see Notification events above
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 — per-chart handler interface for advanced customisation beyond the built-in six IInteractionModifier implementations. v1.2.2 introduced per-chart callbacks via ChartSessionOptions, but not a pluggable interface.
  • Multi-viewer sync as a designed feature — works today as a side-effect of SignalR group fan-out with last-writer-wins semantics. A real multi-viewer story needs per-viewer interaction cursors and conflict resolution.

See Roadmap for the full list.

Clone this wiki locally