PageSourceSearch

https://parkersphysics.com/js/flux-rope/view.js

js parkersphysics.com collected 2026-10-09 22:05:08 UTC 32,202 bytes, 716 lines download raw bytes

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.