1/** 2 * flux-rope/view.js â the 3D heliosphere view of the Flux Rope Simulator. 3 * 4 * GLSL/WASM stack (decision on record in FLUX_ROPE_SIMULATOR_PLAN.md §2): 5 * a WebGL fragment shader RAYMARCHES the rope train in true 3D â the same 6 * tapered-torus + Gold-Hoyle/Lundquist math as the kernel 7 * (FLUX_ROPE_PHYSICS_SPEC.md §3â§4, §10 superposition), evaluated along 8 * camera rays as a signed distance to the axis circle minus Ï(Ï). The 9 * kernel's fr_field_at is the ORACLE for this shader: change the field 10 * math in rust-flux-rope/src/rope.rs first, re-run the gates, then mirror 11 * here. Up to MAX_VIEW_ROPES = 6 ropes (kernel cap â moves in lockstep 12 * with rust-flux-rope MAX_ROPES), rendered as layered translucent shells 13 * colored by local Bz (red south / blue north). 14 * 15 * Camera: orbit (drag) + zoom (wheel) + double-click reset, z = ecliptic 16 * north. The SAME camera projects the 2D overlay (ensemble member axis 17 * circles â faded by particle-filter weight when assimilation is active â 18 * body labels, hints), so shader and annotations never disagree about 19 * where things are. 20 * 21 * Pure rendering: no fetch, no kernel calls â the page feeds it the rope 22 * train, member params (+ optional weights), per-rope kinematic probes, 23 * and the scrub time. 24 *
25 * COMPOUNDING IS RENDERED (2026-07-30): the shader mirrors the kernel's 26 * §15/§16 two-lobe boundary distortion f(θ) + §18 pancaking g(θ) (rope.rs 27 * boundary_distortion â compressed boundary Ï_eff = Ï·g·f, reference 28 * mapping Å = s/(g·f), flux-conservation boost 1/f), and the §14/§17 29 * front-side sheath shell (fixed-k or FarrisâRussell standoff, shaded by 30 * the RankineâHugoniot X(M)). The LIVE inputs ride the probes argument of 31 * draw(): per-rope rearC comes from the kernel's fr_rear_c_at each frame 32 * (oracle-direct â the follower's squeeze on its leader is never 33 * re-derived here), apexVKms/upstreamKms feed the wake-conditioned shock 34 * Mach. X(M)/FR(M)/V_MS are pure mirrors below, pinned against the 35 * kernel's fr_compression_x / fr_standoff_fr / fr_v_ms_kms probes by 36 * tests/flux-rope-kernel-smoke.mjs â if the kernel formulas move, that 37 * gate fails until this file is re-synced. 38 * 39 * Remaining display-only omissions: per-member lobes in the overlay 40 * spaghetti (axis circles only), and the sheath's OU Bz texture (the 41 * shell is drawn as compressed-pileup glow, not a field realization). 42 * §16 wake KINEMATICS are honored â the page passes each 43 * rope's EFFECTIVE w/Î from the kernel getters (fr_rope_w_eff_kms / 44 * fr_rope_gamma_eff), so follower positions match the kernel; member 45 * spaghetti uses the fit-level effective w with each member's own v0/Î â 46 * an approximation (a member's true wake depends on its leader's draw). 47 */ 48 49const AU_KM = 1.495978707e8; 50const RSUN_KM = 6.957e5; 51export const MAX_VIEW_ROPES = 6; 52 53/** Per-rope accent for the overlay median axes 54 * (cyan, violet, amber, green, rose, periwinkle). */ 55const ROPE_STROKES = [ 56 'rgba(170, 235, 255, 0.55)', 57 'rgba(199, 146, 234, 0.55)', 58 'rgba(255, 180, 84, 0.55)', 59 'rgba(127, 230, 195, 0.55)', 60 'rgba(255, 138, 168, 0.55)', 61 'rgba(158, 186, 255, 0.55)', 62]; 63 64/** Mirror of rust rope::Frame::new (local east/north at the launch dir). */ 65export function ropeFrame(lonDeg, latDeg, tiltDeg) { 66 const phi = lonDeg * Math.PI / 180, theta = latDeg * Math.PI / 180, gam = tiltDeg * Math.PI / 180; 67 const eDir = [Math.cos(theta) * Math.cos(phi), Math.cos(theta) * Math.sin(phi), Math.sin(theta)]; 68 let eE = [-eDir[1], eDir[0], 0]; // ê_N à ê_dir with ê_N = Ạ69 const n = Math.hypot(eE[0], eE[1]); 70 eE = n < 1e-9 ? [0, 1, 0] : [eE[0] / n, eE[1] / n, 0]; 71 const eN = [ 72 eDir[1] * eE[2] - eDir[2] * eE[1], 73 eDir[2] * eE[0] - eDir[0] * eE[2], 74 eDir[0] * eE[1] - eDir[1] * eE[0], 75 ]; 76 const eP = [ 77 Math.cos(gam) * eE[0] + Math.sin(gam) * eN[0], 78 Math.cos(gam) * eE[1] + Math.sin(gam) * eN[1], 79 Math.cos(gam) * eE[2] + Math.sin(gam) * eN[2], 80 ]; 81 const nHat = [ 82 eDir[1] * eP[2] - eDir[2] * eP[1], 83 eDir[2] * eP[0] - eDir[0] * eP[2], 84 eDir[0] * eP[1] - eDir[1] * eP[0], 85 ]; 86 return { eDir, eP, nHat }; 87} 88 89/** Mirror of rust kinematics::Dbm::apex_km (closed-form DBM). */ 90export function dbmApexKm(d0Km, v0Kms, wKms, gammaPerKm, tS) { 91 const dv0 = v0Kms - wKms; 92 if (Math.abs(gammaPerKm) < 1e-30 || dv0 === 0) return d0Km + v0Kms * tS; 93 const sgn = Math.sign(dv0); 94 return d0Km + wKms * tS + sgn * Math.log(1 + gammaPerKm * Math.abs(dv0) * tS) / gammaPerKm; 95} 96 97/** Ï_apex(d) mirror. */ 98export function sigmaApexKm(sigma1AuAu, dKm, nSigma) { 99 return sigma1AuAu * AU_KM * Math.pow(dKm / AU_KM, nSigma); 100} 101 102// ââ Shock mirrors (spec §14/§17 â pinned against fr_compression_x / 103// fr_standoff_fr / fr_v_ms_kms by tests/flux-rope-kernel-smoke.mjs) ââââââââ 104 105/** Fast-magnetosonic speed [km/s] (kinematics::V_MS_KMS mirror). */ 106export const V_MS_KMS = 70; 107 108/** RankineâHugoniot compression X(M), γ = 5/3, â 1 at M ⤠1, capped at 4. */ 109export function compressionX(mach) { 110 if (!(mach > 1)) return 1; 111 const m2 = mach * mach; 112 return Math.min((8 / 3) * m2 / ((2 / 3) * m2 + 2), 4); 113} 114 115/** FarrisâRussell standoff ratio FR(M), γ = 5/3, capped at 3 toward M â 1âº. */ 116export function standoffFr(mach) { 117 if (!(mach >
117 1)) return 3; 118 const m2 = mach * mach; 119 return Math.min(((2 / 3) * m2 + 2) / ((8 / 3) * (m2 - 1)), 3); 120} 121 122const VERT = `#version 300 es 123in vec2 a_pos; 124void main() { gl_Position = vec4(a_pos, 0.0, 1.0); } 125`; 126 127// True-3D raymarch of the train. World units: AU, z = ecliptic north. 128// Rope SDF and field math are verbatim ports of rope.rs (kernel = oracle). 129const FRAG = `#version 300 es 130precision highp float; 131out vec4 outColor; 132uniform vec2 u_res; 133uniform vec3 u_camPos; 134uniform vec3 u_camRight; 135uniform vec3 u_camUp; 136uniform vec3 u_camFwd; 137uniform float u_tanHalfFov; 138uniform int u_ropeCount; 139uniform vec3 u_eDir[${MAX_VIEW_ROPES}]; 140uniform vec3 u_eP[${MAX_VIEW_ROPES}]; 141uniform vec3 u_nHat[${MAX_VIEW_ROPES}]; 142uniform float u_dAu[${MAX_VIEW_ROPES}]; 143uniform float u_sigApexAu[${MAX_VIEW_ROPES}]; 144uniform float u_tPerAu[${MAX_VIEW_ROPES}]; 145uniform float u_bAxis[${MAX_VIEW_ROPES}]; 146uniform float u_hand[${MAX_VIEW_ROPES}]; 147uniform float u_profile[${MAX_VIEW_ROPES}]; 148uniform float u_bScale; 149// §15/§16/§18 boundary distortion + §14/§17 sheath, per rope (JS-cooked 150// per frame from kernel probes; all zeros/ones = the legacy circular view). 151uniform float u_frontC[${MAX_VIEW_ROPES}]; // static §15 lobe [0, 0.6] 152uniform float u_rearC[${MAX_VIEW_ROPES}]; // LIVE §16 squeeze via fr_rear_c_at [0, 0.75] 153uniform float u_pancakeA[${MAX_VIEW_ROPES}]; // §18 aspect (1 = circular) 154uniform float u_shK[${MAX_VIEW_ROPES}]; // §14 fixed-k thickness (0 = off/η mode) 155uniform float u_shEta[${MAX_VIEW_ROPES}]; // §17 η·FR(M), precooked (0 = off/k mode) 156uniform float u_shX[${MAX_VIEW_ROPES}]; // §14 X(M) compression â sheath brightness 157 158float j0poly(float x) { 159 float y = (x / 3.0) * (x / 3.0); 160 return 1.0 + y*(-2.2499997 + y*(1.2656208 + y*(-0.3163866 + y*(0.0444479 + y*(-0.0039444 + y*0.0002100))))); 161} 162float j1poly(float x) { 163 float y = (x / 3.0) * (x / 3.0); 164 return x * (0.5 + y*(-0.56249985 + y*(0.21093573 + y*(-0.03954289 + y*(0.00443319 + y*(-0.00031761 + y*0.00001109)))))); 165} 166 167// Signed distance to rope r's outermost renderable boundary at world point 168// p (negative = inside it), plus the local (psi, s, sig) for the field 169// sample and the distortion/sheath state. Mirrors rope.rs 170// boundary_distortion + sheath_at_dyn (kernel = oracle): 171// f(θ) = 1 â frontC·(1+cosθ)/2 â rearC·(1âcosθ)/2 (odd lobes) 172// g(θ) = 1/â(A·cos²θ + sin²θ/A) (§18, even) 173// Ï_eff = Ï·g·f, field maps via Å = s/(g·f), boost 1/f 174// shell: Ï_eff ⤠s < outer, FRONT side only (|p| > |axis point|), where 175// outer = Ï_eff·(1+k) or Ï_eff + η·FR(M)·â(Ï_eff·d/2). 176// shape/boost feed the field sample; shellFrac â (0,1] is the fractional 177// depth into the sheath (1 = at the shock front), 0 = not in the shell.
178float ropeSdf(int r, vec3 p, out float psi, out float s, out float sig, 179 out float shape, out float boost, out float shellFrac) { 180 float dAu = u_dAu[r]; 181 psi = 0.0; s = 1e9; sig = 0.0; shape = 1.0; boost = 1.0; shellFrac = 0.0; 182 if (dAu <= 0.0) return 1e9; 183 float halfD = 0.5 * dAu; 184 float u = dot(p, u_eDir[r]); 185 float w = dot(p, u_eP[r]); 186 float h = dot(p, u_nHat[r]); 187 float qu = u - halfD; 188 float rho = length(vec2(qu, w)); 189 if (rho < 1e-6) return 1e9; 190 psi = atan(w, -qu); 191 if (psi < 0.0) psi += 6.28318530718; 192 s = length(vec2(rho - halfD, h)); 193 float sinHalf = sin(0.5 * psi); 194 sig = u_sigApexAu[r] * sinHalf * sinHalf; 195 196 vec3 tHat = sin(psi) * u_eDir[r] + cos(psi) * u_eP[r]; 197 vec3 nPt = u_eDir[r] * (halfD + halfD * qu / rho) + u_eP[r] * (halfD * w / rho); 198 199 if ((u_frontC[r] > 0.0 || u_rearC[r] > 0.0 || u_pancakeA[r] > 1.0) && s > 1e-6) { 200 float nn = length(nPt); 201 if (nn > 1e-6) { 202 vec3 uHat = nPt / nn; 203 vec3 o = uHat - dot(uHat, tHat) * tHat; 204 float on = length(o); 205 if (on > 1e-6) { 206 float cosTh = dot((p - nPt) / s, o / on); 207 float f = 1.0 - u_frontC[r] * (1.0 + cosTh) * 0.5 208 - u_rearC[r] * (1.0 - cosTh) * 0.5; 209 f = max(f, 0.05); 210 float g = 1.0; 211 if (u_pancakeA[r] > 1.0) { 212 float c2 = cosTh * cosTh; 213 g = inversesqrt(u_pancakeA[r] * c2 + (1.0 - c2) / u_pancakeA[r]); 214 } 215 shape = g * f; 216 boost = 1.0 / f; 217 } 218 } 219 } 220 float sigEff = sig * shape; 221 222 // Sheath shell (shock on: k or η·FR precooked non-zero), front side only. 223 float outer = sigEff; 224 if (u_shK[r] > 0.0) { 225 outer = sigEff * (1.0 + u_shK[r]); 226 } else if (u_shEta[r] > 0.0) { 227 outer = sigEff + u_shEta[r] * sqrt(max(sigEff, 0.0) * halfD); 228 } 229 if (outer > sigEff && dot(p, p) > dot(nPt, nPt)) { 230 if (s >= sigEff && s < outer) { 231 shellFrac = (s - sigEff) / max(outer - sigEff, 1e-9); 232 } 233 return s - outer; 234 } 235 return s - sigEff; 236} 237 238// Local Bz (world-z component of the rope field) at p for rope r â the 239// color driver. Mirrors rope.rs field_at_dyn: the distorted boundary maps 240// onto the reference profile via Å = s/shape with a 1/f flux-conservation 241// boost (a §16-squeezed rear visibly strengthens â compression is field). 242float ropeBz(int r, vec3 p, float psi, float s, float sig, 243 float shape, float boost) { 244 float dAu = u_dAu[r]; 245 float halfD = 0.5 * dAu; 246 float u = dot(p, u_eDir[r]); 247 float w = dot(p, u_eP[r]); 248 float qu = u - halfD; 249 float rho = length(vec2(qu, w)); 250 vec3 tHat = sin(psi) * u_eDir[r] + cos(psi) * u_eP[r]; 251 vec3 nPt = u_eDir[r] * (halfD + halfD * qu / rho) + u_eP[r] * (halfD * w / rho); 252 vec3 rHat = (p - nPt) / max(s, 1e-9); 253 vec3 phiHat = cross(tHat, rHat); 254 float sRef = s / max(shape, 1e-6); 255 float bAx = u_bAxis[r] * boost; 256 float bAxial; float bPol; 257 if (u_profile[r] < 0.5) { 258 float ts = u_tPerAu[r] * sRef; 259 float denom = 1.0 + ts * ts; 260 bAxial = bAx / denom; 261 bPol = u_hand[r] * bAx * ts / denom; 262 } else { 263 float alpha = 2.4048255 / max(sig, 1e-6); 264 bAxial = bAx * j0poly(alpha * sRef); 265 bPol = u_hand[r] * bAx * j1poly(alpha * sRef); 266 } 267 return bAxial * tHat.z + bPol * phiHat.z; 268} 269 270// Distance from the ray to a point (for Sun/Earth glows), clamped behind. 271float rayPointDist(vec3 ro, vec3 rd, vec3 c, out float along) { 272 along = max(dot(c - ro, rd), 0.0); 273 return length(ro + rd * along - c); 274} 275 276void main() { 277 vec2 ndc = (gl_FragCoord.xy - 0.5 * u_res) / (0.5 * u_res.y); 278 vec3 rd = normalize(u_camFwd + u_tanHalfFov * (ndc.x * u_camRight + ndc.y * u_camUp)); 279 vec3 ro = u_camPos; 280 281 // Background vignette. 282 vec3 col = vec3(0.008, 0.004, 0.035) * (1.0 - 0.25 * length(ndc)); 283 284 // Ecliptic reference plane (z = 0): AU rings + 1 AU orbit, distance-faded. 285 float tPlane = -1.0; 286 vec3 gridCol = vec3(0.0); 287 if (abs(rd.z) > 1e-5) { 288 float tp = -ro.z / rd.z; 289 if (tp > 0.0) { 290 tPlane = tp; 291 vec2 pp = ro.xy + rd.xy * tp; 292 float rAu = length(pp); 293 if (rAu < 2.2) { 294 float ring = abs(fract(rAu / 0.25 + 0.5) - 0.5) * 0.25;
295 float fade = exp(-0.35 * tp) * smoothstep(2.2, 1.8, rAu); 296 gridCol += vec3(0.05, 0.08, 0.12) * smoothstep(0.004, 0.0, ring) * 0.5 * fade; 297 gridCol += vec3(0.10, 0.22, 0.30) * smoothstep(0.005, 0.0, abs(rAu - 1.0)) * 0.8 * fade; 298 } 299 } 300 } 301 302 // Raymarch the train: translucent shells, up to 4 composited hits 303 // (a shocked rope is TWO surfaces now â sheath, then the rope inside). 304 float t = 0.02; 305 float alpha = 0.0; 306 vec3 acc = vec3(0.0); 307 int hits = 0; 308 float firstHitT = 1e9; 309 for (int i = 0; i < 96; i++) { 310 if (t > 6.0 || hits >= 4 || alpha > 0.85) break; 311 vec3 p = ro + rd * t; 312 float dMin = 1e9; 313 int rHit = -1; 314 float psiH; float sH; float sigH; float shapeH; float boostH; float shellH; 315 for (int r = 0; r < ${MAX_VIEW_ROPES}; r++) { 316 if (r >= u_ropeCount) break; 317 float psi_; float s_; float sig_; float shp_; float bst_; float shl_; 318 float d = ropeSdf(r, p, psi_, s_, sig_, shp_, bst_, shl_); 319 if (d < dMin) { 320 dMin = d; rHit = r; psiH = psi_; sH = s_; sigH = sig_; 321 shapeH = shp_; boostH = bst_; shellH = shl_; 322 } 323 } 324 if (dMin < 0.002) { 325 firstHitT = min(firstHitT, t); 326 float sigEff = max(sigH * shapeH, 1e-6); 327 if (sH >= sigEff) { 328 // §14/§17 SHEATH: the SHOCK FRONT is the bright surface (rim 329 // at the outer edge, brightness â X(M)); the shell interior 330 // stays faint so the rope's field colors read through it. 331 float xn = clamp((u_shX[rHit] - 1.0) / 3.0, 0.0, 1.0); 332 float rim = smoothstep(0.60, 0.97, shellH); 333 vec3 shCol = mix(vec3(0.75, 0.48, 0.28), vec3(1.0, 0.93, 0.78), rim) 334 * (0.30 + 0.70 * xn); 335 float a = (0.045 + 0.075 * xn + 0.24 * rim) * (1.0 - alpha); 336 acc += shCol * a; 337 alpha += a; 338 hits++; 339 // Step through the shell toward the rope surface beneath. 340 t += max(0.02, (sH - sigEff) * 0.9); 341 } else { 342 float bz = ropeBz(rHit, p, psiH, sH, max(sigH, 1e-6), shapeH, boostH); 343 float mag = clamp(abs(bz) / max(u_bScale, 1e-6), 0.0, 1.2); 344 vec3 south = vec3(0.95, 0.30, 0.18); 345 vec3 north = vec3(0.15, 0.65, 0.95); 346 vec3 fieldCol = (bz < 0.0 ? south : north) * (0.30 + 0.70 * mag); 347 // Rim brightening against the DISTORTED boundary, plus a warm 348 // squeeze glow where a lobe is actively compressing (boost>1). 349 float rim = smoothstep(0.85, 1.0, sH / sigEff); 350 fieldCol += vec3(0.55, 0.7, 0.9) * rim * 0.35; 351 fieldCol += vec3(1.0, 0.62, 0.25) * clamp(boostH - 1.0, 0.0, 1.5) * 0.30; 352 float a = 0.42 * (1.0 - alpha); 353 acc += fieldCol * a; 354 alpha += a; 355 hits++; 356 // Step THROUGH the rope to catch the far side / next rope. 357 t += max(0.06, sigEff * 0.7); 358 } 359 } else { 360 t += max(dMin * 0.8, 0.004); 361 } 362 } 363 364 // Composite: grid behind ropes, glows on top. 365 if (tPlane > 0.0 && tPlane < firstHitT) col += gridCol; 366 col = col * (1.0 - alpha) + acc; 367 if (tPlane > 0.0 && tPlane >= firstHitT) col += gridCol * 0.25; 368 369 float along; 370 float dSun = rayPointDist(ro, rd, vec3(0.0), along); 371 if (along < firstHitT + 0.05) { 372 col += vec3(1.0, 0.85, 0.45) * exp(-dSun * dSun / 0.0016); 373 col += vec3(1.0, 0.97, 0.88) * exp(-dSun * dSun / 0.00012); 374 } 375 float dEarth = rayPointDist(ro, rd, vec3(1.0, 0.0, 0.0), along); 376 if (along < firstHitT + 0.05) { 377 col += vec3(0.25, 0.55, 1.0) * exp(-dEarth * dEarth / 0.00025); 378 col += vec3(0.8, 0.92, 1.0) * exp(-dEarth * dEarth / 0.000018); 379 } 380 381 outColor = vec4(col, 1.0); 382} 383`; 384 385export class HeliosphereView { 386 /** 387 * @param {HTMLCanvasElement} glCanvas shader layer (receives pointer input) 388 * @param {HTMLCanvasElement} overlay 2D annotation layer (pointer-events:none) 389 */ 390 constructor(glCanvas, overlay) { 391 this.glCanvas = glCanvas;
392 this.overlay = overlay; 393 this.ropes = []; 394 this.frames = []; 395 this.ensemble = null; 396 this.weights = null; // particle-filter weights (fade spaghetti) 397 // Orbit camera: z = ecliptic north. Start tilted so depth reads 398 // immediately; users orbit freely from here. 399 this.cam = { yaw: -0.35, pitch: 0.95, dist: 2.1, target: [0.5, 0, 0.02] }; 400 this._defaultCam = { ...this.cam, target: [...this.cam.target] }; 401 this.fovDeg = 42; 402 // WebGL2 (GLSL ES 3.00): required for dynamic uniform-array indexing 403 // inside the raymarch helpers. No WebGL1 fallback â without it the 404 // shader layer stays dark and the 2D overlay still annotates. 405 this.gl = glCanvas.getContext('webgl2', { antialias: true, alpha: false }); 406 if (this.gl) this._initGl(); 407 this._wireInput(); 408 } 409 410 _initGl() { 411 const gl = this.gl; 412 const compile = (type, src) => { 413 const sh = gl.createShader(type); 414 gl.shaderSource(sh, src); 415 gl.compileShader(sh); 416 if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { 417 throw new Error(`flux-rope shader: ${gl.getShaderInfoLog(sh)}`); 418 } 419 return sh; 420 }; 421 const prog = gl.createProgram(); 422 gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT)); 423 gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG)); 424 gl.linkProgram(prog); 425 if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { 426 throw new Error(`flux-rope link: ${gl.getProgramInfoLog(prog)}`); 427 } 428 gl.useProgram(prog); 429 const buf = gl.createBuffer(); 430 gl.bindBuffer(gl.ARRAY_BUFFER, buf); 431 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); 432 const loc = gl.getAttribLocation(prog, 'a_pos'); 433 gl.enableVertexAttribArray(loc); 434 gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); 435 this.u = {}; 436 for (const name of ['u_res', 'u_camPos', 'u_camRight', 'u_camUp', 'u_camFwd', 437 'u_tanHalfFov', 'u_ropeCount', 'u_bScale', 438 'u_eDir', 'u_eP', 'u_nHat', 'u_dAu', 'u_sigApexAu', 'u_tPerAu', 439 'u_bAxis', 'u_hand', 'u_profile', 440 'u_frontC', 'u_rearC', 'u_pancakeA', 'u_shK', 'u_shEta', 'u_shX']) { 441 this.u[name] = gl.getUniformLocation(prog, name); 442 } 443 } 444 445 _wireInput() { 446 const c = this.glCanvas; 447 let drag = null; 448 c.style.cursor = 'grab'; 449 c.addEventListener('pointerdown', (e) => { 450 drag = { x: e.clientX, y: e.clientY }; 451 c.setPointerCapture(e.pointerId); 452 c.style.cursor = 'grabbing'; 453 }); 454 c.addEventListener('pointermove', (e) => { 455 if (!drag) return; 456 this.cam.yaw -= (e.clientX - drag.x) * 0.006; 457 this.cam.pitch = Math.min(1.45, Math.max(-1.45, this.cam.pitch + (e.clientY - drag.y) * 0.006)); 458 drag = { x: e.clientX, y: e.clientY }; 459 }); 460 const end = () => { drag = null; c.style.cursor = 'grab'; }; 461 c.addEventListener('pointerup', end); 462 c.addEventListener('pointercancel', end); 463 c.addEventListener('wheel', (e) => { 464 e.preventDefault(); 465 this.cam.dist = Math.min(4.5, Math.max(0.5, this.cam.dist * Math.exp(e.deltaY * 0.001))); 466 }, { passive: false }); 467 c.addEventListener('dblclick', () => { 468 this.cam = { ...this._defaultCam, target: [...this._defaultCam.target] }; 469 }); 470 } 471 472 /** Camera basis (right-handed, z-up world). */ 473 _camera() { 474 const { yaw, pitch, dist, target } = this.cam; 475 const cp = Math.cos(pitch), sp = Math.sin(pitch); 476 const pos = [ 477 target[0] + dist * cp * Math.cos(yaw), 478 target[1] + dist * cp * Math.sin(yaw), 479 target[2] + dist * sp, 480 ]; 481 let fwd = [target[0] - pos[0], target[1] - pos[1], target[2] - pos[2]]; 482 const fl = Math.hypot(...fwd); 483 fwd = fwd.map((v) => v / fl); 484 let right = [fwd[1] * 1 - fwd[2] * 0, fwd[2] * 0 - fwd[0] * 1, fwd[0] * 0 - fwd[1] * 0]; // fwd à Ạ485 const rl = Math.hypot(...right) || 1; 486 right = right.map((v) => v / rl); 487 const up = [ 488 right[1] * fwd[2] - right[2] * fwd[1], 489 right[2] * fwd[0] - right[0] * fwd[2], 490 right[0] * fwd[1] - right[1] * fwd[0], 491 ]; 492 return { pos, right, up, fwd, tanHalfFov: Math.tan(this.fovDeg * Math.PI / 360) }; 493 } 494 495 /** World (AU) â overlay pixel through the SAME camera. null = behind. */ 496 _project(p, cam) { 497 const v = [p[0] - cam.pos[0], p[1] - cam.pos[1], p[2] - cam.pos[2]]; 498 const z = v[0] * cam.fwd[0] + v[1] * cam.fwd[1] + v[2] * cam.fwd[2]; 499 if (z <= 0.01) return null; 500 const x = v[0] * cam.right[0] + v[1] * cam.right[1] + v[2] * cam.right[2]; 501 const y = v[0] * cam.up[0] + v[1] * cam.up[1] + v[2] * cam.up[2]; 502 const halfH = 0.5 * this.overlay.height; 503 const s = halfH / (cam.tanHalfFov * z); 504 return [0.5 * this.overlay.width + x * s, halfH - y * s]; 505 } 506 507 setRopes(ropes) { 508 this.ropes = ropes.slice(0, MAX_VIEW_ROPES); 509 this.frames = this.ropes.map((r) => ropeFrame(r.lonDeg, r.latDeg, r.tiltDeg)); 510 } 511 512 setRope(rope) { 513 this.setRopes([{ launchOffsetS: 0, ...rope }]); 514 } 515 516 setEnsemble(ens) { 517 this.ensemble = ens; 518 this.weights = null; 519 } 520 521 /** Particle-filter weights (Float32Array, per member) â fades spaghetti. */ 522 setWeights(w) { 523 this.weights = w; 524 } 525 526 /** Auxiliary-observer marker (STEREO-A): world [x,y,z] AU, or null. */ 527 setStaMarker(p) { 528 this.staPos = p; 529 } 530 531 resize() { 532 const dpr = Math.min(window.devicePixelRatio || 1, 2); 533 for (const c of [this.glCanvas, this.overlay]) {
534 const w = Math.round(c.clientWidth * dpr), h = Math.round(c.clientHeight * dpr); 535 if (c.width !== w || c.height !== h) { c.width = w; c.height = h; } 536 } 537 } 538 539 /** 540 * Render at t seconds after the reference epoch. `probes` carries kernel 541 * truth PER ROPE, aligned with setRopes order: 542 * { apexKm, sigmaApexKm, â position + size (required) 543 * rearC?, â LIVE §16 squeeze (fr_rear_c_at) 544 * apexVKms?, upstreamKms? } â wake-conditioned shock Mach inputs 545 * The optional fields default to the legacy circular, sheathless view. 546 */ 547 draw(tS, probes) { 548 this.resize(); 549 const gl = this.gl; 550 const cam = this._camera(); 551 if (gl && this.ropes.length) { 552 gl.viewport(0, 0, this.glCanvas.width, this.glCanvas.height); 553 const N = MAX_VIEW_ROPES; 554 const eDir = new Float32Array(3 * N), eP = new Float32Array(3 * N), nHat = new Float32Array(3 * N); 555 const dAu = new Float32Array(N), sig = new Float32Array(N), tPer = new Float32Array(N); 556 const bAx = new Float32Array(N), hand = new Float32Array(N), prof = new Float32Array(N); 557 const frontC = new Float32Array(N), rearC = new Float32Array(N); 558 const pancake = new Float32Array(N).fill(1), shX = new Float32Array(N).fill(1); 559 const shK = new Float32Array(N), shEta = new Float32Array(N); 560 let bScale = 1; 561 this.ropes.forEach((rope, r) => { 562 const pr = probes[r] || { apexKm: 0, sigmaApexKm: 0 }; 563 const launched = tS > (rope.launchOffsetS || 0); 564 const d = launched ? pr.apexKm / AU_KM : 0; 565 eDir.set(this.frames[r].eDir, 3 * r); 566 eP.set(this.frames[r].eP, 3 * r); 567 nHat.set(this.frames[r].nHat, 3 * r); 568 dAu[r] = d; 569 sig[r] = pr.sigmaApexKm / AU_KM; 570 tPer[r] = d > 0 ? 2 * rope.twistTurns / d : 0; 571 bAx[r] = d > 0 ? rope.b1AuNt * Math.pow(d, -rope.nB) : 0; 572 hand[r] = rope.handedness; 573 prof[r] = rope.profile === 'lundquist' ? 1 : 0; 574 if (d > 0) bScale = Math.max(bScale, bAx[r]); 575 // §15/§16/§18 boundary distortion (clamps mirror the kernel's). 576 frontC[r] = Math.min(Math.max(rope.frontC ?? 0, 0), 0.6); 577 rearC[r] = launched ? Math.min(Math.max(pr.rearC ?? 0, 0), 0.75) : 0; 578 pancake[r] = Math.max(rope.pancakeA ?? 1, 1); 579 // §14/§17 sheath: exists only with δ > 0, a thickness model, 580 // and a super-magnetosonic apex against its (wake) upstream. 581 const deltaNt = rope.sheathDeltaNt ?? 0; 582 const mach = launched && Number.isFinite(pr.apexVKms) && Number.isFinite(pr.upstreamKms) 583 ? (pr.apexVKms - pr.upstreamKms) / V_MS_KMS : 0; 584 if (deltaNt > 0 && mach > 1) { 585 if ((rope.sheathEta ?? 0) > 0) shEta[r] = rope.sheathEta * standoffFr(mach); 586 else if ((rope.sheathK ?? 0) > 0) shK[r] = rope.sheathK; 587 if (shEta[r] > 0 || shK[r] > 0) shX[r] = compressionX(mach); 588 } 589 }); 590 gl.uniform2f(this.u.u_res, this.glCanvas.width, this.glCanvas.height); 591 gl.uniform3fv(this.u.u_camPos, cam.pos); 592 gl.uniform3fv(this.u.u_camRight, cam.right); 593 gl.uniform3fv(this.u.u_camUp, cam.up); 594 gl.uniform3fv(this.u.u_camFwd, cam.fwd); 595 gl.uniform1f(this.u.u_tanHalfFov, cam.tanHalfFov); 596 gl.uniform1i(this.u.u_ropeCount, this.ropes.length); 597 gl.uniform1f(this.u.u_bScale, bScale); 598 gl.uniform3fv(this.u.u_eDir, eDir); 599 gl.uniform3fv(this.u.u_eP, eP); 600 gl.uniform3fv(this.u.u_nHat, nHat); 601 gl.uniform1fv(this.u.u_dAu, dAu); 602 gl.uniform1fv(this.u.u_sigApexAu, sig); 603 gl.uniform1fv(this.u.u_tPerAu, tPer); 604 gl.uniform1fv(this.u.u_bAxis, bAx); 605 gl.uniform1fv(this.u.u_hand, hand); 606 gl.uniform1fv(this.u.u_profile, prof); 607 gl.uniform1fv(this.u.u_frontC, frontC); 608 gl.uniform1fv(this.u.u_rearC, rearC); 609 gl.uniform1fv(this.u.u_pancakeA, pancake); 610 gl.uniform1fv(this.u.u_shK, shK); 611 gl.uniform1fv(this.u.u_shEta, shEta); 612 gl.uniform1fv(this.u.u_shX, shX); 613 gl.drawArrays(gl.TRIANGLES, 0, 3); 614 } 615 this._drawOverlay(tS, cam); 616 } 617 618 _axisPath(ctx, frame, dAu, cam) { 619 ctx.beginPath(); 620 let pen = false;
621 for (let i = 0; i <= 72; i++) { 622 const psi = i / 72 * 2 * Math.PI; 623 const half = dAu / 2; 624 const p = [ 625 half * (1 - Math.cos(psi)) * frame.eDir[0] + half * Math.sin(psi) * frame.eP[0], 626 half * (1 - Math.cos(psi)) * frame.eDir[1] + half * Math.sin(psi) * frame.eP[1], 627 half * (1 - Math.cos(psi)) * frame.eDir[2] + half * Math.sin(psi) * frame.eP[2], 628 ]; 629 const s = this._project(p, cam); 630 if (!s) { pen = false; continue; } 631 if (!pen) { ctx.moveTo(s[0], s[1]); pen = true; } else ctx.lineTo(s[0], s[1]); 632 } 633 } 634 635 _drawOverlay(tS, cam) { 636 const ctx = this.overlay.getContext('2d'); 637 const { width: w, height: h } = this.overlay; 638 ctx.clearRect(0, 0, w, h); 639 const dpr = Math.min(window.devicePixelRatio || 1, 2); 640 641 // Ensemble envelope: member axis circles through the 3D camera, 642 // alpha â particle-filter weight when assimilation is active. 643 const ens = this.ensemble; 644 if (ens && this.ropes.length && ens.members > 0) { 645 const stride = ens.memberStride; 646 const R = ens.ropesPerMember || 1; 647 const maxDraw = 48; 648 const skip = Math.max(1, Math.floor(ens.members / maxDraw)); 649 ctx.lineWidth = dpr; 650 const wSum = this.weights ? this.weights.reduce((a, b) => a + b, 0) : 0; 651 for (let m = 0; m < ens.members; m += skip) { 652 // Uniform prior alpha 0.05; weighted: scale by w·members 653 // (equal-weight â same 0.05; upweighted members brighten, 654 // killed members vanish â the filter made visible). 655 let a = 0.05; 656 if (this.weights && wSum > 0) { 657 a = Math.min(0.5, 0.05 * this.weights[m] * ens.members); 658 } 659 if (a < 0.004) continue; 660 ctx.strokeStyle = `rgba(120, 210, 255, ${a.toFixed(3)})`; 661 for (let r = 0; r < Math.min(R, this.ropes.length); r++) { 662 const rope = this.ropes[r]; 663 const dt = tS - (rope.launchOffsetS || 0); 664 if (dt <= 0) continue; 665 const o = (m * R + r) * stride; 666 const [lon, lat, tilt, v0, gam] = ens.memberParams.slice(o, o + 5); 667 const fr = ropeFrame(lon, lat, tilt); 668 const dAuM = dbmApexKm(rope.d0Rsun * RSUN_KM, v0, rope.wKms, gam, dt) / AU_KM; 669 if (!(dAuM > 0)) continue; 670 this._axisPath(ctx, fr, dAuM, cam); 671 ctx.stroke(); 672 } 673 } 674 } 675 676 // Median rope axes, brighter, one accent per rope of the train. 677 this.ropes.forEach((rope, r) => { 678 const dt = tS - (rope.launchOffsetS || 0); 679 if (dt <= 0) return; 680 const dAu = dbmApexKm(rope.d0Rsun * RSUN_KM, rope.v0Kms, rope.wKms, rope.gammaPerKm, dt) / AU_KM; 681 this._axisPath(ctx, this.frames[r], dAu, cam); 682 ctx.strokeStyle = ROPE_STROKES[r % ROPE_STROKES.length]; 683 ctx.lineWidth = 1.5 * dpr; 684 ctx.stroke(); 685 }); 686 687 // Labels through the same camera. 688 ctx.font = `${11 * dpr}px system-ui, sans-serif`; 689 const label = (p, text, color = 'rgba(180, 195, 220, 0.85)') => { 690 const s = this._project(p, cam); 691 if (!s) return; 692 ctx.fillStyle = color; 693 ctx.fillText(text, s[0] + 8 * dpr, s[1] - 8 * dpr); 694 }; 695 label([0, 0, 0], 'Sun'); 696 label([1, 0, 0], 'Earth · L1'); 697 label([0.5, 0, 0], '0.5 AU', 'rgba(140, 155, 185, 0.5)'); 698 if (this.staPos) { 699 const s = this._project(this.staPos, cam); 700 if (s) { 701 // Diamond marker â the off-SunâEarth-line constraint. 702 ctx.save(); 703 ctx.translate(s[0], s[1]); 704 ctx.rotate(Math.PI / 4); 705 ctx.fillStyle = 'rgba(127, 230, 195, 0.9)'; 706 ctx.fillRect(-3.5 * dpr, -3.5 * dpr, 7 * dpr, 7 * dpr);
707 ctx.restore(); 708 ctx.fillStyle = 'rgba(127, 230, 195, 0.85)'; 709 ctx.fillText('STEREO-A', s[0] + 8 * dpr, s[1] - 8 * dpr); 710 } 711 } 712 ctx.fillStyle = 'rgba(120, 132, 160, 0.55)'; 713 ctx.fillText('drag to orbit · scroll to zoom · double-click to reset', 714 10 * dpr, h - 10 * dpr); 715 } 716}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.