@@ -4291,7 +4291,28 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
42914291 const gpuGeomChanged = p . _gpuObj && p . _gpuObj . geom !== geom ;
42924292 if ( gpuGeomChanged ) { _gpuDisposePanel ( p ) ; p . _gpu = undefined ; }
42934293 if ( p . kind === '3d' && _gpuWanted ( st ) ) {
4294- if ( p . _gpu === undefined ) {
4294+ // Zero-drawable-size guard: SpyDE (and any host) mounts a 3-D panel HIDDEN
4295+ // (display:none) and reveals it later, so the FIRST draw3d can run with
4296+ // pw/ph == 0 (a zero-size layout) and the gpuCanvas collapsed to 0 client
4297+ // px. _gpuInitPanel configures the WebGPU swapchain and every draw pass
4298+ // calls getCurrentTexture() against that canvas — on a zero-drawable-size
4299+ // surface this is undefined behaviour: some drivers throw (→ the mid-draw
4300+ // catch latches p._gpu='unavailable', TERMINAL) and others configure a
4301+ // dead 0×0 context that silently draws nothing. Either way the panel is
4302+ // then stuck on Canvas2D forever because the `_gpu === undefined` guard
4303+ // never re-runs. So DON'T init at zero size: leave _gpu === undefined and
4304+ // let the canvas path render (a hidden panel isn't visible anyway). When
4305+ // the panel is revealed/resized, the layout/fig-size change fires a
4306+ // redrawAll() → this draw3d re-runs at the real size → init re-attempts.
4307+ // Gate on pw/ph (the LOGICAL panel size that drives the gpuCanvas backing
4308+ // store in _gpuDrawPoints/_gpuEnsureSize and every getCurrentTexture()).
4309+ // NB: don't test the gpuCanvas' own client size — it starts display:none
4310+ // until GPU activates, so clientWidth is 0 even for a fully-visible panel;
4311+ // that would wrongly block the normal case. A panel revealed by a pure
4312+ // display toggle (pw/ph already non-zero, only client size was 0) is
4313+ // handled by the ResizeObserver reveal branch, which redraws it.
4314+ const hasDrawableSize = ( pw || 0 ) > 0 && ( ph || 0 ) > 0 ;
4315+ if ( p . _gpu === undefined && hasDrawableSize ) {
42954316 p . _gpu = 'pending' ;
42964317 const initGeom = geom ;
42974318 _gpuDevice ( ) . then ( ( device ) => {
@@ -5077,9 +5098,15 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
50775098 // unit spans equal pixels on x and y (an IPF triangle etc. must not stretch).
50785099 // Baked in here so EVERY consumer (draw1d / markers / overlay / hit-test) uses
50795100 // the identical box — matplotlib's transData derives from the adjusted axes box.
5101+ // Extra right-hand gutter reserved for a secondary (twinx) y-axis so its
5102+ // tick labels and rotated units clear the panel edge.
5103+ // Right gutter for a twinx axis: tick numbers (fmtVal caps them at ~6 chars,
5104+ // e.g. "1.0e+6") plus the rotated units label. ~58px clears both.
5105+ const PAD_R_TWIN = 58 ;
50805106 function _plotRect1d ( p ) {
50815107 const pw = p . pw , ph = p . ph , st = p . state ;
5082- const r = { x :PAD_L , y :PAD_T , w :Math . max ( 1 , pw - PAD_L - PAD_R ) , h :Math . max ( 1 , ph - PAD_T - PAD_B ) } ;
5108+ const padR = ( st && st . right_axis ) ? PAD_R_TWIN : PAD_R ;
5109+ const r = { x :PAD_L , y :PAD_T , w :Math . max ( 1 , pw - PAD_L - padR ) , h :Math . max ( 1 , ph - PAD_T - PAD_B ) } ;
50835110 if ( ! st || st . aspect !== 'equal' ) return r ;
50845111 const xArr = p . _1dXArr || ( st . x_axis_b64 ? _decodeF64 ( st . x_axis_b64 ) : ( st . x_axis || [ ] ) ) ;
50855112 const x0 = st . view_x0 || 0 , x1 = st . view_x1 || 1 ;
@@ -5135,6 +5162,21 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
51355162 return _valToPy1d ( isLog ? Math . log10 ( Math . max ( _logEps , v ) ) : v , effDMin , effDMax , r ) ;
51365163 }
51375164
5165+ // ── secondary (right-hand) y-axis scale ──
5166+ // A twinx line maps against right_y_range (explicit) or the auto range
5167+ // derived from the right-axis overlays. Always linear.
5168+ const hasRightAxis = st . right_axis === true ;
5169+ let rMin = 0 , rMax = 1 ;
5170+ if ( hasRightAxis ) {
5171+ if ( st . right_y_range && st . right_y_range . length === 2 ) {
5172+ rMin = st . right_y_range [ 0 ] ; rMax = st . right_y_range [ 1 ] ;
5173+ } else {
5174+ rMin = st . right_data_min != null ? st . right_data_min : 0 ;
5175+ rMax = st . right_data_max != null ? st . right_data_max : 1 ;
5176+ }
5177+ }
5178+ function _toRightY ( v ) { return _valToPy1d ( v , rMin , rMax , r ) ; }
5179+
51385180 ctx . clearRect ( 0 , 0 , pw , ph ) ;
51395181 ctx . fillStyle = theme . bg ; ctx . fillRect ( 0 , 0 , pw , ph ) ;
51405182 ctx . fillStyle = theme . bgPlot ; ctx . fillRect ( r . x , r . y , r . w , r . h ) ;
@@ -5236,8 +5278,9 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
52365278 // Stroke-only markers (no meaningful fill area)
52375279 const _MARKER_STROKE_ONLY = new Set ( [ '+' , 'x' ] ) ;
52385280
5239- function _drawLine ( yData , lineXArr , color , lw , linestyle , alpha , marker , markersize ) {
5281+ function _drawLine ( yData , lineXArr , color , lw , linestyle , alpha , marker , markersize , yMap ) {
52405282 if ( ! yData || ! yData . length ) return ;
5283+ const _yMap = yMap || _toPlotY ; // left scale by default; right lines pass _toRightY
52415284 const n = yData . length ;
52425285 const isStepMid = linestyle === 'step-mid' ;
52435286 const dash = isStepMid ? [ ] : ( _LINESTYLE_DASH [ linestyle || 'solid' ] || [ ] ) ;
@@ -5252,7 +5295,7 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
52525295 ? ( lineXArr [ i ] - lineXArr [ 0 ] ) / ( ( lineXArr [ lineXArr . length - 1 ] - lineXArr [ 0 ] ) || 1 )
52535296 : i / ( ( n - 1 ) || 1 ) ;
52545297 allPx [ i ] = _fracToPx1d ( xFrac , x0 , x1 , r ) ;
5255- allPy [ i ] = _toPlotY ( yData [ i ] ) ;
5298+ allPy [ i ] = _yMap ( yData [ i ] ) ;
52565299 }
52575300
52585301 ctx . save ( ) ;
@@ -5305,11 +5348,12 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
53055348 for ( const ex of ( st . extra_lines || [ ] ) ) {
53065349 const exY = ex . data_b64 ? _decodeF64 ( ex . data_b64 ) : ( ex . data || [ ] ) ;
53075350 const exX = ex . x_axis_b64 ? _decodeF64 ( ex . x_axis_b64 ) : ( ex . x_axis ? ex . x_axis : xArr ) ;
5351+ const exMap = ex . axis === 'right' ? _toRightY : _toPlotY ;
53085352 _drawLine ( exY , exX ,
53095353 ex . color || ( theme . dark ? '#fff' : '#333' ) , ex . linewidth || 1.5 ,
53105354 ex . linestyle || 'solid' ,
53115355 ex . alpha != null ? ex . alpha : 1.0 ,
5312- ex . marker || 'none' , ex . markersize || 4 ) ;
5356+ ex . marker || 'none' , ex . markersize || 4 , exMap ) ;
53135357 }
53145358 // ── hovered-line highlight: redraw on top with brightened colour + thicker stroke ──
53155359 const _hovId = p . _lineHoverId ;
@@ -5324,10 +5368,11 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
53245368 if ( ex . id === _hovId ) {
53255369 const exY = ex . data_b64 ? _decodeF64 ( ex . data_b64 ) : ( ex . data || [ ] ) ;
53265370 const exX = ex . x_axis_b64 ? _decodeF64 ( ex . x_axis_b64 ) : ( ex . x_axis ?ex . x_axis :xArr ) ;
5371+ const exMap = ex . axis === 'right' ? _toRightY : _toPlotY ;
53275372 _drawLine ( exY , exX ,
53285373 _brightenColor ( ex . color || ( theme . dark ?'#fff' :'#333' ) ) , ( ex . linewidth || 1.5 ) + 1 ,
53295374 ex . linestyle || 'solid' , ex . alpha != null ?ex . alpha :1.0 ,
5330- ex . marker || 'none' , ex . markersize || 4 ) ;
5375+ ex . marker || 'none' , ex . markersize || 4 , exMap ) ;
53315376 break ;
53325377 }
53335378 }
@@ -5399,6 +5444,46 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
53995444 }
54005445 }
54015446
5447+ // ── secondary (right-hand) y-axis: spine, ticks, label ──
5448+ if ( hasRightAxis && axisVis1d && yTicksVis1d ) {
5449+ const rColor = st . right_axis_color || theme . tickText ;
5450+ const rx = r . x + r . w ;
5451+ // Axis spine on the right edge of the plot rect.
5452+ ctx . strokeStyle = rColor ; ctx . lineWidth = 1 ;
5453+ ctx . beginPath ( ) ; ctx . moveTo ( rx , r . y ) ; ctx . lineTo ( rx , r . y + r . h ) ; ctx . stroke ( ) ;
5454+ ctx . font = ( st . tick_size || 10 ) + 'px monospace' ; ctx . textAlign = 'left' ; ctx . textBaseline = 'middle' ;
5455+ // Iterate the ordered range so ticks render regardless of whether the
5456+ // caller pinned set_right_ylim(lo,hi) or an inverted (hi,lo); _valToPy1d
5457+ // still maps against the raw rMin/rMax so a deliberate flip is preserved.
5458+ const rLo = Math . min ( rMin , rMax ) , rHi = Math . max ( rMin , rMax ) ;
5459+ const rRange = ( rHi - rLo ) || 1 ;
5460+ const rStep = findNice ( rRange / Math . max ( 2 , Math . floor ( r . h / 40 ) ) ) ;
5461+ let rMaxTW = 0 ;
5462+ for ( let v = Math . ceil ( rLo / rStep ) * rStep ; v <= rHi + rStep * 0.01 ; v += rStep ) {
5463+ const tw = ctx . measureText ( fmtVal ( v ) ) . width ; if ( tw > rMaxTW ) rMaxTW = tw ;
5464+ }
5465+ for ( let v = Math . ceil ( rLo / rStep ) * rStep ; v <= rHi + rStep * 0.01 ; v += rStep ) {
5466+ const py = _valToPy1d ( v , rMin , rMax , r ) ;
5467+ if ( py < r . y || py > r . y + r . h ) continue ;
5468+ ctx . strokeStyle = rColor ; ctx . beginPath ( ) ; ctx . moveTo ( rx , py ) ; ctx . lineTo ( rx + 5 , py ) ; ctx . stroke ( ) ;
5469+ ctx . fillStyle = rColor ; ctx . fillText ( fmtVal ( v ) , rx + 8 , py ) ;
5470+ }
5471+ const rUnits = st . right_y_units || '' ;
5472+ if ( rUnits ) {
5473+ ctx . save ( ) ;
5474+ const rylpx = st . y_label_size || 9 ;
5475+ // Place the rotated units label just past the widest tick number, but
5476+ // never past the panel edge — clamp keeps it on-canvas even when the
5477+ // fixed right gutter is too narrow for very wide tick strings.
5478+ const rcx = Math . min ( pw - Math . ceil ( rylpx * 0.62 ) - 1 , rx + 8 + rMaxTW + Math . ceil ( rylpx * 0.9 ) ) ;
5479+ ctx . translate ( rcx , r . y + r . h / 2 ) ; ctx . rotate ( - Math . PI / 2 ) ;
5480+ ctx . textBaseline = 'middle' ;
5481+ ctx . fillStyle = rColor ;
5482+ _drawTex ( ctx , rUnits , 0 , 0 , rylpx , { align :'center' , family :'monospace' } ) ;
5483+ ctx . restore ( ) ;
5484+ }
5485+ }
5486+
54025487 // Legend
54035488 const labels = [ ] ;
54045489 if ( st . line_label ) labels . push ( {
@@ -7959,9 +8044,32 @@ fn fs(in : VsOut) -> @location(0) vec4<f32> {
79598044 let _lastCellW = 0 ;
79608045 let _lastRoW = 0 , _lastRoH = 0 ;
79618046 let _roTimer = null ;
8047+ let _wasZeroSize = true ;
79628048 new ResizeObserver ( entries => {
79638049 const cr = entries [ 0 ] . contentRect ;
79648050 const cellW = cr . width , cellH = cr . height ;
8051+ // (0) Reveal detection: a host (SpyDE) can mount a panel HIDDEN
8052+ // (display:none) and reveal it later by toggling display alone, WITHOUT
8053+ // any layout_json / fig_width change (so neither model listener below
8054+ // fires a redraw). A 3-D GPU panel that skipped init while zero-size
8055+ // (see the hasDrawableSize guard in draw3d) would then stay on Canvas2D
8056+ // forever. When the observed element transitions from zero-size to a
8057+ // real size, re-attempt GPU init on any 3-D panel still on the canvas
8058+ // path and redraw it, so the reveal actually flips it to WebGPU.
8059+ const _nowZero = ! ( cellW > 0 && cellH > 0 ) ;
8060+ if ( _wasZeroSize && ! _nowZero ) {
8061+ _wasZeroSize = false ;
8062+ requestAnimationFrame ( ( ) => {
8063+ let _need = false ;
8064+ for ( const p of panels . values ( ) ) {
8065+ if ( p . kind === '3d' && p . _gpu === undefined && p . state
8066+ && _gpuWanted ( p . state ) ) { _need = true ; break ; }
8067+ }
8068+ if ( _need ) redrawAll ( ) ;
8069+ } ) ;
8070+ } else if ( _nowZero ) {
8071+ _wasZeroSize = true ;
8072+ }
79658073 // (1) Jupyter fit-to-cell down-scale — width-only to avoid a feedback
79668074 // loop (our own scaleWrap height updates would re-trigger the observer).
79678075 if ( cellW !== _lastCellW ) {
0 commit comments