Repository navigation
Bidirectional SignalR
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.
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.
┌─ 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.
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;
}
}- SOLID-O: a new event type is a new subclass, and no existing code changes.
-
SOLID-L:
AxisRangeEvent.ApplyToissealed override, soZoomEventandResetEventcannot 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
Figurewithout a DI container.
This code lives in MatPlotLibNet.AspNetCore.
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.Publishis the only path that mutates the figure. -
services.AddMatPlotLibNetSignalR()registers it as a DI singleton.
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.
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.
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.
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.
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.
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.
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:
- It sets
Figure.ChartId = "live-1". - It sets
Figure.ServerInteraction = true. - For each event type you opted in to, it flips the matching
EnableZoomPanorEnableLegendToggleflag, so existing flag-checking code keeps working. -
SvgTransform.Renderbranches onServerInteraction. When it istrue, it emitsSvgSignalRInteractionScriptand skips the localSvgInteractivityScriptandSvgLegendToggleScript. 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.
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();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:
- It finds the JS-side
HubConnectionviawindow.__mpl_signalr_connection. If there is none, the script does nothing and raises no error. - It reads
data-chart-idoff the root<svg>element. - It wires these listeners:
- On
wheel, it computes new x and y limits relative to the cursor and callsconnection.invoke('OnZoom', { chartId, axesIndex: 0, xMin, xMax, yMin, yMax }). - On
pointerdown,pointermoveandpointerup, it computes a delta in data space and callsconnection.invoke('OnPan', { chartId, axesIndex: 0, dxData, dyData }). - On
keydownwithHome, it callsconnection.invoke('OnReset', { chartId, axesIndex: 0, xMin, xMax, yMin, yMax }), using the initial limits baked in at render time. - On
clickon any ancestor withdata-series-index, it callsconnection.invoke('OnLegendToggle', { chartId, axesIndex: 0, seriesIndex }).
- On
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.
Two runnable samples ship with v1.2.0:
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
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 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
ConnectAsynccall used to orphan the priorHubConnectionwithout any warning, and withWithAutomaticReconnectthat orphan kept reconnecting forever.ConnectAsyncnow tears down any existing connection first, throughDisposeCurrentHubAsync. -
Subscription tokens are tracked instead of discarded. The client captures
On(...)handler registrations asIDisposabletokens 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 viaChartDiagnostics.Emit(...).DisposeandDisposeAsyncstill never throw. -
Shared-client note —
MplLiveChartno longer disposes an injected client. The optionalClientparameter (IChartSubscriptionClient?) letsMplLiveChart.DisposeAsync()dispose only the client it created itself. Pass your own instance to share one connection across components.
-
Tst/MatPlotLibNet/Interaction/FigureInteractionEventTests.cshas 13 tests. They cover each event'sApplyTo, the abstractness guards, the inheritance shape, record equality, and thePanEventnull-limit no-op. -
Tst/MatPlotLibNet.AspNetCore/FigureRegistryTests.cshas 7 tests that use a fakeIChartPublisher. 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,UnregisterAsyncshutdown, and the legend-toggle flip. -
Tst/MatPlotLibNet.AspNetCore/SignalRInteractionTests.cshas 4 real-SignalR round-trip tests, one per hub method. They useTestServerandHubConnectionBuilder, with no mocks. -
Tst/MatPlotLibNet/Rendering/Svg/SvgSignalRInteractionScriptTests.cshas 6 tests. They cover the script emission toggle, thedata-chart-idattribute, and the local scripts being suppressed when the dispatcher takes over. -
Tst/MatPlotLibNet/Builders/FigureBuilderServerInteractionTests.cshas 9 tests. They cover flag routing, chaining, the default off-state, and the return-this semantics ofServerInteractionBuilder.
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.
| 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 |
-
Pluggable
IFigureInteractionHandler: a per-chart handler interface beyond the six built-inIInteractionModifierimplementations. v1.2.2 added per-chart callbacks viaChartSessionOptionsinstead. - 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.
MatPlotLibNet 1.18.2