🪐 resonance: music of the spheres — planets as modulators (Stage 1)
Vendor SOLARGOD@6391273's dependency-free JPL ephemeris (viz/spheres.js) and
make the solar system a modulation source, computed locally at the instrument's
own clock — no network, no server, no sockets.
- viz/spheres.js: ephem.js verbatim + the minimal lib subset inlined; loaded via
a <script type="module"> bridge that self-checks (Mars @ JD 2461236.5 within
0.005 AU of Horizons ground truth) and hangs a frozen window.SPHERES; every
consumer guarded so a missing/broken file still boots the app.
- sphere.<planet>.{phase,dist,beat} sources for the 8 planets, routable like any
S&H source; the great-year clock maps 1 bar = N days of sky (log knob 1..3650,
default 30) off seqPhase alone, so scrub/tempo/reload reproduce the same sky.
- 🪐 spheres panel (S / right-click sky): per-planet live meters, rate knob,
☸ orbital-ratios button (drum wheels → 4:8:16 Io:Europa:Ganymede, 8:13
Venus:Earth pentagram), on/off; godspeak: spheres on/off·faster/slower,
"<planet> drive <dest>". spheres {on,rate} travel in the patch.
- grimoire chapter + regenerated viz/manual.html.
Verified: self-check green; determinism (jupiter.phase@bar16 identical across
reload); pentagram (venus.beat period 1.59 bars ≈ 583.9/365, within 2%); ratios
set exact wlens and ⌘Z reverts; zero console errors; ♪ T O B G + ⌘Z all work;
spheres default off on fresh boot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5b0fffd75c
commit
925617d782
242
viz/index.html
242
viz/index.html
@ -370,6 +370,32 @@
|
||||
#godspeed input[type=range] { width: 88px; accent-color: #7fa0e6; height: 3px; }
|
||||
#godspeed .held { color: #bcd; font-size: 11px; letter-spacing: 0.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#godspeed .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
|
||||
/* 🪐 RESONANCE — the spheres panel (right-anchored, mirrors #godspeed) */
|
||||
#spheres {
|
||||
position: fixed; right: 12px; top: 64px; width: 272px; z-index: 13;
|
||||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||||
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
|
||||
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||||
max-height: 80vh; overflow-y: auto;
|
||||
}
|
||||
#spheres.open { display: flex; flex-direction: column; gap: 6px; }
|
||||
#spheres .atop { display: flex; align-items: center; gap: 8px; }
|
||||
#spheres .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 1; }
|
||||
#spheres .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
|
||||
#spheres .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
#spheres .glbl { color: rgba(160,180,215,0.8); font-size: 10px; min-width: 30px; }
|
||||
#spheres .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
|
||||
border-radius: 7px; padding: 3px 9px; cursor: pointer; font-size: 11px; }
|
||||
#spheres .midibtn.on { background: rgba(120,220,150,0.22); color: #bff0c9; border-color: rgba(130,230,160,0.55); }
|
||||
#spheres .bmini { padding: 2px 7px; }
|
||||
#spheres input[type=range] { flex: 1; min-width: 90px; accent-color: #7fa0e6; height: 3px; }
|
||||
#spheres .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
|
||||
#spheres .phead, #spheres .prow { display: grid; grid-template-columns: 56px 1fr 1fr 1fr; gap: 4px; align-items: center; }
|
||||
#spheres .phead { color: rgba(160,180,215,0.7); font-size: 9px; letter-spacing: 0.3px; padding: 0 1px; }
|
||||
#spheres .pname { color: #bcd; font-size: 10px; letter-spacing: 0.3px; }
|
||||
#spheres .meter { position: relative; height: 12px; border-radius: 3px; background: rgba(60,80,120,0.22); overflow: hidden; }
|
||||
#spheres .meter > i { position: absolute; left: 0; top: 0; bottom: 0; background: rgba(127,160,230,0.5); transition: width 0.08s linear; }
|
||||
#spheres .meter > b { position: absolute; inset: 0; font-size: 8px; font-weight: 400; color: #d4dcee; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
/* 🗣 GODSPEAK — a small "listening" chip near the ♪ button, pulses while the mic is held */
|
||||
#godspeakrec { position: fixed; top: 12px; right: 92px; z-index: 40; width: 30px; height: 30px;
|
||||
@ -1208,6 +1234,7 @@
|
||||
<li><b>☸ WHEELS — a wheel within a wheel.</b> <i>"…their work was as it were a wheel in the middle of a wheel"</i> (Ezekiel 1:16). Each lane keeps its own length: set the <b>☸ wheel</b> chip in a beat lane (or the arranger's euclid row) to anything from 1 to 16, and a lane shorter than the full sixteen <b>wraps on its own turn</b>. A hat of twelve over a kick of sixteen drift apart and only realign every forty-eight steps — <b>polymeter</b>, the oldest trick for making a loop that never quite repeats. Steps past the wheel dim out; the playhead rides each lane's own length; and the pattern <b>exports to MIDI exactly as it plays</b>, twelve-wheel and all. A full wheel of sixteen behaves precisely as it always did.</li>
|
||||
<li><b>🕊 PSALM — sing, and the instrument answers.</b> Right-click the sky → <b>🕊 PSALM</b>, allow the microphone, and <b>sing</b>. The pitch is heard, <b>folded to the scale you're in</b> (so you're never out of key), and played as notes — a <b>🕊</b> chip shows the note leaving your throat. And the duet with <b>⚡ GODSPEED</b>: with the arp running, each note you sing <b>latches into the held chord</b>, one at a time — hum four notes and the machine arpeggiates the chord you sang. <b>It listens locally</b>: the analysis happens in the page, in its own silence, and <i>nothing ever leaves the machine</i> — no recording, no upload, just your voice becoming the instrument's.</li>
|
||||
<li><b>✦ CANON — the shelf of worked examples.</b> Open <b>⚙ → DIMENSIONS</b> and at the top of the list, above your own saved worlds, sit four <b>✦ factory dimensions</b> that ship with the instrument — <i>the heartbeat</i> (a boom-bap kit with the swing and the kick's duck), <i>the wheel within the wheel</i> (WHEELS, made audible), <i>the squeeze</i> (the market pumping GODSQUASH), and <i>quiet arrival</i> (the earth echo, slow and sparse). Load one to hear a feature in its element, then save your own version under a new name — the ✦ ones are <b>read-only</b>, the same for everyone, a place to start rather than a thing to overwrite.</li>
|
||||
<li><b>🪐 spheres — the music of the spheres.</b> Press <kbd>S</kbd> (or right-click the sky → <b>🪐 spheres</b>) and turn it <b>on</b>: the eight planets join the field as <b>modulation sources</b>, the slowest LFOs there are. Each planet offers three signals — <b>phase</b> (its heliocentric longitude, a sawtooth one full turn per orbit), <b>dist</b> (its eccentric breathing, in and out between perihelion and aphelion — Mercury and Mars swing hard, Venus barely), and <b>beat</b> (its <i>synodic</i> dance against Earth — Venus and Earth clasp every 583.9 days, the pentagram cycle). Route any of them like any source, or just say <i>"mars drive filter."</i> One knob rules time: the <b>rate</b>, from <b>1 to 3650 days of sky per bar</b> — at the default thirty, Venus' year is about seven and a half bars and Jupiter's a hundred and forty-four, real orbits compressed into slow musical arcs. And the <b>☸ orbital ratios</b> button sets the drum lanes' wheels to the true resonances — <b>4 : 8 : 16</b> (Io, Europa and Ganymede, locked at 1 : 2 : 4) and <b>8 : 13</b> (Venus to Earth, the pentagram in wheel form), so the polymeter you hear is one the solar system actually keeps. It is all <b>computed here, at the instrument's own clock</b> — the same JPL ephemeris SOLARGOD uses, vendored in, no network and no server, so the sky is deterministic: the same bar always shows the same heaven, and a saved patch reloads the identical sky.</li>
|
||||
<li><b>📽 The stage — send the visual to any screen.</b> Right-click the sky → <b>the stage</b> and a clean black mirror window opens, fed live from the canvas. Drag it to a projector, an AirPlay display, a second monitor — macOS treats it like any window — and <b>double-click for fullscreen</b>. The stage has a <b>camera</b>: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → <b>project this node</b> and the camera glides after that one sphere through its orbits, or <b>project its station</b> to frame a whole wheel and its satellites. Change cameras live from the menus while the projector runs; the audience sees the camera breathe from subject to subject.</li>
|
||||
<li><b>🕸 The commune — play together, properly.</b> Right-click the sky → <b>the commune</b>. This is for <b>signed-in players</b>: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you <b>hosts</b>: name the room, then <b>✉ invite</b> the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and <b>join or decline</b>; anyone may <b>leave</b> whenever they wish, and if the host leaves, the room closes with them. Then the sections: members <b>claim voices</b> (right-click any voice, or the panel's picker), or the <b>host allocates</b> them — this bass to her, those pads to him, <b>the godtime itself</b> to whoever conducts — and the hub enforces it: only the section's holder can speak for it. From that moment your levels, mutes, grooves and arranger lanes for what you hold follow your hands on every screen, same living room or opposite sides of the earth. The world — the Source — stays identical for everyone, because it always was. Three people, one planet, one instrument.</li></ul>
|
||||
|
||||
@ -1378,6 +1405,29 @@
|
||||
<p class="foot">So: the sun holds the filter, the earthquake opens the void, bitcoin sings the lead and Delhi's air turns it to grit, every human edit on Earth flickers as a glitch, and the Moon walks slowly through the garden weighting the drone. You did not build these forces — they were already playing. What you built is the <i>listening</i>: a cable dragged here, an amount leaned there, a tempo locked to tonight's true sky. Wake the sound with <b>♪</b>, conduct with the godtime dial, hide the chrome with <kbd>P</kbd>, and save what you've made to carry it anywhere. The instrument was always going to play. Now it plays <i>you back</i>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 🪐 RESONANCE — the vendored ephemeris bridge. spheres.js is an ES module;
|
||||
the app below is one classic IIFE. Import it here (deferred, after parse)
|
||||
and hang a frozen window.SPHERES the IIFE reads lazily each frame. A
|
||||
self-check on load gates the feature; a missing/broken file simply leaves
|
||||
window.SPHERES undefined and every consumer is guarded, so the app boots. -->
|
||||
<script type="module">
|
||||
try {
|
||||
const S = await import("./spheres.js");
|
||||
// self-check (house style, like gsGrooveSelfCheck): Mars @ JD 2461236.5 must
|
||||
// land within 0.005 AU of the wave-1 Horizons ground truth, else disable.
|
||||
const m = S.helioEcl("mars", 2461236.5, {});
|
||||
const gt = { x: 1.05351, y: 1.00863, z: -0.00470 };
|
||||
const err = Math.hypot(m.x - gt.x, m.y - gt.y, m.z - gt.z);
|
||||
const ok = err < 0.005;
|
||||
if (!ok) console.error("🪐 SPHERES: ephemeris self-check FAILED (Mars off by " + err.toFixed(5) + " AU > 0.005) — spheres disabled.");
|
||||
window.SPHERES = Object.freeze({
|
||||
helioEcl: S.helioEcl, orbitPath: S.orbitPath, semiMajor: S.semiMajor,
|
||||
PLANET_IDS: S.PLANET_IDS, J2000_JD: S.J2000_JD, jdFromUnixMs: S.jdFromUnixMs, ok,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("🪐 SPHERES: could not load spheres.js — the feature stays off, the app runs on.", e);
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
"use strict";
|
||||
(function () {
|
||||
@ -1500,7 +1550,7 @@
|
||||
const p = (key || "").split(".")[0].toLowerCase();
|
||||
if (["tof","light","hand","audio","mic","touch","room","cam","body"].includes(p)) return "body";
|
||||
if (["weather","air","quake","seismic","tide","temp"].includes(p)) return "planet";
|
||||
if (["sun","iss","planes","plane","sky","aurora","moon","space","natal"].includes(p)) return "sky";
|
||||
if (["sun","iss","planes","plane","sky","aurora","moon","space","natal","sphere"].includes(p)) return "sky"; // 🪐 the spheres join sky & space
|
||||
if (["crypto","market","stock","btc","eth","fx","me"].includes(p)) return "markets";
|
||||
if (["wiki","net","news","social","edit","syn"].includes(p)) return "human";
|
||||
return "other";
|
||||
@ -1657,6 +1707,24 @@
|
||||
// "Quantize in time" — the rhythmic sibling of the matrix's quantize-to-scale.
|
||||
const seqs = {}; // destKey -> {on, steps[16]} (notes: -1|row, CC: 0|1)
|
||||
let seqPhase = 0, seqCurStep = 0, seqAbsStep = 0, seqGridRefresh = null; // seqAbsStep: monotonic 16th counter for ☸ WHEELS polymeter
|
||||
// 🪐 RESONANCE — the solar system as a modulation source. spheres.js (vendored
|
||||
// SOLARGOD ephemeris) computes heliocentric positions; here each planet is an
|
||||
// LFO on the GREAT-YEAR clock: sky time = J2000 + (bars elapsed)·rate days,
|
||||
// derived only from seqPhase, so scrub / tempo / patch-reload reproduce the
|
||||
// same sky, deterministically. Sources are `sphere.<planet>.<phase|dist|beat>`.
|
||||
const spheres = { on: false, rate: 30 }; // rate = days of sky per bar (log knob)
|
||||
const SPHERE_PLANETS = ["mercury", "venus", "earth", "mars", "jupiter", "saturn", "uranus", "neptune"];
|
||||
const SPHERE_SIGNALS = ["phase", "dist", "beat"];
|
||||
const SPHERE_GLYPH = { mercury: "☿", venus: "♀", earth: "♁", mars: "♂", jupiter: "♃", saturn: "♄", uranus: "♅", neptune: "♆" };
|
||||
const SPHERE_RATE_MIN = 1, SPHERE_RATE_MAX = 3650; // days/bar, log-scaled ∈ [1, 3650]
|
||||
const spheresEngineOK = () => !!(window.SPHERES && window.SPHERES.ok);
|
||||
let spheresRanges = null; // per-planet perihelion..aphelion, computed once from orbitPath
|
||||
const SPHERE_DEST_WORDS = [ // godspeak "<planet> drive <word>" → a dest key
|
||||
["cutoff", "filter.cutoff"], ["filter", "filter.cutoff"], ["reverb", "reverb.size"], ["delay", "delay.feedback"],
|
||||
["lfo", "lfo.rate"], ["drone", "drone.voices"], ["bright", "pad.brightness"], ["pad", "pad.brightness"],
|
||||
["glitch", "glitch"], ["crush", "crush"], ["squash", "squash"], ["space", "master.space"],
|
||||
["saturation", "saturation"], ["grit", "saturation"], ["morph", "wavetable.morph"], ["granular", "granular.density"]];
|
||||
const spheresSpeakDest = (t) => { for (const [w, d] of SPHERE_DEST_WORDS) if (t.indexOf(w) >= 0) return d; return null; };
|
||||
// The Groove — Ableton's Groove Pool (manual §14.1), reduced to a fixed 1/16
|
||||
// grid: swing delays the off-beats, humanize is the manual's "Random" timing
|
||||
// jitter, velo is per-note Velocity Deviation, amount is the Global Amount
|
||||
@ -3192,6 +3260,92 @@
|
||||
godspeedEl.appendChild(hint);
|
||||
}
|
||||
|
||||
// ---- 🪐 RESONANCE — the spheres panel + the orbital-ratios button ----------
|
||||
let spheresEl = null, spheresMeterEls = null;
|
||||
function spheresEnsure() { if (!spheresEl) { spheresEl = document.createElement("div"); spheresEl.id = "spheres"; document.body.appendChild(spheresEl); } return spheresEl; }
|
||||
function spheresPanelOpen() { return !!(spheresEl && spheresEl.classList.contains("open")); }
|
||||
function closeSpheres() { if (spheresEl) spheresEl.classList.remove("open"); spheresMeterEls = null; }
|
||||
function openSpheres() { spheresEnsure().classList.add("open"); spheresRender(); }
|
||||
function toggleSpheres() { spheresPanelOpen() ? closeSpheres() : openSpheres(); }
|
||||
function spheresRatios() { // ☸×🪐 — orbital resonances onto the drum lanes' wheels
|
||||
ungodlyMark("orbital ratios");
|
||||
seedBeat(); // a virgin kit gets its default pattern so the polymeter is audible at once
|
||||
const wl = [4, 8, 16].concat(DRUM_VOICES.length >= 5 ? [8, 13] : []); // Io:Europa:Ganymede 1:2:4 → 4:8:16; Venus:Earth 8:13 (the pentagram, if ≥5 lanes)
|
||||
for (let i = 0; i < wl.length && i < DRUM_VOICES.length; i++)
|
||||
drumSeqFor("drum." + DRUM_VOICES[i]).wlen = clamp(wl[i], 1, 16);
|
||||
if (beat.open) openBeat(); // rebuild the beat panel so the wheel inputs show the new lengths
|
||||
eventTicker.unshift({ text: "🪐 orbital ratios — the drums turn on 4·8·16 and 8·13", tAdded: now(), src: "sys" });
|
||||
}
|
||||
function spheresRender() {
|
||||
if (!spheresEl) return;
|
||||
spheresEl.innerHTML = ""; spheresMeterEls = [];
|
||||
const engineOK = spheresEngineOK();
|
||||
const top = document.createElement("div"); top.className = "atop";
|
||||
const title = document.createElement("span"); title.className = "btitle"; title.textContent = "🪐 spheres";
|
||||
const onBtn = document.createElement("button"); onBtn.className = "midibtn"; onBtn.textContent = spheres.on ? "■ on" : "▶ off";
|
||||
onBtn.classList.toggle("on", spheres.on); onBtn.disabled = !engineOK;
|
||||
onBtn.title = engineOK ? "the eight planets join the matrix as slow modulators" : "the ephemeris didn't load";
|
||||
onBtn.onclick = () => { spheresEnable(!spheres.on); spheresRender(); };
|
||||
const x = document.createElement("span"); x.className = "aclose"; x.textContent = "×"; x.title = "close"; x.onclick = closeSpheres;
|
||||
top.append(title, onBtn, x); spheresEl.appendChild(top);
|
||||
|
||||
if (!engineOK) {
|
||||
const warn = document.createElement("div"); warn.className = "hint";
|
||||
warn.textContent = "the ephemeris (spheres.js) didn't load — the feature is unavailable.";
|
||||
spheresEl.appendChild(warn); return;
|
||||
}
|
||||
|
||||
// rate — the great-year clock (1 bar = N days of sky, log-scaled)
|
||||
const rr = document.createElement("div"); rr.className = "row";
|
||||
const rl = document.createElement("span"); rl.className = "glbl"; rl.textContent = "rate";
|
||||
const rs = document.createElement("input"); rs.type = "range"; rs.min = "0"; rs.max = "1000"; rs.value = String(spheresRateToSlider(spheres.rate));
|
||||
rs.title = "days of sky per bar (1 … 3650) — how fast the great year turns";
|
||||
const rv = document.createElement("span"); rv.className = "glbl"; rv.style.minWidth = "58px"; rv.style.textAlign = "right";
|
||||
const fact = document.createElement("div"); fact.className = "hint";
|
||||
const showRate = () => { rv.textContent = spheres.rate + " d/bar";
|
||||
fact.textContent = "venus' year ≈ " + (224.7 / spheres.rate).toFixed(1) + " bars · venus–earth beat ≈ " + (583.9 / spheres.rate).toFixed(1) + " bars"; };
|
||||
showRate();
|
||||
rs.oninput = () => { spheres.rate = spheresSliderToRate(+rs.value); showRate(); }; // a view knob — cheap to re-set, no undo mark
|
||||
rr.append(rl, rs, rv); spheresEl.appendChild(rr); spheresEl.appendChild(fact);
|
||||
|
||||
// ☸×🪐 — the wheel of heaven
|
||||
const br = document.createElement("div"); br.className = "row";
|
||||
const ratio = document.createElement("button"); ratio.className = "midibtn bmini"; ratio.textContent = "☸ orbital ratios";
|
||||
ratio.title = "set the drum lanes' wheels to 4:8:16 (Io:Europa:Ganymede) and 8:13 (Venus:Earth — the pentagram)";
|
||||
ratio.onclick = () => spheresRatios();
|
||||
br.append(ratio); spheresEl.appendChild(br);
|
||||
|
||||
// the eight planets — phase · dist · beat as live meters
|
||||
const head = document.createElement("div"); head.className = "phead";
|
||||
for (const h of ["", "phase", "dist", "beat"]) { const c = document.createElement("span"); c.textContent = h; head.appendChild(c); }
|
||||
spheresEl.appendChild(head);
|
||||
for (const p of SPHERE_PLANETS) {
|
||||
const row = document.createElement("div"); row.className = "prow";
|
||||
const nm = document.createElement("span"); nm.className = "pname"; nm.textContent = SPHERE_GLYPH[p] + " " + p.slice(0, 3); row.appendChild(nm);
|
||||
for (const sig of SPHERE_SIGNALS) {
|
||||
const m = document.createElement("div"); m.className = "meter";
|
||||
const fill = document.createElement("i"), lab = document.createElement("b");
|
||||
m.append(fill, lab); row.appendChild(m);
|
||||
spheresMeterEls.push({ key: "sphere." + p + "." + sig, fill, lab });
|
||||
}
|
||||
spheresEl.appendChild(row);
|
||||
}
|
||||
spheresPanelTick();
|
||||
const hint = document.createElement("div"); hint.className = "hint";
|
||||
hint.textContent = spheres.on ? "route a planet from its node, or say “mars drive filter.” phase = its year · dist = its breathing · beat = its dance with earth."
|
||||
: "turn on — the eight planets join the field as slow modulators.";
|
||||
spheresEl.appendChild(hint);
|
||||
}
|
||||
function spheresPanelTick() { // live meter fills — cheap, called each frame while open
|
||||
if (!spheresMeterEls) return;
|
||||
for (const m of spheresMeterEls) {
|
||||
const n = sources.get(m.key);
|
||||
const v = n ? clamp(n.norm, 0, 1) : 0;
|
||||
m.fill.style.width = Math.round(v * 100) + "%";
|
||||
m.lab.textContent = (spheres.on && n) ? v.toFixed(2) : "—";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 🕊 PSALM — sing, and the instrument answers ------------------------
|
||||
// Mic pitch, folded to the global scale, played as notes — or, when ⚡ GODSPEED
|
||||
// is on, latched into the arp one note at a time (sing your chord in). PRIVACY:
|
||||
@ -3464,6 +3618,69 @@
|
||||
sendDestsToMidi(destVals);
|
||||
}
|
||||
|
||||
// ---- 🪐 RESONANCE — planets as modulators (music of the spheres) -----------
|
||||
// Called each frame BEFORE computeDests so the `sphere.*` sources carry fresh
|
||||
// values into the matrix. Everything here is a pure function of the master
|
||||
// clock (seqPhase) and the rate knob — no wall clock, no network, no sockets.
|
||||
function spheresSkyJD() { // the great-year clock: 1 bar = `rate` days of sky
|
||||
return window.SPHERES.J2000_JD + (seqPhase / 16) * spheres.rate;
|
||||
}
|
||||
function spheresComputeRanges() { // perihelion..aphelion per planet, sampled once
|
||||
const S = window.SPHERES; if (!S) return null;
|
||||
const r = {};
|
||||
for (const p of SPHERE_PLANETS) {
|
||||
const path = S.orbitPath(p, S.J2000_JD, 256); // one full orbit in mean anomaly → its r-extremes
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (let k = 0; k <= 256; k++) {
|
||||
const d = Math.hypot(path[k * 3], path[k * 3 + 1], path[k * 3 + 2]);
|
||||
if (d < lo) lo = d; if (d > hi) hi = d;
|
||||
}
|
||||
r[p] = { min: lo, max: hi };
|
||||
}
|
||||
return r;
|
||||
}
|
||||
function spheresSet(p, sig, norm, raw) { // ensure the source node, write its value
|
||||
const n = ensureSource("sphere." + p + "." + sig, { label: SPHERE_GLYPH[p] + " " + p + " · " + sig });
|
||||
n.norm = clamp(norm, 0, 1); n.raw = raw;
|
||||
}
|
||||
function spheresFrame() {
|
||||
if (!spheresEngineOK() || !spheres.on) return; // off / no engine → sources absent, routes idle
|
||||
if (!spheresRanges) spheresRanges = spheresComputeRanges();
|
||||
const S = window.SPHERES, jd = spheresSkyJD();
|
||||
const pos = {}, lam = {};
|
||||
for (const p of SPHERE_PLANETS) {
|
||||
const q = S.helioEcl(p, jd, {});
|
||||
pos[p] = q;
|
||||
lam[p] = (Math.atan2(q.y, q.x) * 180 / Math.PI + 360) % 360; // heliocentric ecliptic longitude, deg
|
||||
}
|
||||
const le = lam.earth;
|
||||
for (const p of SPHERE_PLANETS) {
|
||||
const q = pos[p], lp = lam[p], r = Math.hypot(q.x, q.y, q.z);
|
||||
const rg = spheresRanges && spheresRanges[p];
|
||||
const dist = rg && rg.max > rg.min ? (r - rg.min) / (rg.max - rg.min) : 0.5; // eccentricity breathing
|
||||
const beat = 0.5 + 0.5 * Math.sin(2 * Math.PI * (lp - le) / 360); // synodic beat vs Earth
|
||||
spheresSet(p, "phase", lp / 360, lp); // sawtooth on the planet's year; raw = degrees
|
||||
spheresSet(p, "dist", dist, r); // raw = AU (Mercury/Mars breathe hard, Venus barely)
|
||||
spheresSet(p, "beat", beat, beat); // Venus–Earth = the 583.9-day pentagram cycle
|
||||
}
|
||||
}
|
||||
function spheresEnable(on) { // the on/off switch + source-node lifecycle
|
||||
on = !!on;
|
||||
if (on && !spheresEngineOK()) { // self-check failed / file missing — refuse, stay silent
|
||||
eventTicker.unshift({ text: "🪐 spheres — the ephemeris didn't load", tAdded: now(), src: "sys" });
|
||||
return;
|
||||
}
|
||||
spheres.on = on;
|
||||
if (on) spheresFrame(); // populate immediately so the panel/matrix have values
|
||||
else for (const p of SPHERE_PLANETS) for (const sig of SPHERE_SIGNALS) sources.delete("sphere." + p + "." + sig);
|
||||
layoutDirty = true;
|
||||
if (spheresPanelOpen()) spheresRender();
|
||||
}
|
||||
// rate knob — log-scaled days-per-bar; the slider runs 0..1000.
|
||||
const spheresRateToSlider = (n) => clamp(Math.round(1000 * (Math.log(clamp(n, SPHERE_RATE_MIN, SPHERE_RATE_MAX)) - Math.log(SPHERE_RATE_MIN)) / (Math.log(SPHERE_RATE_MAX) - Math.log(SPHERE_RATE_MIN))), 0, 1000);
|
||||
const spheresSliderToRate = (s) => clamp(Math.round(Math.exp(Math.log(SPHERE_RATE_MIN) + (clamp(s, 0, 1000) / 1000) * (Math.log(SPHERE_RATE_MAX) - Math.log(SPHERE_RATE_MIN)))), SPHERE_RATE_MIN, SPHERE_RATE_MAX);
|
||||
function spheresSetRate(n) { spheres.rate = clamp(Math.round(n), SPHERE_RATE_MIN, SPHERE_RATE_MAX); if (spheresPanelOpen()) spheresRender(); }
|
||||
|
||||
// ---- local edits (mutate your patch instead of the read-only hub) ----------
|
||||
function toggleMuteLocal(key) { ungodlyMark("mute"); const t = tw(key); t.mute = t.mute >= 0.5 ? 0 : 1; }
|
||||
function setParamLocal(target, param, value) { tw(target)[param] = value; }
|
||||
@ -3572,6 +3789,7 @@
|
||||
groove: Object.assign({}, groove), // the feel travels with the patch
|
||||
scale: Object.assign({}, gScale), // the key travels too
|
||||
kit: (Synth.drumKit ? Synth.drumKit() : undefined), // the GODRUM kit travels too (undefined pre-merge)
|
||||
spheres: { on: spheres.on, rate: spheres.rate }, // 🪐 the great-year rate + on/off travel, so the sky reloads identically
|
||||
};
|
||||
}
|
||||
function loadPatch(p) {
|
||||
@ -3598,6 +3816,10 @@
|
||||
}
|
||||
if (typeof p.bpm === "number") godtime.bpm = clamp(p.bpm, 40, 180);
|
||||
if (typeof p.locked === "boolean") godtime.locked = p.locked; // restore the lock (old patches omit it → left as-is)
|
||||
if (p.spheres) { // 🪐 restore the sky (old patches omit it → left as-is, default off)
|
||||
if (typeof p.spheres.rate === "number") spheres.rate = clamp(Math.round(p.spheres.rate), SPHERE_RATE_MIN, SPHERE_RATE_MAX);
|
||||
spheresEnable(!!p.spheres.on);
|
||||
}
|
||||
if (beat.open) openBeat(); // rebuild the beat panel on the new song
|
||||
layoutDirty = true;
|
||||
}
|
||||
@ -4916,6 +5138,19 @@
|
||||
if (has("stop") || has("pause")) { godspeakBeat(false); godspeakSay("beat stopped"); return; }
|
||||
if (has("play") || has("start") || has("go")) { godspeakBeat(true); godspeakSay("beat playing"); return; }
|
||||
}
|
||||
if (has("spheres") || has("planets")) { // 🪐 RESONANCE — the sky by voice
|
||||
if (has("off") || (has("stop") && !has("start"))) { spheresEnable(false); godspeakSay("spheres off"); return; }
|
||||
if (has("faster") || has("quicker")) { spheresSetRate(spheres.rate * 2); godspeakSay("spheres faster → " + spheres.rate + " d/bar"); return; }
|
||||
if (has("slower")) { spheresSetRate(spheres.rate / 2); godspeakSay("spheres slower → " + spheres.rate + " d/bar"); return; }
|
||||
spheresEnable(true); godspeakSay(spheres.on ? "spheres on" : "spheres — the ephemeris didn't load"); return;
|
||||
}
|
||||
{ const pl = SPHERE_PLANETS.find(p => has(p)); // "<planet> drive <dest>" → route that planet's year to a dest
|
||||
if (pl && has("drive")) {
|
||||
const dest = spheresSpeakDest(t);
|
||||
if (dest) { if (!spheres.on) spheresEnable(true); ungodlyMark("sphere route"); addRouteLocal("sphere." + pl + ".phase", dest, 0.4); godspeakSay(pl + " drives " + dest); }
|
||||
else godspeakSay("drive " + pl + " to what? try “" + pl + " drive filter”");
|
||||
return;
|
||||
} }
|
||||
{ const m = matchMood(t); if (m) { applyMood(m); godspeakSay("mood → " + m); return; } }
|
||||
{ const k = matchSkin(t); if (k) { setSkin(k); godspeakSay("skin → " + (SKINS[k].label || k)); return; } }
|
||||
if (/\bzen\b/.test(t)) { setZen(!zenMode); godspeakSay(zenMode ? "zen" : "left zen"); return; }
|
||||
@ -5215,6 +5450,8 @@
|
||||
}
|
||||
|
||||
if (layoutDirty) doLayout();
|
||||
spheresFrame(); // 🪐 RESONANCE — refresh the sphere sources on the great-year clock
|
||||
if (spheresMeterEls) spheresPanelTick(); // live meters while the panel is open
|
||||
computeDests(); // YOUR patch -> dests (synth + viz + midi)
|
||||
|
||||
// advance smoothing
|
||||
@ -7688,6 +7925,7 @@
|
||||
beat.open ? closeBeat() : openBeat(); // 🥁 GODRUM — make a beat
|
||||
}
|
||||
if ((ev.key === "g" || ev.key === "G") && !ev.metaKey && !ev.ctrlKey) toggleGodspeed(); // ⚡ GODSPEED — the arp panel
|
||||
if ((ev.key === "s" || ev.key === "S") && !ev.metaKey && !ev.ctrlKey) toggleSpheres(); // 🪐 RESONANCE — the spheres panel
|
||||
if (beat.open && !ev.repeat && !ev.metaKey && !ev.ctrlKey && !ev.altKey) { // finger-drum the kit (1–5)
|
||||
const byCode = ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5"].indexOf(ev.code); // ev.code keeps ⇧+digit mapped
|
||||
const di = byCode >= 0 ? byCode : "12345".indexOf(ev.key); // …with an ev.key fallback for code-less envs
|
||||
@ -7960,6 +8198,7 @@
|
||||
ctxItem((Synth.testamentState && Synth.testamentState().on) ? "📼 TESTAMENT — stop & save the take" : "📼 TESTAMENT — record what god does",
|
||||
() => { if (Synth.testament) Synth.testament(); });
|
||||
ctxItem("⚡ GODSPEED — hold a chord, the machine runs", () => openGodspeed());
|
||||
ctxItem((spheres.on ? "🪐 spheres — music of the spheres (on)" : "🪐 spheres — the planets as modulators"), () => openSpheres(), "S");
|
||||
ctxItem((psalm.on ? "🕊 PSALM — listening… (click to stop)" : "🕊 PSALM — sing, and the instrument answers"), () => togglePsalm());
|
||||
ctxItem("🌐 " + (Synth.feeding() ? "tab feed: live — click to stop" : "feed a tab (YouTube, a stream…) into the world"),
|
||||
async () => { const r = await Synth.feedTab();
|
||||
@ -8527,6 +8766,7 @@
|
||||
muteKey: { label: "mute / unmute…", targeted: true, run: (k) => toggleMuteLocal(k) },
|
||||
soloKeyA: { label: "solo…", targeted: true, run: (k) => { soloKey = soloKey === k ? null : k; } },
|
||||
zero: { label: "zero — build from nothing", run: () => { zeroMode ? exitZeroToMood("first light") : enterZero(); } },
|
||||
spheres: { label: "🪐 spheres — music of the spheres", run: () => toggleSpheres() },
|
||||
};
|
||||
const DEFAULT_KEYS = { F1: { a: "grimoire" }, F2: { a: "play" }, F3: { a: "chakraView" }, F4: { a: "skinNext" },
|
||||
F5: { a: "moodCycle" }, F6: { a: "tracks" }, F7: { a: "perform" }, F8: { a: "godLock" }, F9: { a: "earthEcho" }, F10: { a: "stage" }, F11: { a: "zero" } };
|
||||
|
||||
@ -374,6 +374,32 @@
|
||||
#godspeed input[type=range] { width: 88px; accent-color: #7fa0e6; height: 3px; }
|
||||
#godspeed .held { color: #bcd; font-size: 11px; letter-spacing: 0.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#godspeed .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
|
||||
/* 🪐 RESONANCE — the spheres panel (right-anchored, mirrors #godspeed) */
|
||||
#spheres {
|
||||
position: fixed; right: 12px; top: 64px; width: 272px; z-index: 13;
|
||||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||||
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
|
||||
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||||
max-height: 80vh; overflow-y: auto;
|
||||
}
|
||||
#spheres.open { display: flex; flex-direction: column; gap: 6px; }
|
||||
#spheres .atop { display: flex; align-items: center; gap: 8px; }
|
||||
#spheres .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 1; }
|
||||
#spheres .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
|
||||
#spheres .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
#spheres .glbl { color: rgba(160,180,215,0.8); font-size: 10px; min-width: 30px; }
|
||||
#spheres .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
|
||||
border-radius: 7px; padding: 3px 9px; cursor: pointer; font-size: 11px; }
|
||||
#spheres .midibtn.on { background: rgba(120,220,150,0.22); color: #bff0c9; border-color: rgba(130,230,160,0.55); }
|
||||
#spheres .bmini { padding: 2px 7px; }
|
||||
#spheres input[type=range] { flex: 1; min-width: 90px; accent-color: #7fa0e6; height: 3px; }
|
||||
#spheres .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
|
||||
#spheres .phead, #spheres .prow { display: grid; grid-template-columns: 56px 1fr 1fr 1fr; gap: 4px; align-items: center; }
|
||||
#spheres .phead { color: rgba(160,180,215,0.7); font-size: 9px; letter-spacing: 0.3px; padding: 0 1px; }
|
||||
#spheres .pname { color: #bcd; font-size: 10px; letter-spacing: 0.3px; }
|
||||
#spheres .meter { position: relative; height: 12px; border-radius: 3px; background: rgba(60,80,120,0.22); overflow: hidden; }
|
||||
#spheres .meter > i { position: absolute; left: 0; top: 0; bottom: 0; background: rgba(127,160,230,0.5); transition: width 0.08s linear; }
|
||||
#spheres .meter > b { position: absolute; inset: 0; font-size: 8px; font-weight: 400; color: #d4dcee; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
/* 🗣 GODSPEAK — a small "listening" chip near the ♪ button, pulses while the mic is held */
|
||||
#godspeakrec { position: fixed; top: 12px; right: 92px; z-index: 40; width: 30px; height: 30px;
|
||||
@ -1188,6 +1214,7 @@
|
||||
<li><b>☸ WHEELS — a wheel within a wheel.</b> <i>"…their work was as it were a wheel in the middle of a wheel"</i> (Ezekiel 1:16). Each lane keeps its own length: set the <b>☸ wheel</b> chip in a beat lane (or the arranger's euclid row) to anything from 1 to 16, and a lane shorter than the full sixteen <b>wraps on its own turn</b>. A hat of twelve over a kick of sixteen drift apart and only realign every forty-eight steps — <b>polymeter</b>, the oldest trick for making a loop that never quite repeats. Steps past the wheel dim out; the playhead rides each lane's own length; and the pattern <b>exports to MIDI exactly as it plays</b>, twelve-wheel and all. A full wheel of sixteen behaves precisely as it always did.</li>
|
||||
<li><b>🕊 PSALM — sing, and the instrument answers.</b> Right-click the sky → <b>🕊 PSALM</b>, allow the microphone, and <b>sing</b>. The pitch is heard, <b>folded to the scale you're in</b> (so you're never out of key), and played as notes — a <b>🕊</b> chip shows the note leaving your throat. And the duet with <b>⚡ GODSPEED</b>: with the arp running, each note you sing <b>latches into the held chord</b>, one at a time — hum four notes and the machine arpeggiates the chord you sang. <b>It listens locally</b>: the analysis happens in the page, in its own silence, and <i>nothing ever leaves the machine</i> — no recording, no upload, just your voice becoming the instrument's.</li>
|
||||
<li><b>✦ CANON — the shelf of worked examples.</b> Open <b>⚙ → DIMENSIONS</b> and at the top of the list, above your own saved worlds, sit four <b>✦ factory dimensions</b> that ship with the instrument — <i>the heartbeat</i> (a boom-bap kit with the swing and the kick's duck), <i>the wheel within the wheel</i> (WHEELS, made audible), <i>the squeeze</i> (the market pumping GODSQUASH), and <i>quiet arrival</i> (the earth echo, slow and sparse). Load one to hear a feature in its element, then save your own version under a new name — the ✦ ones are <b>read-only</b>, the same for everyone, a place to start rather than a thing to overwrite.</li>
|
||||
<li><b>🪐 spheres — the music of the spheres.</b> Press <kbd>S</kbd> (or right-click the sky → <b>🪐 spheres</b>) and turn it <b>on</b>: the eight planets join the field as <b>modulation sources</b>, the slowest LFOs there are. Each planet offers three signals — <b>phase</b> (its heliocentric longitude, a sawtooth one full turn per orbit), <b>dist</b> (its eccentric breathing, in and out between perihelion and aphelion — Mercury and Mars swing hard, Venus barely), and <b>beat</b> (its <i>synodic</i> dance against Earth — Venus and Earth clasp every 583.9 days, the pentagram cycle). Route any of them like any source, or just say <i>"mars drive filter."</i> One knob rules time: the <b>rate</b>, from <b>1 to 3650 days of sky per bar</b> — at the default thirty, Venus' year is about seven and a half bars and Jupiter's a hundred and forty-four, real orbits compressed into slow musical arcs. And the <b>☸ orbital ratios</b> button sets the drum lanes' wheels to the true resonances — <b>4 : 8 : 16</b> (Io, Europa and Ganymede, locked at 1 : 2 : 4) and <b>8 : 13</b> (Venus to Earth, the pentagram in wheel form), so the polymeter you hear is one the solar system actually keeps. It is all <b>computed here, at the instrument's own clock</b> — the same JPL ephemeris SOLARGOD uses, vendored in, no network and no server, so the sky is deterministic: the same bar always shows the same heaven, and a saved patch reloads the identical sky.</li>
|
||||
<li><b>📽 The stage — send the visual to any screen.</b> Right-click the sky → <b>the stage</b> and a clean black mirror window opens, fed live from the canvas. Drag it to a projector, an AirPlay display, a second monitor — macOS treats it like any window — and <b>double-click for fullscreen</b>. The stage has a <b>camera</b>: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → <b>project this node</b> and the camera glides after that one sphere through its orbits, or <b>project its station</b> to frame a whole wheel and its satellites. Change cameras live from the menus while the projector runs; the audience sees the camera breathe from subject to subject.</li>
|
||||
<li><b>🕸 The commune — play together, properly.</b> Right-click the sky → <b>the commune</b>. This is for <b>signed-in players</b>: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you <b>hosts</b>: name the room, then <b>✉ invite</b> the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and <b>join or decline</b>; anyone may <b>leave</b> whenever they wish, and if the host leaves, the room closes with them. Then the sections: members <b>claim voices</b> (right-click any voice, or the panel's picker), or the <b>host allocates</b> them — this bass to her, those pads to him, <b>the godtime itself</b> to whoever conducts — and the hub enforces it: only the section's holder can speak for it. From that moment your levels, mutes, grooves and arranger lanes for what you hold follow your hands on every screen, same living room or opposite sides of the earth. The world — the Source — stays identical for everyone, because it always was. Three people, one planet, one instrument.</li></ul>
|
||||
|
||||
|
||||
203
viz/spheres.js
Normal file
203
viz/spheres.js
Normal file
@ -0,0 +1,203 @@
|
||||
// vendored from SOLARGOD@6391273 — do not edit; re-vendor to update.
|
||||
// This is js/ephem.js (whole) with the minimal js/lib.js subset it needs inlined
|
||||
// (centuriesSinceJ2000, normDegPM180, DEG, J2000_JD, jdFromUnixMs) so the file
|
||||
// stands alone with no import of Three.js or the rest of SOLARGOD. The IIFE in
|
||||
// index.html imports from here via a <script type="module"> bridge that hangs a
|
||||
// frozen window.SPHERES. Godstrument computes the sky itself, deterministically,
|
||||
// at its own musical clock — no network, no server, no sockets.
|
||||
|
||||
// ---- lib.js subset (SOLARGOD@6391273/js/lib.js — pure, dependency-free) ----
|
||||
export const MS_PER_DAY = 86400000;
|
||||
export const J2000_JD = 2451545.0; // 2000-Jan-01 12:00 TT
|
||||
export const UNIX_EPOCH_JD = 2440587.5; // JD of 1970-Jan-01 00:00 UTC
|
||||
export const DEG = Math.PI / 180;
|
||||
// JD from unix-ms: divide by ms/day, add the unix-epoch JD.
|
||||
export function jdFromUnixMs(ms) { return ms / MS_PER_DAY + UNIX_EPOCH_JD; }
|
||||
// Julian centuries since J2000.0.
|
||||
export function centuriesSinceJ2000(jd) { return (jd - J2000_JD) / 36525; }
|
||||
// Normalize degrees to (−180, +180]. Must run in float64 AFTER any large
|
||||
// subtraction (Mercury's L rate is 149473°/Cy → M can be tens of thousands of
|
||||
// degrees before reduction).
|
||||
export function normDegPM180(d) {
|
||||
d = d % 360;
|
||||
if (d <= -180) d += 360;
|
||||
else if (d > 180) d -= 360;
|
||||
return d;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// js/ephem.js — JPL SSD "Approximate Positions of the Planets" (Standish).
|
||||
// Tables extracted 2026-07-15 from https://ssd.jpl.nasa.gov/planets/approx_pos.html
|
||||
// — authoritative, verbatim. Angles in degrees, rates per Julian century; `e` is
|
||||
// dimensionless (the page's "rad" header for e is its own typo). No n-body
|
||||
// integration — this IS the spec.
|
||||
|
||||
// Row layout: el = [a(au), e, I(deg), L(deg), ϖ long.peri(deg), Ω long.node(deg)]
|
||||
// rate = same order, per century.
|
||||
const TABLE1 = {
|
||||
mercury: { el: [0.38709927, 0.20563593, 7.00497902, 252.25032350, 77.45779628, 48.33076593],
|
||||
rate: [0.00000037, 0.00001906, -0.00594749, 149472.67411175, 0.16047689, -0.12534081] },
|
||||
venus: { el: [0.72333566, 0.00677672, 3.39467605, 181.97909950, 131.60246718, 76.67984255],
|
||||
rate: [0.00000390, -0.00004107, -0.00078890, 58517.81538729, 0.00268329, -0.27769418] },
|
||||
earth: { el: [1.00000261, 0.01671123, -0.00001531, 100.46457166, 102.93768193, 0.0],
|
||||
rate: [0.00000562, -0.00004392, -0.01294668, 35999.37244981, 0.32327364, 0.0] },
|
||||
mars: { el: [1.52371034, 0.09339410, 1.84969142, -4.55343205, -23.94362959, 49.55953891],
|
||||
rate: [0.00001847, 0.00007882, -0.00813131, 19140.30268499, 0.44441088, -0.29257343] },
|
||||
jupiter: { el: [5.20288700, 0.04838624, 1.30439695, 34.39644051, 14.72847983, 100.47390909],
|
||||
rate: [-0.00011607, -0.00013253, -0.00183714, 3034.74612775, 0.21252668, 0.20469106] },
|
||||
saturn: { el: [9.53667594, 0.05386179, 2.48599187, 49.95424423, 92.59887831, 113.66242448],
|
||||
rate: [-0.00125060, -0.00050991, 0.00193609, 1222.49362201, -0.41897216, -0.28867794] },
|
||||
uranus: { el: [19.18916464, 0.04725744, 0.77263783, 313.23810451, 170.95427630, 74.01692503],
|
||||
rate: [-0.00196176, -0.00004397, -0.00242939, 428.48202785, 0.40805281, 0.04240589] },
|
||||
neptune: { el: [30.06992276, 0.00859048, 1.77004347, -55.12002969, 44.96476227, 131.78422574],
|
||||
rate: [0.00026291, 0.00005105, 0.00035372, 218.45945325, -0.32241464, -0.00508664] },
|
||||
};
|
||||
|
||||
// Table 2a — valid 3000 BC–3000 AD. Shipped for the extended-range config swap
|
||||
// (CONFIG.useExtendedRange); unused by v1 UI.
|
||||
const TABLE2A = {
|
||||
mercury: { el: [0.38709843, 0.20563661, 7.00559432, 252.25166724, 77.45771895, 48.33961819],
|
||||
rate: [0.00000000, 0.00002123, -0.00590158, 149472.67486623, 0.15940013, -0.12214182] },
|
||||
venus: { el: [0.72332102, 0.00676399, 3.39777545, 181.97970850, 131.76755713, 76.67261496],
|
||||
rate: [-0.00000026, -0.00005107, 0.00043494, 58517.81560260, 0.05679648, -0.27274174] },
|
||||
earth: { el: [1.00000018, 0.01673163, -0.00054346, 100.46691572, 102.93005885, -5.11260389],
|
||||
rate: [-0.00000003, -0.00003661, -0.01337178, 35999.37306329, 0.31795260, -0.24123856] },
|
||||
mars: { el: [1.52371243, 0.09336511, 1.85181869, -4.56813164, -23.91744784, 49.71320984],
|
||||
rate: [0.00000097, 0.00009149, -0.00724757, 19140.29934243, 0.45223625, -0.26852431] },
|
||||
jupiter: { el: [5.20248019, 0.04853590, 1.29861416, 34.33479152, 14.27495244, 100.29282654],
|
||||
rate: [-0.00002864, 0.00018026, -0.00322699, 3034.90371757, 0.18199196, 0.13024619] },
|
||||
saturn: { el: [9.54149883, 0.05550825, 2.49424102, 50.07571329, 92.86136063, 113.63998702],
|
||||
rate: [-0.00003065, -0.00032044, 0.00451969, 1222.11494724, 0.54179478, -0.25015002] },
|
||||
uranus: { el: [19.18797948, 0.04685740, 0.77298127, 314.20276625, 172.43404441, 73.96250215],
|
||||
rate: [-0.00020455, -0.00001550, -0.00180155, 428.49512595, 0.09266985, 0.05739699] },
|
||||
neptune: { el: [30.06952752, 0.00895439, 1.77005520, 304.22289287, 46.68158724, 131.78635853],
|
||||
rate: [0.00006447, 0.00000818, 0.00022400, 218.46515314, 0.01009938, -0.00606302] },
|
||||
};
|
||||
|
||||
// Table 2b — extra terms added to M (deg) for Jupiter–Neptune, Table 2a only:
|
||||
// M += b·T² + c·cos(f·T) + s·sin(f·T). [b, c, s, f]
|
||||
const TABLE2B = {
|
||||
jupiter: [-0.00012452, 0.06064060, -0.35635438, 38.35125000],
|
||||
saturn: [0.00025899, -0.13434469, 0.87320147, 38.35125000],
|
||||
uranus: [0.00058331, -0.97731848, 0.17689245, 7.67025000],
|
||||
neptune: [-0.00041348, 0.68346318, -0.10162547, 7.67025000],
|
||||
};
|
||||
|
||||
export const PLANET_IDS = ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'];
|
||||
|
||||
let useExtended = false;
|
||||
export function setExtendedRange(on) { useExtended = !!on; }
|
||||
|
||||
// Solve Kepler's equation E − e·sinE = M (radians) by Newton–Raphson.
|
||||
// Seed E₀ = M + e·sinM; converges in a few iters even for e→0.97 (verify gate).
|
||||
export function solveKepler(M, e, tol = 1e-9, maxIter = 20) {
|
||||
let E = M + e * Math.sin(M);
|
||||
for (let i = 0; i < maxIter; i++) {
|
||||
const dE = (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
|
||||
E -= dE;
|
||||
if (Math.abs(dE) < tol) break;
|
||||
}
|
||||
return E;
|
||||
}
|
||||
|
||||
// The reusable core: resolved classical elements → heliocentric J2000 ecliptic
|
||||
// {x,y,z} in AU. a[au], e, and iDeg/OmDeg(Ω)/wDeg(ω arg.peri)/Mdeg all in degrees.
|
||||
// Shared by planets (element+rate) and small bodies (epoch elements) — brief §3.
|
||||
export function eclFromElements(a, e, iDeg, OmDeg, wDeg, Mdeg, out = {}) {
|
||||
const M = normDegPM180(Mdeg) * DEG;
|
||||
const E = solveKepler(M, e);
|
||||
// Orbital-plane coords (perifocal), AU.
|
||||
const xp = a * (Math.cos(E) - e);
|
||||
const yp = a * Math.sqrt(1 - e * e) * Math.sin(E);
|
||||
const w = wDeg * DEG, Om = OmDeg * DEG, I = iDeg * DEG;
|
||||
const cw = Math.cos(w), sw = Math.sin(w);
|
||||
const cO = Math.cos(Om), sO = Math.sin(Om);
|
||||
const cI = Math.cos(I), sI = Math.sin(I);
|
||||
// Perifocal → ecliptic rotation (brief §3 step 5 = standard R_z(Ω)R_x(I)R_z(ω)).
|
||||
out.x = (cw * cO - sw * sO * cI) * xp + (-sw * cO - cw * sO * cI) * yp;
|
||||
out.y = (cw * sO + sw * cO * cI) * xp + (-sw * sO + cw * cO * cI) * yp;
|
||||
out.z = (sw * sI) * xp + (cw * sI) * yp;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Resolve a planet's elements at jd (Table 1, 1800–2050) → {a,e,I,Om,w,M} deg.
|
||||
function resolvePlanet(bodyId, jd) {
|
||||
const T = centuriesSinceJ2000(jd);
|
||||
const row = TABLE1[bodyId];
|
||||
if (!row) throw new Error(`ephem: unknown planet ${bodyId}`);
|
||||
const a = row.el[0] + row.rate[0] * T;
|
||||
const e = row.el[1] + row.rate[1] * T;
|
||||
const I = row.el[2] + row.rate[2] * T;
|
||||
const L = row.el[3] + row.rate[3] * T;
|
||||
const wb = row.el[4] + row.rate[4] * T; // ϖ longitude of perihelion
|
||||
const Om = row.el[5] + row.rate[5] * T; // Ω longitude of ascending node
|
||||
const w = wb - Om; // ω argument of perihelion
|
||||
const M = L - wb; // mean anomaly (normalize AFTER subtract)
|
||||
return { a, e, I, Om, w, M };
|
||||
}
|
||||
// Extended-range resolver (Table 2a + the 2b M-correction for Jupiter–Neptune,
|
||||
// valid 3000 BC–3000 AD). f·T uses T in centuries; f is deg-per-century so f·T
|
||||
// is degrees → convert to rad for the trig. Only used when useExtended is set.
|
||||
function resolvePlanetExt(bodyId, jd) {
|
||||
const T = centuriesSinceJ2000(jd);
|
||||
const row = TABLE2A[bodyId];
|
||||
const a = row.el[0] + row.rate[0] * T;
|
||||
const e = row.el[1] + row.rate[1] * T;
|
||||
const I = row.el[2] + row.rate[2] * T;
|
||||
const L = row.el[3] + row.rate[3] * T;
|
||||
const wb = row.el[4] + row.rate[4] * T;
|
||||
const Om = row.el[5] + row.rate[5] * T;
|
||||
const w = wb - Om;
|
||||
let M = L - wb;
|
||||
const b2 = TABLE2B[bodyId];
|
||||
if (b2) {
|
||||
const [b, c, s, f] = b2;
|
||||
M += b * T * T + c * Math.cos(f * T * DEG) + s * Math.sin(f * T * DEG);
|
||||
}
|
||||
return { a, e, I, Om, w, M };
|
||||
}
|
||||
|
||||
// Public: heliocentric J2000 ecliptic position of a planet at jd, AU.
|
||||
export function helioEcl(bodyId, jd, out = {}) {
|
||||
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
|
||||
return eclFromElements(p.a, p.e, p.I, p.Om, p.w, p.M, out);
|
||||
}
|
||||
|
||||
// Mean motion [deg/day] from semi-major axis via Kepler's third law (Gaussian).
|
||||
export function meanMotionDegPerDay(a) { return 0.9856076686 / Math.pow(a, 1.5); }
|
||||
|
||||
// Resolved semi-major axis [AU] of a planet at jd (for vis-viva speed, year length).
|
||||
export function semiMajor(bodyId, jd) {
|
||||
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
|
||||
return p.a;
|
||||
}
|
||||
|
||||
// Small-body position (asteroids/comets/Pluto): epoch elements propagated by
|
||||
// M = M0 + n·(jd − epoch). el = {a,e,i,om(Ω),w(ω),ma(M0),epoch}.
|
||||
export function smallBodyEcl(el, jd, out = {}) {
|
||||
const n = meanMotionDegPerDay(el.a);
|
||||
const M = el.ma + n * (jd - el.epoch);
|
||||
return eclFromElements(el.a, el.e, el.i, el.om, el.w, M, out);
|
||||
}
|
||||
|
||||
// One full orbit sampled in mean anomaly, as a Float64Array of xyz triples
|
||||
// (nSamples+1 points; last == first so a LineLoop/LineSegments closes cleanly —
|
||||
// verify gate "orbitPath endpoints join"). Elements frozen at jd (they drift
|
||||
// slowly; caller rebuilds only when |ΔT| > 0.1 Cy, brief §11).
|
||||
export function orbitPath(bodyId, jd, nSamples = 256) {
|
||||
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
|
||||
return samplePath(p.a, p.e, p.I, p.Om, p.w, nSamples);
|
||||
}
|
||||
export function orbitPathFromElements(el, nSamples = 256) {
|
||||
return samplePath(el.a, el.e, el.i, el.om, el.w, nSamples);
|
||||
}
|
||||
function samplePath(a, e, I, Om, w, nSamples) {
|
||||
const out = new Float64Array((nSamples + 1) * 3);
|
||||
const tmp = {};
|
||||
for (let k = 0; k <= nSamples; k++) {
|
||||
const M = (360 * k) / nSamples; // 0..360; k=nSamples reproduces k=0
|
||||
eclFromElements(a, e, I, Om, w, M, tmp);
|
||||
out[k * 3] = tmp.x; out[k * 3 + 1] = tmp.y; out[k * 3 + 2] = tmp.z;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user