Spaces:
Running
Running
| <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> |