2026-10-23 19:32:57 +00:00
<!doctype html>
< html lang = "en" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1, viewport-fit=cover" >
< title > Sphere Yantra</ title >
< link rel = "stylesheet" href = "https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;1,500&family=JetBrains+Mono:wght@400;500&display=swap" >
< style >
/* Layout: full-bleed night canvas, sphere centred; one compact control tray along the bottom. Single dark look by choice. */
: root {
--bg : #0b0a12 ; --panel : #15131f ; --line : #2a2638 ; --fg : #e9e4f2 ; --muted : #9a93ad ;
--up : #f0b44c ; --down : #e2559a ; --bindu : #ff3b4e ; --grid : #3a3550 ;
--display : "Cormorant Garamond" , Georgia , serif ; --mono : "JetBrains Mono" , ui-monospace , monospace ;
color-scheme : dark ;
}
html , body { height : 100 % ; margin : 0 }
body { background : var ( -- bg ); color : var ( -- fg ); font-family : var ( -- mono ); font-size : 14 px ; overflow : hidden }
# stage { position : fixed ; inset : 0 }
header { position : fixed ; top : 0 ; left : 0 ; right : 0 ; padding-top : calc ( 12 px + env ( safe - area - inset - top , 0 px )); padding-left : max ( 16 px , env ( safe - area - inset - left , 0 px )); padding-right : max ( 16 px , env ( safe - area - inset - right , 0 px )); pointer-events : none }
. home { pointer-events : auto ; display : inline-block ; margin-bottom : 4 px ; color : var ( -- muted ); font-size : 11 px ; text-decoration : none ; letter-spacing : .05 em }
h1 { font-family : var ( -- display ); font-weight : 500 ; font-size : 26 px ; margin : 0 ; letter-spacing : .01 em }
h1 i { color : var ( -- down )}
. sub { color : var ( -- muted ); font-size : 11 px ; margin-top : 2 px ; letter-spacing : .04 em }
. tray { position : fixed ; left : 0 ; right : 0 ; bottom : 0 ; background : linear-gradient ( transparent , var ( -- bg ) 30 % ); padding-bottom : calc ( 14 px + env ( safe - area - inset - bottom , 0 px )); padding-top : 28 px ; padding-left : max ( 16 px , env ( safe - area - inset - left , 0 px )); padding-right : max ( 16 px , env ( safe - area - inset - right , 0 px ))}
. inner { max-width : 560 px ; margin : 0 auto ; display : flex ; flex-direction : column ; gap : 12 px }
. row { display : flex ; gap : 8 px ; flex-wrap : wrap }
. seg { display : flex ; border : 1 px solid var ( -- line ); border-radius : 999 px ; overflow : hidden }
. seg button { background : transparent ; color : var ( -- muted ); border : 0 ; font : inherit ; font-size : 11 px ; padding : 7 px 12 px ; cursor : pointer ; letter-spacing : .05 em }
2026-10-23 19:50:45 +00:00
. seg button { opacity : .55 }
. seg button [ aria-pressed = "true" ] { background : var ( -- panel ); color : var ( -- fg ); opacity : 1 }
. seg button [ aria-pressed = "true" ] :: before { content : "" ; display : inline-block ; width : 6 px ; height : 6 px ; border-radius : 50 % ; background : var ( -- down ); margin-right : 7 px ; vertical-align : middle }
2026-10-23 19:32:57 +00:00
button : focus-visible , input : focus-visible { outline : 2 px solid var ( -- down ); outline-offset : 2 px }
label . sl { display : grid ; grid-template-columns : auto 1 fr auto ; align-items : center ; gap : 10 px ; font-size : 11 px ; color : var ( -- muted ); letter-spacing : .05 em }
input [ type = range ] { width : 100 % ; accent-color : var ( -- down )}
. val { font-variant-numeric : tabular-nums ; color : var ( -- fg ); min-width : 3.5 em ; text-align : right }
. chip { font-size : 11 px ; color : var ( -- muted ); display : flex ; align-items : center ; gap : 6 px }
. dot { width : 8 px ; height : 8 px ; border-radius : 50 % }
. note { font-size : 10.5 px ; color : var ( -- muted ); line-height : 1.5 }
</ style >
</ head >
< body >
< div id = "stage" ></ div >
< header >< a class = "home" href = "/" > ← maths</ a >
< h1 > Śrī Yantra < i > on the sphere</ i ></ h1 >
< div class = "sub" > drag to spin · pinch or scroll to zoom</ div >
</ header >
< div class = "tray" >< div class = "inner" >
< div class = "row" >
< div class = "seg" role = "group" aria-label = "Projection" >
< button id = "m-stereo" aria-pressed = "true" > stereographic</ button >
< button id = "m-gnomo" aria-pressed = "false" > great circles</ button >
</ div >
< div class = "seg" role = "group" aria-label = "Show" >
< button id = "t-sphere" aria-pressed = "true" > sphere</ button >
< button id = "t-inv" aria-pressed = "true" > inverse</ button >
2026-10-23 19:50:45 +00:00
< button id = "t-plane" aria-pressed = "true" > flat shadow</ button >
2026-10-23 20:30:34 +00:00
< button id = "t-outer" aria-pressed = "true" > outer</ button >
2026-10-23 19:32:57 +00:00
< button id = "t-spin" aria-pressed = "true" > auto-spin</ button >
</ div >
</ div >
2026-10-23 19:54:28 +00:00
< label class = "sl" for = "lift" > lift < input id = "lift" type = "range" min = "-3" max = "4" step = "0.01" value = "0" > < span class = "val" id = "liftv" > 1.00</ span ></ label >
2026-10-23 19:32:57 +00:00
< div class = "row" >
< span class = "chip" >< span class = "dot" style = "background:var(--up)" ></ span > 4 up · Śiva</ span >
< span class = "chip" >< span class = "dot" style = "background:var(--down)" ></ span > 5 down · Śakti</ span >
< span class = "chip" >< span class = "dot" style = "background:var(--bindu)" ></ span > bindu at the pole</ span >
</ div >
< div class = "note" id = "note" ></ div >
</ div ></ div >
< script src = "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" ></ script >
< script src = "https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js" ></ script >
< script >
// Triangle data (Tripathi / texample, after Kulaichev & Huet): left_x, base_y, apex_y, right_x, type (1=up)
// SVG frame: centre (150,150), radius 100, y down.
const T = [
[ 53.65669559977147 , 123.20508075688774 , 250 , 246.34330440022853 , 0 ],
[ 52.984011026495736 , 174.24660560764943 , 50 , 247.01598897350425 , 1 ],
[ 98.71823312733801 , 220.03828947357886 , 123.20508075688774 , 201.281766872662 , 1 ],
[ 78.26467997914015 , 197.92315674002487 , 78.10499177949904 , 221.73532002085986 , 1 ],
[ 90.4856922951427 , 78.10499177949904 , 160.66014976539617 , 209.51430770485734 , 0 ],
[ 80.98384838952128 , 103.12199145016105 , 220.03828947357886 , 219.0161516104787 , 0 ],
[ 114.9488500600036 , 160.66014976539617 , 103.12199145016105 , 185.0511499399964 , 1 ],
[ 116.35142605010424 , 134.30757626706648 , 197.92315674002487 , 183.64857394989576 , 0 ],
[ 124.61190803072795 , 144.79777263138968 , 174.24660560764943 , 175.38809196927207 , 0 ]];
const P = ( X , Y )=>[( X - 150 ) / 100 , - ( Y - 150 ) / 100 ];
const css = n => getComputedStyle ( document . documentElement ). getPropertyValue ( n ). trim ();
const C = { up : new THREE . Color ( css ( '--up' )), down : new THREE . Color ( css ( '--down' )), bindu : new THREE . Color ( css ( '--bindu' )), grid : new THREE . Color ( css ( '--grid' )), fg : new THREE . Color ( css ( '--fg' ))};
function seg ( a , b , n = 72 ){ const o = []; for ( let i = 0 ; i <= n ; i ++ ){ const t = i / n ; o . push ([ a [ 0 ] + ( b [ 0 ] - a [ 0 ]) * t , a [ 1 ] + ( b [ 1 ] - a [ 1 ]) * t ])} return o }
const tris = T . map (([ lx , by , ay , rx , ty ])=>{ const A = P ( lx , by ), B = P ( 150 , ay ), Cc = P ( rx , by ); return { up :!! ty , pts : [... seg ( A , B ),... seg ( B , Cc ). slice ( 1 ),... seg ( Cc , A ). slice ( 1 )]}});
const circ = ( r , n = 256 )=> Array . from ({ length : n + 1 },( _ , i )=>{ const a = i / n * Math . PI * 2 ; return [ r * Math . cos ( a ), r * Math . sin ( a )]});
function petals ( r0 , r1 , k ){ const o = []; for ( let i = 0 ; i < k ; i ++ ){ const a0 = i / k * Math . PI * 2 , a1 = ( i + 1 ) / k * Math . PI * 2 , am = ( a0 + a1 ) / 2 ; const pts = []; for ( let j = 0 ; j <= 24 ; j ++ ){ const t = j / 24 ; const a = a0 + ( am - a0 ) * t ; const r = r0 + ( r1 - r0 ) * Math . sin ( t * Math . PI / 2 ); pts . push ([ r * Math . cos ( a ), r * Math . sin ( a )])} for ( let j = 0 ; j <= 24 ; j ++ ){ const t = j / 24 ; const a = am + ( a1 - am ) * t ; const r = r1 - ( r1 - r0 ) * ( 1 - Math . cos ( t * Math . PI / 2 )); pts . push ([ r * Math . cos ( a ), r * Math . sin ( a )])} o . push ( pts )} return o }
2026-10-23 20:30:34 +00:00
function bhupura (){ const q = [[ 1.6 , - 1.6 ],[ 1.6 , - 0.6 ],[ 1.73 , - 0.6 ],[ 1.73 , - 1.2 ],[ 1.97 , - 1.2 ],[ 1.97 , 1.2 ],[ 1.73 , 1.2 ],[ 1.73 , 0.6 ],[ 1.6 , 0.6 ],[ 1.6 , 1.6 ]]; const c = []; for ( let k = 0 ; k < 4 ; k ++ ){ const co = Math . cos ( k * Math . PI / 2 ), si = Math . sin ( k * Math . PI / 2 ); q . forEach (([ x , y ])=> c . push ([ x * co - y * si , x * si + y * co ]))} c . push ( c [ 0 ]); const o = []; for ( let i = 0 ; i < c . length - 1 ; i ++ ) o . push (... seg ( c [ i ], c [ i + 1 ], 60 ). slice ( i ? 1 : 0 )); return o }
const decorInner = [ circ ( 1 )];
const decorOuter = [ circ ( 1.2 ), circ ( 1.4 ), circ ( 1.5 ),... petals ( 1 , 1.2 , 8 ),... petals ( 1.2 , 1.4 , 16 ), bhupura ()];
let showOuter = true ;
let decor = [... decorInner ,... decorOuter ];
2026-10-23 19:32:57 +00:00
let mode = 'stereo' , lift = 1 ;
function toSphere ([ x , y ]){ x *= lift ; y *= lift ; if ( mode === 'stereo' ){ const r2 = x * x + y * y , d = r2 + 1 ; return new THREE . Vector3 ( 2 * x / d ,( r2 - 1 ) / d , 2 * y / d )} return new THREE . Vector3 ( x , - 1 , y ). normalize ()}
function toPlane ([ x , y ]){ return new THREE . Vector3 ( x * lift , - 1.001 , y * lift )}
const W = ()=> innerWidth , H = ()=> innerHeight ;
const renderer = new THREE . WebGLRenderer ({ antialias : true , alpha : false });
2026-10-23 20:26:50 +00:00
renderer . setPixelRatio ( Math . min ( devicePixelRatio , 1.5 )); renderer . setClearColor ( new THREE . Color ( css ( '--bg' )));
2026-10-23 19:32:57 +00:00
document . getElementById ( 'stage' ). appendChild ( renderer . domElement );
const scene = new THREE . Scene ();
const cam = new THREE . PerspectiveCamera ( 40 , 1 , 0.01 , 100 ); cam . position . set ( 2.6 , - 2.4 , 3.2 );
const ctl = new THREE . OrbitControls ( cam , renderer . domElement ); ctl . enableDamping = true ; ctl . autoRotate = true ; ctl . autoRotateSpeed = 0.6 ; ctl . minDistance = 1.6 ; ctl . maxDistance = 12 ;
scene . add ( new THREE . Mesh ( new THREE . SphereGeometry ( 0.995 , 64 , 48 ), new THREE . MeshBasicMaterial ({ color : new THREE . Color ( css ( '--panel' )), transparent : true , opacity : . 55 })));
const gmat = new THREE . LineBasicMaterial ({ color : C . grid , transparent : true , opacity : . 55 });
for ( let i = 1 ; i < 12 ; i ++ ){ const ph = i / 12 * Math . PI ; const pts = []; for ( let j = 0 ; j <= 128 ; j ++ ){ const a = j / 128 * Math . PI * 2 ; pts . push ( new THREE . Vector3 ( Math . sin ( ph ) * Math . cos ( a ), Math . cos ( ph ), Math . sin ( ph ) * Math . sin ( a )))} scene . add ( new THREE . Line ( new THREE . BufferGeometry (). setFromPoints ( pts ), gmat ))}
for ( let i = 0 ; i < 12 ; i ++ ){ const a = i / 12 * Math . PI ; const pts = []; for ( let j = 0 ; j <= 128 ; j ++ ){ const t = j / 128 * Math . PI * 2 ; pts . push ( new THREE . Vector3 ( Math . sin ( t ) * Math . cos ( a ), Math . cos ( t ), Math . sin ( t ) * Math . sin ( a )))} scene . add ( new THREE . Line ( new THREE . BufferGeometry (). setFromPoints ( pts ), gmat ))}
const group = new THREE . Group (), planeG = new THREE . Group (); scene . add ( group , planeG );
const bindu = new THREE . Mesh ( new THREE . SphereGeometry (. 022 , 16 , 12 ), new THREE . MeshBasicMaterial ({ color : C . bindu })); scene . add ( bindu );
const northPt = new THREE . Mesh ( new THREE . SphereGeometry (. 014 , 12 , 8 ), new THREE . MeshBasicMaterial ({ color : C . fg })); northPt . position . set ( 0 , 1 , 0 ); scene . add ( northPt );
2026-10-23 19:50:45 +00:00
let showPlane = true , showInv = true , showSphere = true ;
2026-10-23 19:32:57 +00:00
const invP = ([ x , y ])=>{ const r = x * x + y * y ; return r < 1e-12 ? [ 1e6 , 0 ] : [ x / r , y / r ]};
const trisDense = T . map (([ lx , by , ay , rx , ty ])=>{ const A = P ( lx , by ), B = P ( 150 , ay ), Cc = P ( rx , by ); return { up :!! ty , pts : [... seg ( A , B , 600 ),... seg ( B , Cc , 600 ). slice ( 1 ),... seg ( Cc , A , 600 ). slice ( 1 )]}});
const bindu2 = new THREE . Mesh ( new THREE . SphereGeometry (. 022 , 16 , 12 ), new THREE . MeshBasicMaterial ({ color : C . bindu })); scene . add ( bindu2 );
2026-10-23 20:26:50 +00:00
const drop = g =>{ g . traverse ( o =>{ if ( o . geometry ) o . geometry . dispose (); if ( o . material ) o . material . dispose ()}); g . clear ()};
let pending = false ;
function build (){ if ( pending ) return ; pending = true ; requestAnimationFrame (()=>{ pending = false ; rebuild ()})}
function rebuild (){
drop ( group ); drop ( planeG );
2026-10-23 19:32:57 +00:00
const mk = ( pts , col , op , g , f )=> g . add ( new THREE . Line ( new THREE . BufferGeometry (). setFromPoints ( pts . map ( f )), new THREE . LineBasicMaterial ({ color : col , transparent : op < 1 , opacity : op })));
tris . forEach ( t => mk ( t . pts , t . up ? C . up : C . down , 1 , group , toSphere ));
decor . forEach ( d => mk ( d , C . fg ,. 45 , group , toSphere ));
bindu . position . set ( 0 , - 1 , 0 );
if ( showInv ){ trisDense . forEach ( t => mk ( t . pts . map ( invP ), t . up ? C . up : C . down ,. 8 , group , toSphere )); decor . forEach ( d => mk ( d . map ( invP ), C . fg ,. 3 , group , toSphere ));}
bindu2 . visible = showInv ; bindu2 . position . copy ( toSphere ([ 1e6 , 0 ]));
const pOp = showSphere ? . 35 : 1 ;
scene . children . forEach ( o =>{ if ( o !== planeG ) o . visible = showSphere });
if ( ! showSphere ) bindu2 . visible = false ;
2026-10-23 20:10:44 +00:00
if ( showPlane ){ const pb = new THREE . Mesh ( new THREE . CircleGeometry (. 022 , 24 ), new THREE . MeshBasicMaterial ({ color : C . bindu , side : THREE . DoubleSide })); pb . rotation . x =- Math . PI / 2 ; pb . position . copy ( toPlane ([ 0 , 0 ])). add ( new THREE . Vector3 ( 0 ,. 002 , 0 )); planeG . add ( pb );
tris . forEach ( t => mk ( t . pts , t . up ? C . up : C . down , pOp , planeG , toPlane )); decor . forEach ( d => mk ( d , C . fg , pOp * . 45 , planeG , toPlane ));
2026-10-23 19:32:57 +00:00
if ( showInv ){ const R = 6 / lift ; const clip = ( pts , col , op )=>{ let run = []; const flush = ()=>{ if ( run . length > 1 ) mk ( run , col , op , planeG , toPlane ); run = []}; for ( const p of pts ){ if ( Math . hypot ( p [ 0 ], p [ 1 ]) > R ) flush (); else run . push ( p )} flush ()};
2026-10-23 20:05:45 +00:00
trisDense . forEach ( t => clip ( t . pts . map ( invP ), t . up ? C . up : C . down , showSphere ? pOp * . 85 : . 55 )); decor . forEach ( d => clip ( d . map ( invP ), C . fg , pOp * . 35 ));}}
2026-10-23 19:32:57 +00:00
const deg = mode === 'stereo' ? 2 * Math . atan ( lift ) * 180 / Math . PI : Math . atan ( lift ) * 180 / Math . PI ;
document . getElementById ( 'note' ). textContent = ( ! showSphere ? 'The plane on its own: the yantra and its inversion in the outer circle. ' : '' ) + ( mode === 'stereo'
? 'Straight sides lift to circles through the top pole. Concurrences are kept exactly; only curvature changes.'
: 'Straight sides lift to great circles (central projection), the Kulaichev– Rao spherical form. Lift → 0 is the flat limit.' )
2026-10-23 19:54:37 +00:00
+ ` Yantra rim at ${ deg . toFixed ( 1 ) } ° from the bindu pole.` + ( lift === 0 ? ' At zero lift every finite point, the yantra and its inverse alike, shrinks into the bindu at the south pole; only infinity stays north. It mirrors lift ∞.' : '' ) + ( mode === 'stereo' && lift > 1e6 ? ' The rim has closed onto the north pole: 0 and ∞ hold the bindu and its dual.' : '' ) + ( mode !== 'stereo' && lift > 20 ? ' Great circles cap at the equator; switch to stereographic to reach the north pole.' : '' );
2026-10-23 19:32:57 +00:00
}
function resize (){ renderer . setSize ( W (), H ()); cam . aspect = W () / H (); cam . updateProjectionMatrix ()}
addEventListener ( 'resize' , resize ); resize (); build ();
const reduce = matchMedia ( '(prefers-reduced-motion: reduce)' ). matches ;
if ( reduce ){ ctl . autoRotate = false ; document . getElementById ( 't-spin' ). setAttribute ( 'aria-pressed' , 'false' )}
2026-10-23 20:26:50 +00:00
( function loop (){ requestAnimationFrame ( loop ); if ( document . hidden ) return ; ctl . update (); renderer . render ( scene , cam )})();
2026-10-23 19:32:57 +00:00
const $ = id => document . getElementById ( id );
function setMode ( m ){ mode = m ; $ ( 'm-stereo' ). setAttribute ( 'aria-pressed' , m === 'stereo' ); $ ( 'm-gnomo' ). setAttribute ( 'aria-pressed' , m === 'gnomo' ); build ()}
$ ( 'm-stereo' ). onclick = ()=> setMode ( 'stereo' ); $ ( 'm-gnomo' ). onclick = ()=> setMode ( 'gnomo' );
2026-10-23 20:05:45 +00:00
$ ( 't-sphere' ). onclick = e =>{ showSphere =! showSphere ; e . currentTarget . setAttribute ( 'aria-pressed' , showSphere ); if ( ! showSphere &&! showPlane ){ showPlane = true ; $ ( 't-plane' ). setAttribute ( 'aria-pressed' , 'true' )}
if ( ! showSphere ){ cam . position . set ( 0 , 3.6 , 0.0001 ); ctl . target . set ( 0 , - 1 , 0 ); ctl . autoRotate = false ; $ ( 't-spin' ). setAttribute ( 'aria-pressed' , 'false' )} else { cam . position . set ( 2.6 , - 2.4 , 3.2 ); ctl . target . set ( 0 , 0 , 0 )}
ctl . update (); build ()};
2026-10-23 20:30:34 +00:00
$ ( 't-outer' ). onclick = e =>{ showOuter =! showOuter ; e . currentTarget . setAttribute ( 'aria-pressed' , showOuter ); decor = showOuter ? [... decorInner ,... decorOuter ] : [... decorInner ]; build ()};
2026-10-23 19:32:57 +00:00
$ ( 't-inv' ). onclick = e =>{ showInv =! showInv ; e . currentTarget . setAttribute ( 'aria-pressed' , showInv ); build ()};
$ ( 't-plane' ). onclick = e =>{ showPlane =! showPlane ; e . currentTarget . setAttribute ( 'aria-pressed' , showPlane ); build ()};
$ ( 't-spin' ). onclick = e =>{ ctl . autoRotate =! ctl . autoRotate ; e . currentTarget . setAttribute ( 'aria-pressed' , ctl . autoRotate )};
2026-10-23 19:54:28 +00:00
$ ( 'lift' ). oninput = e =>{ const v =+ e . target . value ; lift = v >= 3.99 ? 1e7 : v <=- 2.99 ? 0 : Math . pow ( 10 , v ); $ ( 'liftv' ). textContent = v >= 3.99 ? '∞' : v <=- 2.99 ? '0' : ( lift < 0.01 ? lift . toFixed ( 3 ) : lift < 10 ? lift . toFixed ( 2 ) : lift . toFixed ( 0 )); build ()};
2026-10-23 19:32:57 +00:00
</ script >
</ body >
</ html >