Skip to content

Commit e4fb09f

Browse files
committed
Enhance responsive iframe embedding: add max width for notebook display and improve scaling logic for interactive widgets
1 parent 9260bc8 commit e4fb09f

File tree

4 files changed

+139
-77
lines changed

4 files changed

+139
-77
lines changed

anyplotlib/_repr_utils.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
from html import escape
2525
from uuid import uuid4
2626

27+
# Maximum display width (px) for the non-resizable notebook embed.
28+
# Figures wider than this are scaled down proportionally via CSS transform.
29+
# 860 px fits comfortably in a standard JupyterLab / VS Code notebook cell.
30+
MAX_NOTEBOOK_WIDTH = 860
31+
2732

2833
# ---------------------------------------------------------------------------
2934
# Trait serialisation
@@ -204,8 +209,9 @@ def build_standalone_html(widget, *, resizable: bool = True) -> str:
204209

205210

206211
def repr_html_iframe(widget, *, resizable: bool = False,
212+
max_width: int = MAX_NOTEBOOK_WIDTH,
207213
max_height: int = 800) -> str:
208-
"""Return a centred ``<iframe srcdoc=...>`` embedding *widget*.
214+
"""Return a centred, responsive ``<iframe srcdoc=...>`` embedding *widget*.
209215
210216
Parameters
211217
----------
@@ -215,9 +221,13 @@ def repr_html_iframe(widget, *, resizable: bool = False,
215221
Passed to :func:`build_standalone_html`. Default ``False`` for
216222
documentation embeds — hides the resize handle and sizes the iframe
217223
exactly to the widget.
224+
max_width : int
225+
Maximum display width in pixels. Figures wider than this are scaled
226+
down proportionally via ``transform:scale()`` so they never overflow
227+
the notebook cell. Default ``MAX_NOTEBOOK_WIDTH`` (860 px).
218228
max_height : int
219-
Upper bound on iframe height in pixels (only applied when
220-
``resizable=True`` and auto-sizing is used).
229+
Upper bound on iframe height in pixels (only used when
230+
``resizable=True``).
221231
"""
222232
inner_html = build_standalone_html(widget, resizable=resizable)
223233
escaped = escape(inner_html, quote=True)
@@ -226,19 +236,54 @@ def repr_html_iframe(widget, *, resizable: bool = False,
226236
w, h = _widget_px(widget)
227237

228238
if not resizable:
229-
# Fixed size — iframe is exactly the widget's natural dimensions.
230-
# Centred via a block wrapper with auto margins.
239+
# ── Responsive fixed-size embed ────────────────────────────────────
240+
# The iframe always renders at its native resolution so the widget
241+
# is pixel-perfect on wide screens. On narrower cells a CSS
242+
# transform:scale() shrinks it proportionally — CSS transforms
243+
# correctly route pointer events so interaction still works.
244+
#
245+
# The static scale (baked into the style attribute) renders correctly
246+
# before JS runs. requestAnimationFrame defers the first JS
247+
# measurement until after layout is complete so offsetWidth is
248+
# always valid; the !avail guard prevents a not-yet-reflowed parent
249+
# from collapsing the wrapper.
250+
init_scale = min(1.0, max_width / w)
251+
init_w = round(w * init_scale)
252+
init_h = round(h * init_scale)
253+
scale_css = f"{init_scale:.6f}".rstrip("0").rstrip(".")
254+
255+
js = (
256+
f"(function(){{"
257+
f"var wrap=document.getElementById('vw-{uid}'),"
258+
f"ifr=wrap.querySelector('iframe'),"
259+
f"nw={w},nh={h};"
260+
f"function r(){{"
261+
f"var avail=wrap.parentElement?wrap.parentElement.offsetWidth:0;"
262+
f"if(!avail)return;"
263+
f"var s=Math.min(1,avail/nw);"
264+
f"wrap.style.width=Math.round(nw*s)+'px';"
265+
f"wrap.style.height=Math.round(nh*s)+'px';"
266+
f"ifr.style.transform='scale('+s+')';"
267+
f"}}"
268+
f"requestAnimationFrame(r);window.addEventListener('resize',r);"
269+
f"}})()"
270+
)
271+
231272
return (
232-
f'<div style="display:block;text-align:center;line-height:0;">'
233-
f'<iframe id="vw-{uid}" srcdoc="{escaped}" frameborder="0" '
234-
f'scrolling="no" '
235-
f'style="width:{w}px;height:{h}px;border:none;overflow:hidden;'
236-
f'display:inline-block;max-width:100%;">'
273+
f'<div style="display:block;text-align:center;line-height:0;margin:8px 0;">'
274+
f'<div id="vw-{uid}" style="display:inline-block;overflow:hidden;'
275+
f'position:relative;width:{init_w}px;height:{init_h}px;">'
276+
f'<iframe srcdoc="{escaped}" frameborder="0" scrolling="no" '
277+
f'style="width:{w}px;height:{h}px;border:none;overflow:hidden;display:block;'
278+
f'transform-origin:top left;transform:scale({scale_css});'
279+
f'position:absolute;top:0;left:0;">'
237280
f'</iframe>'
238281
f'</div>'
282+
f'<script>{js}</script>'
283+
f'</div>'
239284
)
240285
else:
241-
# Resizable — fill container width, auto-resize height after render.
286+
# ── Resizable embed (fills cell width, auto-sizes height) ──────────
242287
return (
243288
f'<iframe id="vw-{uid}" srcdoc="{escaped}" frameborder="0" '
244289
f'style="width:100%;height:{h}px;border:none;overflow:hidden;" '

anyplotlib/figure.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,31 @@ class Figure(anywidget.AnyWidget):
7373
# Bidirectional JS event bus: JS writes interaction events here, Python reads them.
7474
event_json = traitlets.Unicode("{}").tag(sync=True)
7575
_esm = _ESM_SOURCE
76+
# Static CSS injected by anywidget alongside _esm.
77+
# .apl-scale-wrap — outer container; width:100% means it always fills
78+
# the cell without any JS width updates.
79+
# .apl-outer — the figure root; will-change:transform pre-promotes
80+
# it to a GPU compositing layer so transform:scale()
81+
# changes cost zero layout/paint passes.
82+
_css = """\
83+
.apl-scale-wrap {
84+
display: block;
85+
width: 100%;
86+
overflow: visible;
87+
position: relative;
88+
line-height: 0;
89+
}
90+
.apl-outer {
91+
display: inline-block;
92+
position: relative;
93+
user-select: none;
94+
z-index: 1;
95+
isolation: isolate;
96+
will-change: transform;
97+
transform-origin: top left;
98+
vertical-align: top;
99+
}
100+
"""
76101

77102
def __init__(self, nrows=1, ncols=1, figsize=(640, 480),
78103
width_ratios=None, height_ratios=None,

anyplotlib/figure_esm.js

Lines changed: 49 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -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

docs/_sg_html_scraper.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,19 +129,26 @@ def _iframe_html(src: str, w: int, h: int) -> str:
129129
# Inline JS: re-scale whenever the window is resized.
130130
# Uses the wrapper's parent width as the available space so the figure
131131
# always fills (but never overflows) the content column.
132+
#
133+
# requestAnimationFrame defers the first call until after the browser has
134+
# finished its initial layout pass, so offsetWidth is always non-zero.
135+
# The !avail guard ensures a partially-laid-out parent (offsetWidth==0)
136+
# never collapses the wrapper — the CSS-baked initial scale stays intact
137+
# until a valid measurement is available.
132138
js = (
133139
f"(function(){{"
134140
f"var wrap=document.getElementById('{uid}'),"
135141
f"ifr=wrap.querySelector('iframe'),"
136142
f"nw={w},nh={h};"
137143
f"function r(){{"
138-
f"var avail=wrap.parentElement?wrap.parentElement.offsetWidth:nw;"
144+
f"var avail=wrap.parentElement?wrap.parentElement.offsetWidth:0;"
145+
f"if(!avail)return;"
139146
f"var s=Math.min(1,avail/nw);"
140147
f"wrap.style.width=Math.round(nw*s)+'px';"
141148
f"wrap.style.height=Math.round(nh*s)+'px';"
142149
f"ifr.style.transform='scale('+s+')';"
143150
f"}}"
144-
f"r();window.addEventListener('resize',r);"
151+
f"requestAnimationFrame(r);window.addEventListener('resize',r);"
145152
f"}})()"
146153
)
147154

0 commit comments

Comments
 (0)