Skip to content

Commit e027252

Browse files
committed
WAN live chart follows map timeline scrubber (#720)
* WAN live chart follows map timeline scrubber When scrubbing or playing back historic data on the 2D/3D maps, the WAN live chart now shows the same time window. Stops live polling during historic mode and resumes when returning to live. No-op for the Dashboard version since it has no scrubber. * Remove stale review diff * Pause/resume WAN chart when map live playback is toggled Map now notifies Blazor on play/pause via OnMapPlayStateChanged. WAN chart pauses polling when live playback is paused and resumes when unpaused. * Clamp WAN chart time axis to present - no future blank space * Add playhead annotation line on WAN chart during historic scrubbing * Stabilize RTT Y-axis scaling - anchor at 0, snap max to 5ms increments * Guard RTT Y-axis max against NaN/undefined during scrubbing * Fix RTT Y-axis jitter - compute max from buffer data, not dynamic callback * Wider RTT Y-axis steps (10ms) to reduce oscillation * Don't overwrite saved 3D map camera position during fly-in animation * Use p95 RTT for Y-axis max to ignore smoothed-out spikes
1 parent 2f327c3 commit e027252

3 files changed

Lines changed: 108 additions & 2 deletions

File tree

src/NetworkOptimizer.Web/Components/Pages/Monitoring.razor

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,9 +1261,26 @@ else
12611261
}
12621262
}
12631263

