@@ -82,7 +82,51 @@ function render({ model, el }) {
8282 return arr [ lo ] + t * ( arr [ lo + 1 ] - arr [ lo ] ) ;
8383 }
8484
85- // ── outer DOM ────────────────────────────────────────────────────────────
85+ // ── per-panel frame timing ────────────────────────────────────────────────
86+ // Called at the entry of every draw function (draw2d / draw1d / draw3d /
87+ // drawBar). Records a high-resolution timestamp in a 60-entry rolling
88+ // buffer on the panel object, then:
89+ // • updates window._aplTiming[p.id] — always, for Playwright readback
90+ // • updates p.statsDiv text — only when display_stats is true
91+ //
92+ // Placing the call at the *start* of each draw function means we measure
93+ // the inter-trigger interval: how often the CPU initiates a render, which
94+ // is the right metric for both interactive (pan/zoom) and data-push paths.
95+ const _FRAME_BUF = 60 ;
96+
97+ function _recordFrame ( p ) {
98+ const now = performance . now ( ) ;
99+ p . frameTimes . push ( now ) ;
100+ if ( p . frameTimes . length > _FRAME_BUF ) p . frameTimes . shift ( ) ;
101+
102+ const n = p . frameTimes . length ;
103+
104+ // Always keep the global timing dict fresh so Playwright can read it back
105+ // at any point via window._aplTiming[panelId].
106+ if ( ! window . _aplTiming ) window . _aplTiming = { } ;
107+
108+ if ( n >= 2 ) {
109+ let sum = 0 , minDt = Infinity , maxDt = - Infinity ;
110+ for ( let i = 1 ; i < n ; i ++ ) {
111+ const dt = p . frameTimes [ i ] - p . frameTimes [ i - 1 ] ;
112+ sum += dt ; if ( dt < minDt ) minDt = dt ; if ( dt > maxDt ) maxDt = dt ;
113+ }
114+ const mean_ms = sum / ( n - 1 ) ;
115+ const fps = 1000 * ( n - 1 ) / ( now - p . frameTimes [ 0 ] ) ;
116+ window . _aplTiming [ p . id ] = {
117+ count : n , fps, mean_ms, min_ms : minDt , max_ms : maxDt ,
118+ } ;
119+
120+ if ( p . statsDiv && model . get ( 'display_stats' ) ) {
121+ p . statsDiv . style . display = 'block' ;
122+ p . statsDiv . textContent =
123+ `FPS ${ fps . toFixed ( 1 ) } \n` +
124+ ` dt ${ mean_ms . toFixed ( 1 ) } ms\n` +
125+ `min ${ minDt . toFixed ( 1 ) } ms\n` +
126+ `max ${ maxDt . toFixed ( 1 ) } ms` ;
127+ }
128+ }
129+ }
86130 // Static layout styles live in the _css traitlet (.apl-scale-wrap /
87131 // .apl-outer). Only the two dynamic properties — transform and
88132 // marginBottom — are ever written here at runtime.
@@ -207,6 +251,7 @@ function render({ model, el }) {
207251 let plotCanvas , overlayCanvas , markersCanvas , statusBar ;
208252 let xAxisCanvas = null , yAxisCanvas = null , scaleBar = null ;
209253 let _p2d = null ; // extra 2D DOM refs, null for 1D panels
254+ let _wrapNode = null ; // container to which statsDiv is appended
210255
211256 if ( kind === '2d' ) {
212257 // ── 2D branch ──────────────────────────────────────────────────────────
@@ -270,6 +315,7 @@ function render({ model, el }) {
270315
271316 const cbCtx = cbCanvas . getContext ( '2d' ) ;
272317 _p2d = { cbCanvas, cbCtx, plotWrap } ;
318+ _wrapNode = plotWrap ;
273319
274320 } else if ( kind === '3d' ) {
275321 // ── 3D branch: one full-panel plotCanvas + overlayCanvas on top ───────
@@ -293,8 +339,7 @@ function render({ model, el }) {
293339 statusBar . style . cssText =
294340 'position:absolute;bottom:4px;right:4px;padding:2px 6px;display:none;' ;
295341 wrap3 . appendChild ( statusBar ) ;
296-
297- } else {
342+ _wrapNode = wrap3 ;
298343 plotCanvas = document . createElement ( 'canvas' ) ;
299344 plotCanvas . tabIndex = 1 ;
300345 plotCanvas . style . cssText = 'outline:none;cursor:crosshair;display:block;border-radius:2px;' ;
@@ -319,6 +364,7 @@ function render({ model, el }) {
319364 'background:rgba(0,0,0,0.55);color:white;font-size:10px;font-family:monospace;' +
320365 'border-radius:4px;pointer-events:none;white-space:nowrap;display:none;z-index:9;' ;
321366 wrap . appendChild ( statusBar ) ;
367+ _wrapNode = wrap ;
322368 }
323369
324370 const plotCtx = plotCanvas . getContext ( '2d' ) ;
@@ -329,12 +375,25 @@ function render({ model, el }) {
329375
330376 const blitCache = { bitmap :null , bytesKey :null , lutKey :null , w :0 , h :0 } ;
331377
378+ // ── stats overlay (top-left of panel) ────────────────────────────────
379+ // Positioned absolutely inside the panel's wrap container so it floats
380+ // over the plot area. Visibility is toggled by the display_stats traitlet.
381+ const statsDiv = document . createElement ( 'div' ) ;
382+ statsDiv . style . cssText =
383+ 'position:absolute;top:4px;left:4px;padding:4px 7px;' +
384+ 'background:rgba(0,0,0,0.65);color:#e0e0e0;font-size:10px;' +
385+ 'font-family:monospace;border-radius:4px;pointer-events:none;' +
386+ 'white-space:pre;line-height:1.5;z-index:20;display:none;' ;
387+ if ( _wrapNode ) _wrapNode . appendChild ( statsDiv ) ;
388+
332389 const p = {
333390 id, kind, cell, pw, ph,
334391 plotCanvas, overlayCanvas, markersCanvas,
335392 plotCtx, ovCtx, mkCtx,
336393 xAxisCanvas, yAxisCanvas, xCtx, yCtx,
337394 scaleBar, statusBar,
395+ statsDiv, // ← per-panel FPS overlay element
396+ frameTimes : [ ] , // ← rolling 60-entry timestamp buffer (performance.now())
338397 blitCache,
339398 ovDrag : null ,
340399 isPanning : false , panStart : { } ,
@@ -578,6 +637,7 @@ function render({ model, el }) {
578637 function draw2d ( p ) {
579638 const st = p . state ;
580639 if ( ! st ) return ;
640+ _recordFrame ( p ) ;
581641 // Re-sync axis/histogram canvas visibility whenever state changes
582642 _resizePanelDOM ( p . id , p . pw , p . ph ) ;
583643 const { pw, ph, plotCtx :ctx , blitCache} = p ;
@@ -1030,6 +1090,7 @@ function render({ model, el }) {
10301090
10311091 function draw3d ( p ) {
10321092 const st = p . state ; if ( ! st ) return ;
1093+ _recordFrame ( p ) ;
10331094 const { pw, ph, plotCtx : ctx } = p ;
10341095
10351096 ctx . clearRect ( 0 , 0 , pw , ph ) ;
@@ -1317,6 +1378,7 @@ function render({ model, el }) {
13171378
13181379 function draw1d ( p ) {
13191380 const st = p . state ; if ( ! st ) return ;
1381+ _recordFrame ( p ) ;
13201382 const { pw, ph, plotCtx :ctx } = p ;
13211383 const r = _plotRect1d ( pw , ph ) ;
13221384 const xArr = st . x_axis || [ ] , x0 = st . view_x0 || 0 , x1 = st . view_x1 || 1 ;
@@ -2696,6 +2758,7 @@ function render({ model, el }) {
26962758
26972759 function drawBar ( p ) {
26982760 const st = p . state ; if ( ! st ) return ;
2761+ _recordFrame ( p ) ;
26992762 const { pw, ph, plotCtx : ctx } = p ;
27002763 const r = _plotRect1d ( pw , ph ) ;
27012764
@@ -3100,6 +3163,20 @@ function render({ model, el }) {
31003163 model . on ( 'change:layout_json' , ( ) => { applyLayout ( ) ; redrawAll ( ) ; requestAnimationFrame ( _applyScale ) ; } ) ;
31013164 model . on ( 'change:fig_width change:fig_height' , ( ) => { applyLayout ( ) ; redrawAll ( ) ; requestAnimationFrame ( _applyScale ) ; } ) ;
31023165
3166+ // Toggle the per-panel stats overlay when display_stats changes.
3167+ // Hiding is immediate; showing waits for the next natural redraw to
3168+ // populate the overlay text — but we also call redrawAll() here so the
3169+ // stats appear instantly without having to interact with the figure first.
3170+ model . on ( 'change:display_stats' , ( ) => {
3171+ const show = model . get ( 'display_stats' ) ;
3172+ for ( const p of panels . values ( ) ) {
3173+ if ( ! show && p . statsDiv ) {
3174+ p . statsDiv . style . display = 'none' ;
3175+ }
3176+ }
3177+ if ( show ) redrawAll ( ) ;
3178+ } ) ;
3179+
31033180 // Python→JS targeted widget update (source:"python" in event_json).
31043181 // Applies changed fields directly to the widget in overlay_widgets and
31053182 // redraws the panel — no image re-decode, no Python echo.
0 commit comments