-
Notifications
You must be signed in to change notification settings - Fork 2
Bidirectional SignalR
Introduced in v1.2.0 (mutation events — zoom, pan, reset, legend-toggle). Refined in v1.2.1 (legend clipping fix via
ThemedFontProviderconsolidation). 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-sideFigure(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.
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.
┌─ 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.
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;
}
}- SOLID-O: adding a new event type means adding a new subclass. No existing code changes.
-
SOLID-L:
AxisRangeEvent.ApplyToissealed override—ZoomEventandResetEventcannot 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
Figurewith 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— notIsVisible).
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);
}- 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.Publishis the only mutation path. - Registered as a DI singleton by
services.AddMatPlotLibNetSignalR().
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.
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.
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.
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.
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".
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.
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:
- Sets
Figure.ChartId = "live-1". - Sets
Figure.ServerInteraction = true. - For each opted-in event type, flips the matching existing
EnableZoomPan/EnableLegendToggleflag — soFigure.HasInteractivityalready sees the chart as interactive and existing renderers that check those flags keep working. -
SvgTransform.Renderbranches onServerInteraction: whentrueit emits the newSvgSignalRInteractionScriptand skips the localSvgInteractivityScriptandSvgLegendToggleScript— 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.
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.
When Figure.ServerInteraction == true, SvgTransform emits SvgSignalRInteractionScript into the <svg> output. The script is a single IIFE with marker token mplSignalRInteraction:
- Discovers the JS-side
HubConnectionviawindow.__mpl_signalr_connection. If not found, it's a graceful no-op — the SVG still renders, no JS errors in the console. - Reads
data-chart-idoff the root<svg>element (SvgTransformemits it whenServerInteractionis on, otherwise the default path is byte-identical to v1.1.4). - 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 }) -
keydownHome→connection.invoke('OnReset', { chartId, axesIndex: 0, xMin, xMax, yMin, yMax })with initial limits baked in at render time -
clickon any ancestor withdata-series-index→connection.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.
Two runnable samples ship with v1.2.0:
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.
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.
-
Tst/MatPlotLibNet/Interaction/FigureInteractionEventTests.cs— 13 tests covering the event hierarchy: each concrete event'sApplyTo, abstractness guards, inheritance shape, record value equality, null-limit no-op forPanEvent. -
Tst/MatPlotLibNet.AspNetCore/FigureRegistryTests.cs— 7 tests with a recording fakeIChartPublisher: 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 viaUnregisterAsync,LegendToggleEventvisibility flip. -
Tst/MatPlotLibNet.AspNetCore/SignalRInteractionTests.cs— 4 real-SignalR end-to-end round-trip tests usingMicrosoft.AspNetCore.TestHost.TestServer+HubConnectionBuilder— no mocks. One test per hub method: connect, subscribe, invoke, wait forUpdateChartSvgcallback, 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-idattribute on the root, and that the localSvgInteractivityScript/SvgLegendToggleScriptare suppressed when the dispatcher takes over. -
Tst/MatPlotLibNet/Builders/FigureBuilderServerInteractionTests.cs— 9 tests for the fluent builder's flag routing, chaining, default off-state, andServerInteractionBuilderreturn-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.
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 |
-
Pluggable
IFigureInteractionHandler— per-chart handler interface for advanced customisation beyond the built-in sixIInteractionModifierimplementations. v1.2.2 introduced per-chart callbacks viaChartSessionOptions, 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.