201201 .lg-tier .focused { color : var (--text ); }
202202 .lg-tier .focused b { color : var (--epi ); }
203203
204+ # btn-home { position : fixed; right : 34px ; bottom : 108px ; z-index : 20 ;
205+ background : rgba (9 , 7 , 18 , .78 ); color : var (--dim ); cursor : pointer;
206+ border : 1px solid rgba (186 , 220 , 240 , .28 ); border-radius : 999px ;
207+ padding : 8px 18px ; font : 300 10.5px "Avenir Next" , Futura, system-ui, sans-serif;
208+ letter-spacing : .22em ; text-transform : uppercase;
209+ opacity : 0 ; pointer-events : none; transition : opacity .25s ; }
210+ # btn-home .show { opacity : 1 ; pointer-events : auto; }
211+ # btn-home : hover { color : var (--text ); border-color : var (--epi );
212+ box-shadow : 0 0 28px rgba (143 , 231 , 255 , .14 ); }
213+
204214 # toast { position : fixed; top : 80px ; left : 50% ; transform : translateX (-50% ); z-index : 30 ;
205215 color : var (--epi ); font-size : 13px ; letter-spacing : .06em ; display : none;
206216 text-shadow : 0 0 20px rgba (143 , 231 , 255 , .6 ); }
@@ -349,6 +359,7 @@ <h6>Live activity</h6>
349359</ div >
350360< div id ="toast "> </ div >
351361< div id ="tip "> </ div >
362+ < button id ="btn-home "> ⌖ center the brain</ button >
352363< div id ="hint "> grab & spin freely · scroll to zoom · double-click to reset · click a memory or a folder · drop a file to teach</ div >
353364
354365< script >
@@ -524,16 +535,15 @@ <h6>Live activity</h6>
524535 // how face-on the view is: 1 when the depth axis is unrotated, →0 edge-on
525536 return Math . abs ( ROT . m [ 8 ] ) ;
526537}
538+ // The organ is pinned to screen center: zoom scales AROUND the brain (never
539+ // toward the cursor — cursor-anchored zoom is how residual pan used to park
540+ // it off-center), and any drag-pan springs back home on release.
541+ let HOMING = false ;
527542canvas . addEventListener ( "wheel" , e => {
528543 e . preventDefault ( ) ;
544+ HOMING = false ;
529545 const factor = Math . exp ( - e . deltaY * 0.0016 ) ;
530- const k2 = Math . min ( 7 , Math . max ( 0.5 , VIEW . k * factor ) ) ;
531- const f = k2 / VIEW . k ;
532- // keep the point under the cursor fixed while zooming
533- const cxs = e . clientX - W / 2 , cys = e . clientY - H / 2 ;
534- VIEW . ox = cxs - ( cxs - VIEW . ox ) * f ;
535- VIEW . oy = cys - ( cys - VIEW . oy ) * f ;
536- VIEW . k = k2 ;
546+ VIEW . k = Math . min ( 7 , Math . max ( 0.5 , VIEW . k * factor ) ) ;
537547 dismissHint ( false ) ;
538548} , { passive : false } ) ;
539549let panDrag = null ;
@@ -543,15 +553,31 @@ <h6>Live activity</h6>
543553 ox : VIEW . ox , oy : VIEW . oy , moved : false ,
544554 pan : e . shiftKey || e . button === 1 } ;
545555 ROT . vx = 0 ; ROT . vy = 0 ; // grab kills any leftover spin
556+ HOMING = false ; // ...and any homing in flight
546557 canvas . style . cursor = "grabbing" ;
547558} ) ;
548559window . addEventListener ( "mouseup" , ( ) => { panDrag = null ; canvas . style . cursor = "grab" ; } ) ;
560+ // The ambient idle turntable also rotates the organ, so raw orientation says
561+ // nothing about intent: only a USER-caused turn makes the view "not home".
562+ let USER_TURNED = false ;
563+ function goHome ( ) {
564+ HOMING = true ;
565+ ROT . vx = 0 ; ROT . vy = 0 ;
566+ }
549567canvas . addEventListener ( "dblclick" , e => {
550- if ( ! pick ( e . clientX , e . clientY ) ) {
551- VIEW . k = 1 ; VIEW . ox = 0 ; VIEW . oy = 0 ;
552- ROT . m = [ 1 , 0 , 0 , 0 , 1 , 0 , 0 , 0 , 1 ] ; ROT . vx = 0 ; ROT . vy = 0 ;
553- }
568+ if ( ! pick ( e . clientX , e . clientY ) ) goHome ( ) ;
554569} ) ;
570+ const HOME_BTN = document . getElementById ( "btn-home" ) ;
571+ HOME_BTN . addEventListener ( "click" , goHome ) ;
572+ const IDENT = [ 1 , 0 , 0 , 0 , 1 , 0 , 0 , 0 , 1 ] ;
573+ function viewIsHome ( ) {
574+ if ( Math . abs ( VIEW . k - 1 ) > 0.01 ) return false ;
575+ if ( Math . abs ( VIEW . ox ) > 0.5 || Math . abs ( VIEW . oy ) > 0.5 ) return false ;
576+ return ! USER_TURNED ;
577+ }
578+ function rotNearIdentity ( ) {
579+ return ROT . m [ 0 ] > 0.9995 && ROT . m [ 4 ] > 0.9995 && ROT . m [ 8 ] > 0.9995 ;
580+ }
555581
556582/* ---------- luminous sprites (cheap, soft glow) ---------- */
557583function makeSprite ( color , size ) {
@@ -987,6 +1013,10 @@ <h6>Live activity</h6>
9871013
9881014function loop ( now ) {
9891015 if ( document . hidden ) { setTimeout ( ( ) => requestAnimationFrame ( loop ) , 900 ) ; return ; }
1016+ // A webview can finish its first layout AFTER the boot-time resize() ran,
1017+ // or swallow the resize event entirely — a stale W/H bakes the organ into a
1018+ // wrong-sized viewport (off-center, wrong scale) until a manual resize.
1019+ if ( W !== window . innerWidth || H !== window . innerHeight ) resize ( ) ;
9901020 // Watching without touching the mouse is a real mode: ambient stays a
9911021 // smooth ~30fps (half the cost of active), near-zero only when hidden.
9921022 const idle = now - lastInteract > 6000 ;
@@ -996,11 +1026,32 @@ <h6>Live activity</h6>
9961026 if ( Math . abs ( ROT . vx ) > 0.01 || Math . abs ( ROT . vy ) > 0.01 ) {
9971027 tumble ( ROT . vy * ROT_SPEED , ROT . vx * ROT_SPEED ) ; // flick keeps spinning
9981028 ROT . vx *= 0.90 ; ROT . vy *= 0.90 ;
999- } else if ( idle ) {
1029+ } else if ( idle && ! HOMING ) {
10001030 tumble ( 0 , 0.0022 ) ; // slow turntable while idle
10011031 }
10021032 }
10031033 }
1034+ // The organ always finds its way home: a released drag-pan springs back to
1035+ // dead center at any zoom, and homing (button / double-click) additionally
1036+ // eases zoom and orientation back to the default view.
1037+ if ( ! panDrag ) {
1038+ if ( VIEW . ox !== 0 || VIEW . oy !== 0 ) {
1039+ VIEW . ox *= 0.88 ; VIEW . oy *= 0.88 ;
1040+ if ( Math . abs ( VIEW . ox ) < 0.4 ) VIEW . ox = 0 ;
1041+ if ( Math . abs ( VIEW . oy ) < 0.4 ) VIEW . oy = 0 ;
1042+ }
1043+ if ( HOMING ) {
1044+ VIEW . k += ( 1 - VIEW . k ) * 0.16 ;
1045+ const m = ROT . m , t = 0.18 ;
1046+ ROT . m = rotOrtho ( m . map ( ( v , i ) => v + ( IDENT [ i ] - v ) * t ) ) ;
1047+ if ( Math . abs ( VIEW . k - 1 ) < 0.01 && rotNearIdentity ( ) ) {
1048+ VIEW . k = 1 ; VIEW . ox = 0 ; VIEW . oy = 0 ;
1049+ ROT . m = IDENT . slice ( ) ;
1050+ HOMING = false ; USER_TURNED = false ;
1051+ }
1052+ }
1053+ }
1054+ HOME_BTN . classList . toggle ( "show" , ! viewIsHome ( ) ) ;
10041055 const rate = idle ? 0.04 : Math . min ( 0.5 , 0.06 + edges . length / 350 ) ;
10051056 if ( ! REDUCED && Math . random ( ) < rate ) spawnPulse ( false ) ;
10061057 draw ( now ) ;
@@ -1031,6 +1082,7 @@ <h6>Live activity</h6>
10311082 if ( Math . abs ( dx ) + Math . abs ( dy ) > 4 ) panDrag . moved = true ;
10321083 if ( panDrag . moved && ! panDrag . pan ) {
10331084 const ix = ev . clientX - panDrag . lx , iy = ev . clientY - panDrag . ly ; // step since last frame
1085+ USER_TURNED = true ;
10341086 tumble ( iy * ROT_SPEED , ix * ROT_SPEED ) ;
10351087 ROT . vx = ix ; ROT . vy = iy ; // carry velocity for the flick
10361088 panDrag . lx = ev . clientX ; panDrag . ly = ev . clientY ;
0 commit comments