anycoder-ff0dba24 / index.html
hendrik289's picture
Upload folder using huggingface_hub
7690ec7 verified
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Realistische Sonnensystem-Simulation</title>
<style>
:root {
--bg: #0a0f1c;
--panel: #0f1629cc;
--panel-border: #2b3a66;
--accent: #7dd3fc;
--accent-2: #a78bfa;
--text: #e6f0ff;
--muted: #9fb2d6;
--good: #34d399;
--warn: #f59e0b;
--danger: #ef4444;
--shadow: 0 10px 30px rgba(0, 0, 0, .35);
--blur: saturate(140%) blur(8px);
--radius: 16px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
background: radial-gradient(1200px 800px at 70% 20%, #111a31 0%, #0b1223 40%, #070d1a 65%, #050913 100%), var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji;
overflow: hidden;
}
canvas {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
display: block;
}
.hud {
position: fixed;
top: 16px;
left: 16px;
display: flex;
flex-direction: column;
gap: 12px;
z-index: 10;
pointer-events: none;
}
.header {
pointer-events: auto;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
backdrop-filter: var(--blur);
background: linear-gradient(180deg, rgba(255, 255, 255, .06), rgba(255, 255, 255, .02));
border: 1px solid var(--panel-border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.logo {
width: 28px;
height: 28px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #ffd166 0%, #ff9f1c 35%, #ff6b35 60%, #f94144 100%);
box-shadow: 0 0 20px 6px rgba(255, 198, 94, .35), inset 0 0 20px rgba(255, 255, 255, .15);
border: 1px solid rgba(255, 255, 255, .25);
}
.title {
font-weight: 700;
letter-spacing: .3px;
}
.subtitle {
color: var(--muted);
font-size: .9rem;
}
.brand {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.brand:hover {
text-decoration: underline;
}
.panel {
pointer-events: auto;
padding: 14px;
backdrop-filter: var(--blur);
background: linear-gradient(180deg, rgba(13, 20, 40, .75), rgba(10, 15, 28, .65));
border: 1px solid var(--panel-border);
border-radius: var(--radius);
box-shadow: var(--shadow);
min-width: 320px;
max-width: 420px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 8px 0;
}
.label {
color: var(--muted);
font-size: .9rem;
}
.value {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.controls {
display: grid;
gap: 10px;
}
.control {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 10px;
}
input[type="range"] {
width: 220px;
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(125, 211, 252, .35), rgba(167, 139, 250, .45));
outline: none;
border: 1px solid rgba(255, 255, 255, .08);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .35);
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #fff 0%, #cfe9ff 35%, #8bd6ff 70%, #6ea8ff 100%);
border: 1px solid rgba(255, 255, 255, .7);
box-shadow: 0 3px 10px rgba(0, 0, 0, .35), 0 0 0 6px rgba(125, 211, 252, .15);
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #fff 0%, #cfe9ff 35%, #8bd6ff 70%, #6ea8ff 100%);
border: 1px solid rgba(255, 255, 255, .7);
box-shadow: 0 3px 10px rgba(0, 0, 0, .35), 0 0 0 6px rgba(125, 211, 252, .15);
cursor: pointer;
}
.toggles {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.chip {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
cursor: pointer;
user-select: none;
border: 1px solid var(--panel-border);
background: rgba(255, 255, 255, .04);
transition: transform .12s ease, background .2s ease, border-color .2s ease;
}
.chip input {
display: none;
}
.chip span {
color: var(--muted);
}
.chip.active {
background: rgba(125, 211, 252, .12);
border-color: rgba(125, 211, 252, .45);
}
.chip.active span {
color: var(--text);
}
.chip:hover {
transform: translateY(-1px);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.planet-key {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: .85rem;
background: rgba(255, 255, 255, .04);
border: 1px solid var(--panel-border);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 8px currentColor;
}
.footer {
position: fixed;
right: 16px;
bottom: 16px;
pointer-events: auto;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius);
background: linear-gradient(180deg, rgba(255, 255, 255, .06), rgba(255, 255, 255, .02));
border: 1px solid var(--panel-border);
box-shadow: var(--shadow);
}
.btn {
padding: 8px 12px;
border-radius: 10px;
border: 1px solid var(--panel-border);
background: rgba(255, 255, 255, .04);
color: var(--text);
cursor: pointer;
transition: transform .1s ease, background .2s ease, border-color .2s ease;
}
.btn:hover {
transform: translateY(-1px);
background: rgba(125, 211, 252, .1);
}
.btn:active {
transform: translateY(0);
}
.btn.primary {
background: linear-gradient(180deg, rgba(125, 211, 252, .25), rgba(125, 211, 252, .15));
border-color: rgba(125, 211, 252, .45);
}
.note {
color: var(--muted);
font-size: .85rem;
}
@media (max-width: 780px) {
.panel {
min-width: unset;
width: calc(100vw - 32px);
}
input[type="range"] {
width: 160px;
}
.hud {
left: 50%;
transform: translateX(-50%);
width: calc(100vw - 32px);
}
}
</style>
</head>
<body>
<canvas id="space"></canvas>
<div class="hud">
<div class="header">
<div class="logo" aria-hidden="true"></div>
<div>
<div class="title">Sonnensystem-Simulation</div>
<div class="subtitle">Echtzeit-Physik mit anpassbarem Zoom, Umlaufgeschwindigkeit und Gravitation</div>
</div>
</div>
<div class="panel">
<div class="controls">
<div class="control">
<div class="label">Zoom</div>
<div style="display:flex; align-items:center; gap:8px;">
<input id="zoom" type="range" min="0.25" max="4" step="0.01" value="1.4" />
<div class="value" id="zoomVal">1.40×</div>
</div>
</div>
<div class="control">
<div class="label">Umlaufgeschwindigkeit</div>
<div style="display:flex; align-items:center; gap:8px;">
<input id="speed" type="range" min="0.1" max="50" step="0.1" value="8" />
<div class="value"><span id="speedVal">8.0</span>×</div>
</div>
</div>
<div class="control">
<div class="label">Gravitation</div>
<div style="display:flex; align-items:center; gap:8px;">
<input id="gravity" type="range" min="0.2" max="2.5" step="0.01" value="1.00" />
<div class="value"><span id="gravityVal">1.00</span>×</div>
</div>
</div>
<div class="control">
<div class="label">Planeten-Interaktionen</div>
<div class="toggles">
<label class="chip" id="togglePP">
<input type="checkbox" />
<span>Ein/Aus</span>
</label>
</div>
</div>
<div class="control">
<div class="label">Spuren anzeigen</div>
<div class="toggles">
<label class="chip" id="toggleTrails">
<input type="checkbox" checked />
<span>Ein/Aus</span>
</label>
</div>
</div>
<div class="control">
<div class="label">Planetennamen</div>
<div class="toggles">
<label class="chip" id="toggleLabels">
<input type="checkbox" checked />
<span>Ein/Aus</span>
</label>
</div>
</div>
<div class="row">
<div class="label">Sichtskalierung Planeten</div>
<div style="display:flex; align-items:center; gap:8px;">
<input id="size" type="range" min="0.5" max="2.0" step="0.01" value="1.00" />
<div class="value"><span id="sizeVal">1.00</span>×</div>
</div>
</div>
</div>
<div class="legend" id="legend"></div>
</div>
</div>
<div class="footer">
<button class="btn primary" id="playPause">Pause</button>
<button class="btn" id="reset">Reset</button>
<div class="note">Tipp: Rad = Zoom, Leertaste = Pause</div>
<a class="brand" href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" rel="noreferrer">Built with
anycoder</a>
</div>
<script>
// --- Canvas setup with HiDPI ---
const canvas = document.getElementById('space');
const ctx = canvas.getContext('2d', { alpha: false, desynchronized: true });
let W = 0, H = 0, DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); // cap DPR for perf
function resize() {
const { clientWidth, clientHeight } = canvas;
DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
W = clientWidth;
H = clientHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
rebuildStarfield();
}
window.addEventListener('resize', resize);
resize();
// --- Controls ---
const zoomEl = document.getElementById('zoom');
const zoomValEl = document.getElementById('zoomVal');
const speedEl = document.getElementById('speed');
const speedValEl = document.getElementById('speedVal');
const gravityEl = document.getElementById('gravity');
const gravityValEl = document.getElementById('gravityVal');
const sizeEl = document.getElementById('size');
const sizeValEl = document.getElementById('sizeVal');
const togglePP = document.getElementById('togglePP');
const toggleTrails = document.getElementById('toggleTrails');
const toggleLabels = document.getElementById('toggleLabels');
const playPauseBtn = document.getElementById('playPause');
const resetBtn = document.getElementById('reset');
let isRunning = true;
playPauseBtn.addEventListener('click', () => {
isRunning = !isRunning;
playPauseBtn.textContent = isRunning ? 'Pause' : 'Start';
});
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
isRunning = !isRunning;
playPauseBtn.textContent = isRunning ? 'Pause' : 'Start';
}
});
// Wheel zoom
let targetZoom = parseFloat(zoomEl.value);
let zoom = targetZoom;
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = Math.sign(e.deltaY);
const step = 0.04;
targetZoom = clamp(targetZoom * (1 - delta * step), parseFloat(zoomEl.min), parseFloat(zoomEl.max));
zoomEl.value = targetZoom.toFixed(2);
updateZoomLabel();
}, { passive: false });
zoomEl.addEventListener('input', () => {
targetZoom = parseFloat(zoomEl.value);
updateZoomLabel();
});
function updateZoomLabel(){
zoomValEl.textContent = `${parseFloat(zoomEl.value).toFixed(2)}×`;
}
updateZoomLabel();
speedEl.addEventListener('input', () => {
speedValEl.textContent = parseFloat(speedEl.value).toFixed(1);
});
speedValEl.textContent = parseFloat(speedEl.value).toFixed(1);
gravityEl.addEventListener('input', () => {
gravityValEl.textContent = parseFloat(gravityEl.value).toFixed(2);
// Gravity changes orbital periods; reinitialize to keep planets on nice orbits with current settings
resetSystem();
});
gravityValEl.textContent = parseFloat(gravityEl.value).toFixed(2);
sizeEl.addEventListener('input', () => {
sizeValEl.textContent = parseFloat(sizeEl.value).toFixed(2);
});
sizeValEl.textContent = parseFloat(sizeEl.value).toFixed(2);
function setupChip(el){
const input = el.querySelector('input');
const sync = () => el.classList.toggle('active', input.checked);
input.addEventListener('change', sync);
el.addEventListener('click', (e) => {
if (e.target !== input) input.checked = !input.checked;
sync();
});
sync();
}
setupChip(togglePP);
setupChip(toggleTrails);
setupChip(toggleLabels);
resetBtn.addEventListener('click', () => resetSystem());
// --- Physics / Simulation ---
// Units: AU for distance, days for time, solar mass for mass.
// Gravitational constant in these units: G = k^2, where k = 0.01720209895 (Gaussian gravitational constant)
// => G = k^2 ≈ 0.0002959122082855911 AU^3 / (day^2 * solar_mass)
const G0 = 0.0002959122082855911;
const bodies = [];
const trailsEnabled = () => toggleTrails.querySelector('input').checked;
const labelsEnabled = () => toggleLabels.querySelector('input').checked;
const mutualGravityEnabled = () => togglePP.querySelector('input').checked;
const planetDefs = [
{ name:'Merkur', a:0.387098, mass:1.651e-7, color:'#b8b8b8' },
{ name:'Venus', a:0.723332, mass:2.447e-6, color:'#e6c07b' },
{ name:'Erde', a:1.000000, mass:3.003e-6, color:'#4da3ff' },
{ name:'Mars', a:1.523679, mass:3.213e-7, color:'#ff6b6b' },
{ name:'Jupiter',a:5.204267, mass:9.543e-4, color:'#d4a373' },
{ name:'Saturn', a:9.582017, mass:2.857e-4, color:'#e5c97d' },
{ name:'Uranus', a:19.189164, mass:4.365e-5, color:'#7dd3fc' },
{ name:'Neptun', a:30.069922, mass:5.149e-5, color:'#6ea8ff' },
];
const SUN = {
name:'Sonne', mass:1, color:'#ffd166', drawR: 18, isSun: true
};
// Visual scaling (not physical): planets' radii for visibility
const basePlanetR = { Merkur:2.8, Venus:4.2, Erde:4.6, Mars:3.2, Jupiter:9.5, Saturn:8.4, Uranus:6.0, Neptun:6.0 };
const pxPerAUBase = 75; // base pixels per AU at zoom=1.0
function getPxPerAU(){ return pxPerAUBase * zoom; }
function resetSystem(){
bodies.length = 0;
// Sun at origin, stationary
bodies.push({ name:SUN.name, mass:SUN.mass, color:SUN.color, drawR:SUN.drawR, isSun:true, x:0, y:0, vx:0, vy:0, trail:[] });
for (const p of planetDefs) {
const r = p.a; // circular start
const posAngle = Math.random() * Math.PI * 2;
const x = r * Math.cos(posAngle);
const y = r * Math.sin(posAngle);
// Circular orbit speed around Sun
const G = G0 * parseFloat(gravityEl.value);
const v = Math.sqrt(G * SUN.mass / r);
// Perpendicular to radius for circular orbit
const vx = -v * Math.sin(posAngle);
const vy = v * Math.cos(posAngle);
bodies.push({
name: p.name, mass: p.mass, color: p.color,
drawR: (basePlanetR[p.name] || 4) * parseFloat(sizeEl.value),
x, y, vx, vy, isSun:false, trail:[]
});
}
}
resetSystem();
// For display legend
const legendEl = document.getElementById('legend');
function rebuildLegend(){
legendEl.innerHTML = '';
const frag = document.createDocumentFragment();
for (const b of bodies) {
if (b.isSun) continue;
const el = document.createElement('div');
el.className = 'planet-key';
const dot = document.createElement('span');
dot.className = 'dot';
dot.style.color = b.color;
dot.style.background = b.color;
const name = document.createElement('span');
name.textContent = b.name;
el.appendChild(dot); el.appendChild(name);
frag.appendChild(el);
}
legendEl.appendChild(frag);
}
rebuildLegend();
// Starfield background
let stars = [];
function rebuildStarfield(){
const count = Math.floor(Math.sqrt(W*H) * 0.25); // scale with area
stars = [];
for (let i=0;i<count;i++){
stars.push({
x: Math.random()*W,
y: Math.random()*H,
r: Math.random()*1.2 + 0.3,
a: Math.random()*0.6 + 0.2,
tw: Math.random()*0.6 + 0.4,
ph: Math.random()*Math.PI*2
});
}
}
rebuildStarfield();
// Utility
function clamp(v, a, b){ return Math.max(a, Math.min(b, v)); }
// Integration: Leapfrog (Velocity Verlet)
let lastTime = performance.now();
const maxFrameDays = 2.0; // clamp to avoid huge steps on tab switch
function step(dtDays){
const G = G0 * parseFloat(gravityEl.value);
const n = bodies.length;
// First half-kick
for (let i=0; i<n; i++){
const bi = bodies[i];
if (bi.isSun) continue; // keep sun fixed
const ax1 = computeAccelX(i);
const ay1 = computeAccelY(i);
bi.vx += ax1 * (dtDays*0.5);
bi.vy += ay1 * (dtDays*0.5);
}
// Drift
for (let i=0;i<n;i++){
const b = bodies[i];
if (b.isSun) continue;
b.x += b.vx * dtDays;
b.y += b.vy * dtDays;
}
// Second half-kick
for (let i=0; i<n; i++){
const bi = bodies[i];
if (bi.isSun) continue;
const ax2 = computeAccelX(i);
const ay2 = computeAccelY(i);
bi.vx += ax2 * (dtDays*0.5);
bi.vy += ay2 * (dtDays*0.5);
}
// Trails
if (trailsEnabled()) {
for (const b of bodies) {
if (b.isSun) continue;
b.trail.push({x:b.x, y:b.y});
if (b.trail.length > 800) b.trail.shift();
}
} else {
for (const b of bodies) b.trail.length = 0;
}
}
function computeAccelX(i){
let ax = 0;
const bi = bodies[i];
for (let j=0; j<bodies.length; j++){
if (i === j) continue;
const bj = bodies[j];
const dx = bj.x - bi.x;
const dy = bj.y - bi.y;
const r2 = dx*dx + dy*dy;
// Avoid division by zero (same position)
if (r2 < 1e-12) continue;
const invR3 = 1 / Math.pow(r2, 1.5);
const G = G0 * parseFloat(gravityEl.value);
// If mutual gravity disabled, only gravitate towards Sun
const factor = (!mutualGravityEnabled() && !bj.isSun) ? 0 : 1;
ax += G * bj.mass * dx * invR3 * factor;
}
return ax;
}
function computeAccelY(i){
let ay = 0;
const bi = bodies[i];
for (let j=0; j<bodies.length; j++){
if (i === j) continue;
const bj = bodies[j];
const dx = bj.x - bi.x;
const dy = bj.y - bi.y;
const r2 = dx*dx + dy*dy;
if (r2 < 1e-12) continue;
const invR3 = 1 / Math.pow(r2, 1.5);
const G = G0 * parseFloat(gravityEl.value);
const factor = (!mutualGravityEnabled() && !bj.isSun) ? 0 : 1;
ay += G * bj.mass * dy * invR3 * factor;
}
return ay;
}
// Rendering
function worldToScreen(x, y){
const s = getPxPerAU();
return [ W/2 + x*s, H/2 + y*s ];
}
function draw(){
// Background
ctx.fillStyle = '#070d1a';
ctx.fillRect(0,0,W,H);
// Subtle vignette
const grad = ctx.createRadialGradient(W*0.5, H*0.55, Math.min(W,H)*0.2, W*0.5, H*0.55, Math.max(W,H)*0.75);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(1, 'rgba(0,0,0,0.35)');
ctx.fillStyle = grad;
ctx.fillRect(0,0,W,H);
// Stars (twinkle)
const t = performance.now() * 0.001;
for (const s of stars){
const tw = 0.65 + 0.35*Math.sin(t * s.tw + s.ph);
ctx.globalAlpha = s.a * tw;
ctx.fillStyle = '#cfe6ff';
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
// Trails
if (trailsEnabled()){
for (const b of bodies) {
if (b.isSun) continue;
ctx.beginPath();
for (let i=0;i<b.trail.length;i++){
const p = b.trail[i];
const [sx, sy] = worldToScreen(p.x, p.y);
if (i===0) ctx.moveTo(sx, sy);
else ctx.lineTo(sx, sy);
}
const last = b.trail[b.trail.length-1];
if (last){
const [sx, sy] = worldToScreen(last.x, last.y);
ctx.strokeStyle = b.color + 'cc';
ctx.lineWidth = 1.4;
ctx.stroke();
// Draw faint head dot
ctx.beginPath();
ctx.arc(sx, sy, 1.8, 0, Math.PI*2);
ctx.fillStyle = b.color + 'cc';
ctx.fill();
}
}
}
// Sun glow
{
const [sx, sy] = worldToScreen(0,0);
const r = SUN.drawR * 2.1 + 10;
const g = ctx.createRadialGradient(sx, sy, 2, sx, sy, r);
g.addColorStop(0, 'rgba(255,209,102,0.95)');
g.addColorStop(0.5, 'rgba(255,160,64,0.5)');
g.addColorStop(1, 'rgba(255,140,64,0.0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(sx, sy, r, 0, Math.PI*2);
ctx.fill();
}
// Planets
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.font = '12px ui-sans-serif, system-ui, Segoe UI, Roboto, Helvetica, Arial';
for (const b of bodies){
const [sx, sy] = worldToScreen(b.x, b.y);
if (b.isSun){
// Sun core
ctx.beginPath();
ctx.fillStyle = SUN.color;
ctx.shadowColor = '#ffae34';
ctx.shadowBlur = 20;
ctx.arc(sx, sy, SUN.drawR, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
if (labelsEnabled()){
ctx.fillStyle = '#ffdf9b';
ctx.fillText('Sonne', sx, sy + SUN.drawR + 6);
}
continue;
}
// Planet body
ctx.beginPath();
ctx.fillStyle = b.color;
ctx.shadowColor = b.color;
ctx.shadowBlur = 12;
ctx.arc(sx, sy, b.drawR, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
if (labelsEnabled()){
ctx.fillStyle = '#cfe6ff';
ctx.fillText(b.name, sx, sy + b.drawR + 6);
}
}
}
// Animation loop
function frame(now){
const elapsed = Math.min(0.1, (now - lastTime) / 1000); // seconds
lastTime = now;
// Smooth zoom towards target
zoom += (targetZoom - zoom) * 0.12;
if (isRunning){
// Convert elapsed to simulation days
let simDays = elapsed * parseFloat(speedEl.value);
simDays = Math.min(simDays, maxFrameDays);
// If mutual gravity toggled, ensure integrator rest always uses same dt
step(simDays);
}
draw();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// Initial values set
updateZoomLabel();
speedValEl.textContent = parseFloat(speedEl.value).toFixed(1);
gravityValEl.textContent = parseFloat(gravityEl.value).toFixed(2);
sizeValEl.textContent = parseFloat(sizeEl.value).toFixed(2);
// Update when size scaling changes
sizeEl.addEventListener('input', () => {
for (const b of bodies){
if (!b.isSun){
const base = basePlanetR[b.name] || 4;
b.drawR = base * parseFloat(sizeEl.value);
}
}
});
// Ensure planet labels/legend after reset
const observer = new MutationObserver(() => {});
// Optional: rebuild legend if someone changes language or so (not used here)
// In case DPR changes (move between screens)
window.matchMedia(`(resolution: ${DPR}dppx)`).addEventListener?.('change', resize);
// Helper: reset also called when gravity changes (set above)
// Accessibility: click anywhere on canvas toggles pause
canvas.addEventListener('click', () => {
isRunning = !isRunning;
playPauseBtn.textContent = isRunning ? 'Pause' : 'Start';
});
// Make sure resizing updates legend layout
window.addEventListener('resize', () => {
setTimeout(rebuildLegend, 50);
});
</script>
</body>
</html>