@@ -83,9 +83,16 @@ function render({ model, el }) {
8383 }
8484
8585 // ── outer DOM ────────────────────────────────────────────────────────────
86+ // Static layout styles live in the _css traitlet (.apl-scale-wrap /
87+ // .apl-outer). Only the two dynamic properties — transform and
88+ // marginBottom — are ever written here at runtime.
89+ const scaleWrap = document . createElement ( 'div' ) ;
90+ scaleWrap . classList . add ( 'apl-scale-wrap' ) ;
91+ el . appendChild ( scaleWrap ) ;
92+
8693 const outerDiv = document . createElement ( 'div' ) ;
87- outerDiv . style . cssText = 'position:relative;display:inline-block;user-select:none;z-index:1;isolation:isolate;' ;
88- el . appendChild ( outerDiv ) ;
94+ outerDiv . classList . add ( 'apl-outer' ) ;
95+ scaleWrap . appendChild ( outerDiv ) ;
8996
9097 const gridDiv = document . createElement ( 'div' ) ;
9198 gridDiv . style . cssText = `display:grid;gap:4px;background:${ theme . bg } ;padding:8px;border-radius:4px;` ;
@@ -2801,75 +2808,53 @@ function render({ model, el }) {
28012808 for ( const p of panels . values ( ) ) _redrawPanel ( p ) ;
28022809 }
28032810
2804- // ── cell-aware layout: redraw when the notebook cell changes size ─────────
2805- // We observe `el` (the anywidget mount point) directly. The notebook sets
2806- // its width; we never write to it, so there is no feedback loop.
2807- // Only triggers when the cell is narrower than the current widget — we
2808- // never upscale beyond the stored fig_width / fig_height.
2811+ // ── cell-aware CSS scaling ────────────────────────────────────────────────
2812+ // When the notebook cell (or any container) is narrower than the figure's
2813+ // native size, apply CSS transform:scale() to outerDiv so it shrinks
2814+ // proportionally without re-rendering canvases or writing to the model.
2815+ // Full canvas resolution is preserved; CSS transforms correctly route
2816+ // pointer events so all interactive features (drag, zoom, pan) keep working.
2817+ // Also called after layout/resize changes so the scale stays in sync.
2818+ function _applyScale ( ) {
2819+ const cellW = el . getBoundingClientRect ( ) . width ;
2820+ if ( ! cellW ) return ;
2821+ // offsetWidth is the pre-transform layout width — unaffected by any
2822+ // currently applied transform, so it always reflects the native figure size.
2823+ const nativeW = outerDiv . offsetWidth ;
2824+ const nativeH = outerDiv . offsetHeight ;
2825+ if ( ! nativeW ) return ;
2826+ const s = Math . min ( 1.0 , cellW / nativeW ) ;
2827+ // transform-origin:top left (set in _css) means scale(s) shrinks the
2828+ // figure from the top-left corner. With width:100% on scaleWrap the
2829+ // scaled figure (nativeW*s ≤ cellW) always fits — no overflow, no
2830+ // clipping, no cross-browser layout-box vs visual-box discrepancies.
2831+ outerDiv . style . transform = s < 1 ? `scale(${ s } )` : '' ;
2832+ // transform:scale does not affect layout — outerDiv still occupies
2833+ // nativeH px in the flow even when visually shorter. A negative
2834+ // marginBottom compensates exactly, pulling subsequent content up by
2835+ // the difference without ever touching scaleWrap's own dimensions.
2836+ // This eliminates any ResizeObserver feedback loop.
2837+ outerDiv . style . marginBottom = ( s < 1 && nativeH )
2838+ ? Math . round ( nativeH * ( s - 1 ) ) + 'px'
2839+ : '' ;
2840+ }
2841+
28092842 if ( typeof ResizeObserver !== 'undefined' ) {
2810- let _roTimer = null ;
2811- let _roActive = false ; // prevent re-entry
28122843 let _lastCellW = 0 ;
2813-
2814- const _ro = new ResizeObserver ( entries => {
2815- if ( _roActive || isResizing || _suppressLayoutUpdate ) return ;
2844+ new ResizeObserver ( entries => {
2845+ // React only to width changes to avoid a feedback loop: our own
2846+ // scaleWrap height updates would otherwise re-trigger the observer.
28162847 const cellW = entries [ 0 ] . contentRect . width ;
2817- if ( ! cellW || cellW === _lastCellW ) return ;
2848+ if ( cellW === _lastCellW ) return ;
28182849 _lastCellW = cellW ;
2819-
2820- // Measure the widget's natural width (what it would like to be)
2821- const gridW = gridDiv . scrollWidth || gridDiv . getBoundingClientRect ( ) . width ;
2822- if ( ! gridW || cellW >= gridW - 2 ) return ; // fits — nothing to do
2823-
2824- clearTimeout ( _roTimer ) ;
2825- _roTimer = setTimeout ( ( ) => {
2826- if ( _roActive || isResizing || _suppressLayoutUpdate ) return ;
2827-
2828- // Re-read the cell width inside the timeout (may have changed)
2829- const availW = el . getBoundingClientRect ( ) . width ;
2830- if ( ! availW ) return ;
2831-
2832- let layout ;
2833- try { layout = JSON . parse ( model . get ( 'layout_json' ) ) ; } catch ( _ ) { return ; }
2834-
2835- const curW = layout . fig_width || model . get ( 'fig_width' ) ;
2836- const curH = layout . fig_height || model . get ( 'fig_height' ) ;
2837- if ( ! curW || availW >= curW ) return ; // already fits
2838-
2839- const scale = availW / curW ;
2840- const nfw = Math . max ( 200 , Math . round ( curW * scale ) ) ;
2841- const nfh = Math . max ( 100 , Math . round ( curH * scale ) ) ;
2842-
2843- _roActive = true ;
2844- _suppressLayoutUpdate = true ;
2845- _cachedLayout = layout ; // reuse cached layout for the resize
2846- _applyFigResize ( nfw , nfh ) ;
2847-
2848- // Persist the new size into the model so it survives kernel restarts
2849- try {
2850- layout . fig_width = nfw ;
2851- layout . fig_height = nfh ;
2852- for ( const spec of layout . panel_specs ) {
2853- const p = panels . get ( spec . id ) ;
2854- if ( p ) { spec . panel_width = p . pw ; spec . panel_height = p . ph ; }
2855- }
2856- model . set ( 'layout_json' , JSON . stringify ( layout ) ) ;
2857- } catch ( _ ) { }
2858- _suppressLayoutUpdate = false ;
2859- model . set ( 'fig_width' , nfw ) ;
2860- model . set ( 'fig_height' , nfh ) ;
2861- model . save_changes ( ) ;
2862-
2863- _roActive = false ;
2864- } , 150 ) ;
2865- } ) ;
2866-
2867- _ro . observe ( el ) ;
2850+ requestAnimationFrame ( _applyScale ) ;
2851+ } ) . observe ( el ) ;
2852+ requestAnimationFrame ( _applyScale ) ;
28682853 }
28692854
28702855 // ── model listeners ───────────────────────────────────────────────────────
2871- model . on ( 'change:layout_json' , ( ) => { applyLayout ( ) ; redrawAll ( ) ; } ) ;
2872- model . on ( 'change:fig_width change:fig_height' , ( ) => { applyLayout ( ) ; redrawAll ( ) ; } ) ;
2856+ model . on ( 'change:layout_json' , ( ) => { applyLayout ( ) ; redrawAll ( ) ; requestAnimationFrame ( _applyScale ) ; } ) ;
2857+ model . on ( 'change:fig_width change:fig_height' , ( ) => { applyLayout ( ) ; redrawAll ( ) ; requestAnimationFrame ( _applyScale ) ; } ) ;
28732858
28742859 // Python→JS targeted widget update (source:"python" in event_json).
28752860 // Applies changed fields directly to the widget in overlay_widgets and
0 commit comments