1264+
[JSInvokable]
1265+
public void OnMapPlayStateChanged(bool paused, string mode)
1266+
{
1267+
if (!_wanLiveChartMounted) return;
1268+
if (paused)
1269+
_ = JS.InvokeVoidAsync("eval", "window.__wanLiveChart?.pause();");
1270+
else if (mode == "live")
1271+
_ = JS.InvokeVoidAsync("eval", "window.__wanLiveChart?.resume();");
1272+
}
1273+
12641274
[JSInvokable]
12651275
public async Task OnMapTimeChanged(string? isoTimestamp)
12661276
{
1277+
// Sync WAN live chart to the same timeline position
1278+
if (_wanLiveChartMounted)
1279+
{
1280+
try { await JS.InvokeVoidAsync("eval", $"window.__wanLiveChart?.seekTime({(isoTimestamp != null ? $"'{isoTimestamp}'" : "null")});"); }
1281+
catch { }
1282+
}
1283+
12671284
if (isoTimestamp == null)
12681285
{
12691286
_mapHistoricAt = null;

src/NetworkOptimizer.Web/wwwroot/js/lan-flow-map.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,10 +248,13 @@ export class LanFlowMap {
248248
} catch {}
249249

250250
// Persist camera on orbit change with 500ms debounce.
251+
// Skip saves during the fly-in animation so we don't overwrite
252+
// the saved position with intermediate fly-in frames.
251253
let camSaveTimer = null;
252254
this.controls.addEventListener('change', () => {
253255
clearTimeout(camSaveTimer);
254256
camSaveTimer = setTimeout(() => {
257+
if (this._flyInUntil && performance.now() < this._flyInUntil) return;
255258
try {
256259
const p = this.camera.position;
257260
const t = this.controls.target;
@@ -1540,6 +1543,7 @@ export class LanFlowMap {
15401543
this._paused = !this._paused;
15411544
this._syncPlayPauseIcon();
15421545
flowData.publishPlayState(this._paused, this._mode);
1546+
this._notifyPlayState();
15431547
if (this._paused) {
15441548
this._stopHistoricPlayback();
15451549
return;
@@ -2113,6 +2117,12 @@ export class LanFlowMap {
21132117
await this._loadHistoric(at);
21142118
}
21152119

2120+
_notifyPlayState() {
2121+
if (!this._dotnetRef) return;
2122+
this._dotnetRef.invokeMethodAsync('OnMapPlayStateChanged', this._paused, this._mode)
2123+
.catch(() => {});
2124+
}
2125+
21162126
_notifyStatCards(at) {
21172127
if (!this._dotnetRef) {
21182128
console.warn('[LanFlowMap] _notifyStatCards: dotnetRef is null');

src/NetworkOptimizer.Web/wwwroot/js/wan-live-chart.js

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ function buildOpts() {
9595
{
9696
seriesName: 'RTT',
9797
opposite: true,
98-
min: v => Math.max(0, v * 0.5),
99-
max: v => v * 1.5,
98+
min: 0,
99+
max: 10,
100100
labels: {
101101
style: { colors: '#9ca3af', fontSize: '10px' },
102102
formatter: v => v != null ? v.toFixed(0) : '',
@@ -129,11 +129,19 @@ function buildOpts() {
129129
};
130130
}
131131

132+
function rttYMax() {
133+
const rtts = buffer.map(p => p.rtt).filter(v => v != null && v > 0).sort((a, b) => a - b);
134+
if (rtts.length === 0) return 10;
135+
const p95 = rtts[Math.floor(rtts.length * 0.95)];
136+
return Math.ceil((p95 * 1.5) / 10) * 10;
137+
}
138+
132139
function updateChart() {
133140
if (!chart || buffer.length === 0) return;
134141
const now = Date.now();
135142
chart.updateOptions({
136143
xaxis: { min: now - HISTORY_MINUTES * 60000, max: now },
144+
yaxis: [chart.opts.yaxis[0], chart.opts.yaxis[1], chart.opts.yaxis[2], { ...chart.opts.yaxis[3], max: rttYMax() }],
137145
}, false, false, false);
138146
chart.updateSeries([
139147
{ name: 'Download', data: buffer.map(p => ({ x: p.time, y: p.download })) },
@@ -203,6 +211,77 @@ export async function mount(containerId, opts) {
203211
pollTimer = setInterval(pollLive, interval);
204212
}
205213

214+
export function pause() {
215+
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
216+
}
217+
218+
export function resume() {
219+
if (!chart || pollTimer) return;
220+
pollTimer = setInterval(pollLive, POLL_MS);
221+
}
222+
223+
export async function seekTime(isoTimestamp) {
224+
if (!chart) return;
225+
if (!isoTimestamp) {
226+
// Return to live mode - clear playhead annotation
227+
chart.clearAnnotations();
228+
if (pollTimer) return; // already live
229+
buffer = [];
230+
await loadHistory();
231+
updateChart();
232+
pollTimer = setInterval(pollLive, POLL_MS);
233+
return;
234+
}
235+
// Historic mode: stop polling, fetch window centered on timestamp
236+
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
237+
const at = new Date(isoTimestamp).getTime();
238+
const halfWindow = HISTORY_MINUTES * 60000 / 2;
239+
const from = new Date(at - halfWindow);
240+
const to = new Date(at + halfWindow);
241+
try {
242+
const resp = await fetch(
243+
`/api/monitoring/wan-live-chart-data?from=${from.toISOString()}&to=${to.toISOString()}`,
244+
{ credentials: 'same-origin' });
245+
if (!resp.ok) return;
246+
const data = await resp.json();
247+
buffer = (data.points || []).map(p => ({
248+
time: new Date(p.time).getTime(),
249+
download: p.downloadBps,
250+
upload: p.uploadBps,
251+
rtt: p.rttMs,
252+
loss: p.lossPercent,
253+
}));
254+
} catch { return; }
255+
if (buffer.length === 0) return;
256+
const maxTime = Math.min(at + halfWindow, Date.now());
257+
chart.updateOptions({
258+
xaxis: { min: maxTime - HISTORY_MINUTES * 60000, max: maxTime },
259+
yaxis: [chart.opts.yaxis[0], chart.opts.yaxis[1], chart.opts.yaxis[2], { ...chart.opts.yaxis[3], max: rttYMax() }],
260+
}, false, false, false);
261+
chart.updateSeries([
262+
{ name: 'Download', data: buffer.map(p => ({ x: p.time, y: p.download })) },
263+
{ name: 'Upload', data: buffer.map(p => ({ x: p.time, y: p.upload })) },
264+
{ name: 'Loss', data: buffer.map(p => ({ x: p.time, y: p.loss })) },
265+
{ name: 'RTT', data: buffer.map(p => ({ x: p.time, y: p.rtt })) },
266+
], false);
267+
268+
chart.clearAnnotations();
269+
chart.addXaxisAnnotation({
270+
x: at,
271+
borderColor: '#f1f5f9',
272+
strokeDashArray: 3,
273+
opacity: 0.5,
274+
label: {
275+
text: new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
276+
borderColor: 'transparent',
277+
style: { background: 'transparent', color: '#f1f5f9', fontSize: '9px' },
278+
position: 'front',
279+
orientation: 'horizontal',
280+
offsetY: -5,
281+
}
282+
});
283+
}
284+
206285
export function unmount() {
207286
mountGen++;
208287
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }

0 commit comments

Comments
 (0)