Play the lesson. A local Three.js scene makes gravity visible; ordinary HTML controls make each experiment usable with a keyboard or touch.
altitude is height above the surface in planet radii (0.3–3). speed is
initial tangential speed in simulation units (0–1.5). vectors="on" starts
with velocity and acceleration arrows visible. Each instance owns its state. An optional id gives the experiment a stable
Player Beat link.
<section class="orbit-lab" data-pmx-prose="off" data-pmx-beat="{{ id }}">
<div class="orbit-heading"><span>ORBITAL OBSERVATORY</span><span>GM = 1 · R = 1</span></div>
<div class="orbit-stage" aria-label="Orbital simulation. Use the launch controls below.">
<div class="orbit-hint">Drag to look around · Scroll or use + / − to zoom</div>
<div class="orbit-readout"><span>Distance <b data-distance>—</b> R</span><span>Revolutions <b data-turns>0.00</b></span></div>
</div>
<div class="orbit-camera" aria-label="Camera controls">
<label>View <select data-view><option value="observatory">Observatory</option><option value="top">Top-down</option><option value="follow">Follow satellite</option></select></label>
<button type="button" data-zoom-in aria-label="Zoom in">+</button><button type="button" data-zoom-out aria-label="Zoom out">−</button>
</div>
<div class="orbit-console">
<label class="orbit-speed">Launch speed <output data-speed-label></output><input data-speed type="range" min="0" max="1.5" step="0.001" value="0.4" /></label>
<div class="orbit-actions"><button type="button" data-launch disabled>Launch</button><button type="button" data-step disabled>Step</button><button type="button" data-reset disabled>Reset</button></div>
<div class="orbit-options"><label><input type="checkbox" data-vectors /> Motion arrows</label><label><input type="checkbox" data-prediction /> Predicted path</label></div>
<p class="orbit-status" role="status">Preparing the observatory…</p>
<div class="orbit-instructions"><slot /></div>
</div>
</section>
The scene uses a deep navy field, a lit globe, and a gold trail. Controls remain HTML, with visible focus, generous touch targets, and stable-width readouts.
.orbit-lab { color: #edf3ff; background: #080e1b; border-radius: 18px; overflow: hidden; margin-block: 1.5rem; box-shadow: 0 18px 48px #03091428; font: 14px/1.5 var(--pmx-font-body, sans-serif); }
.orbit-heading { display: flex; justify-content: space-between; gap: 1rem; padding: 18px 22px; color: #9aabc6; font-size: 10px; letter-spacing: .15em; }
.orbit-stage { position: relative; height: clamp(290px, 45vw, 440px); background: radial-gradient(ellipse at 48% 45%, #172844, #080e1b 70%); }
.orbit-stage canvas { display: block; width: 100%; height: 100%; touch-action: none; }
.orbit-hint { position: absolute; top: 8px; width: 100%; text-align: center; color: #acbcd4; font-size: 11px; pointer-events: none; }
.orbit-readout { position: absolute; bottom: 14px; left: 22px; right: 22px; display: flex; justify-content: space-between; color: #9aabc6; pointer-events: none; font-size: 11px; }
.orbit-readout b { color: #f3cc8c; font-variant-numeric: tabular-nums; font-weight: 500; }
.orbit-camera { display: flex; align-items: center; gap: 8px; padding: 10px 22px; border-top: 1px solid #ffffff12; }
.orbit-camera label { display: flex; align-items: center; gap: 10px; margin-right: auto; min-width: 0; flex: 1; color: #bbc9dd; }
.orbit-camera select, .orbit-camera button { min-height: 44px; border: 1px solid #ffffff24; border-radius: 8px; background: #101a2b; color: #edf3ff; font: inherit; padding: 6px 12px; }
.orbit-camera select { font-size: max(16px, 1em); min-width: 0; width: 100%; max-width: 190px; }
.orbit-camera button { flex-shrink: 0; min-width: 44px; cursor: pointer; }
.orbit-console { padding: 20px 22px; border-top: 1px solid #ffffff12; display: grid; gap: 14px; }
.orbit-speed { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
.orbit-speed output { font-variant-numeric: tabular-nums; color: #f3cc8c; }
.orbit-speed input { grid-column: 1 / -1; width: 100%; accent-color: #f3cc8c; min-height: 44px; }
.orbit-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.orbit-actions button { min-height: 44px; padding: 8px 20px; border: 1px solid #ffffff24; border-radius: 8px; background: #ffffff08; color: #edf3ff; font: inherit; cursor: pointer; }
.orbit-actions button:first-child { background: #f3cc8c; color: #141a26; border-color: transparent; min-width: 105px; font-weight: 650; }
.orbit-actions button:hover { filter: brightness(1.12); }
.orbit-actions button:active { scale: .96; }
.orbit-actions button:disabled { opacity: .4; cursor: default; }
.orbit-lab :focus-visible { outline: 2px solid #92caff; outline-offset: 4px; }
.orbit-options { display: flex; flex-wrap: wrap; gap: 8px 22px; color: #bbc9dd; font-size: 12px; }
.orbit-options label { display: flex; align-items: center; gap: 7px; min-height: 40px; }
.orbit-options input { accent-color: #f3cc8c; }
.orbit-status { margin: 0; color: #f3cc8c; min-height: 1.5em; }
.orbit-instructions { color: #aebcd1; font-size: 12px; }
.orbit-instructions p { margin: 0; }
@media (max-width: 360px) { .orbit-camera { flex-wrap: wrap; padding: 10px 16px; } .orbit-camera label { flex-basis: 100%; } .orbit-camera select { max-width: none; } .orbit-console { min-width: 0; } }
@media (max-width: 480px) { .orbit-heading, .orbit-console { padding: 16px; } .orbit-heading { font-size: 9px; } }
The browser module is local, including pinned Three.js 0.180.0 and its MIT license. Loading it only when an experiment approaches the viewport keeps other pages light. Cleanup releases observers, listeners, and GPU resources.
let disposed = false
let destroy
const observer = new IntersectionObserver(async (entries) => {
if (!entries.some(entry => entry.isIntersecting)) return
observer.disconnect()
try {
const { mount } = await import(new URL('./assets/observatory.js?v=81661549bbf1', location.href).href)
if (!disposed) destroy = mount(el, attrs)
} catch (error) {
if (!disposed) el.querySelector('.orbit-status').textContent = 'The 3D scene could not start. This experiment needs WebGL2. Try reloading in a supported browser.'
console.error(error)
}
}, { rootMargin: '200px' })
observer.observe(el)
cleanup(() => { disposed = true; observer.disconnect(); destroy?.() })
<orbit-lab altitude="1" speed="0.707" vectors="on">
Launch, pause, and step through a circular orbit.
</orbit-lab>
Preparing the observatory…
The physics module contains the entire gravity model. With GM = 1, acceleration is the position vector multiplied by −1/r³. Velocity Verlet advances it with two half-kicks around a position update. A fixed timestep keeps the result independent of display refresh rate.
The camera module provides an angled perspective, a top-down view for comparing paths, and a moving chase view behind the satellite. Drag the observatory to rotate it; use the zoom buttons with any input device. In top-down view, dragging upward from the satellite sets launch speed.
The observatory module draws that state with Three.js. The planet is a sphere with a procedural surface; satellite motion lives in a fixed orbital plane. Prediction and actual flight use the same step function. A complete accumulated turn earns a revolution; hitting the surface ends the attempt. Leaving the viewing region pauses flight without claiming that every departing trajectory has escaped gravity.