import type { BodyInfo } from '$lib/wasm/types'; import { rgbToCss } from '$lib/utils/colors'; const AU_TO_PIXELS_BASE = 80; // pixels per AU at zoom level 1 interface Camera2D { x: number; // center offset in AU y: number; zoom: number; } export class SolarSystem2DRenderer { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private camera: Camera2D = { x: 0, y: 0, zoom: 1 }; private bodyInfos: BodyInfo[] = []; private positions: Float64Array = new Float64Array(0); private orbitPoints: Map = new Map(); private isDragging = false; private lastMouse = { x: 0, y: 0 }; private animFrameId: number = 0; constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; this.ctx = canvas.getContext('2d')!; this.setupEventListeners(); } private setupEventListeners() { this.canvas.addEventListener('wheel', (e) => { e.preventDefault(); const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; this.camera.zoom *= zoomFactor; this.camera.zoom = Math.max(0.05, Math.min(50, this.camera.zoom)); }); this.canvas.addEventListener('mousedown', (e) => { this.isDragging = true; this.lastMouse = { x: e.clientX, y: e.clientY }; }); this.canvas.addEventListener('mousemove', (e) => { if (!this.isDragging) return; const scale = this.getScale(); this.camera.x -= (e.clientX - this.lastMouse.x) / scale; this.camera.y -= (e.clientY - this.lastMouse.y) / scale; this.lastMouse = { x: e.clientX, y: e.clientY }; }); this.canvas.addEventListener('mouseup', () => { this.isDragging = false; }); this.canvas.addEventListener('mouseleave', () => { this.isDragging = false; }); } private getScale(): number { return AU_TO_PIXELS_BASE * this.camera.zoom; } private auToScreen(xAU: number, yAU: number): [number, number] { const scale = this.getScale(); const rect = this.canvas.getBoundingClientRect(); const cx = rect.width / 2; const cy = rect.height / 2; return [ cx + (xAU - this.camera.x) * scale, cy - (yAU - this.camera.y) * scale, // flip Y for screen coords ]; } updateBodies(infos: BodyInfo[], positions: Float64Array) { this.bodyInfos = infos; this.positions = positions; } updateOrbit(bodyId: number, points: Float64Array) { this.orbitPoints.set(bodyId, points); } render() { const { canvas, ctx } = this; const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); // Clear ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, rect.width, rect.height); // Draw grid this.drawGrid(rect.width, rect.height); // Draw orbit ellipses for (const [bodyId, points] of this.orbitPoints) { if (bodyId < this.bodyInfos.length) { this.drawOrbit(points, this.bodyInfos[bodyId]); } } // Draw bodies on top if (this.positions.length > 0) { for (let i = 0; i < this.bodyInfos.length; i++) { const info = this.bodyInfos[i]; const x = this.positions[i * 3]; const y = this.positions[i * 3 + 1]; if (i === 0) { this.drawSun(x, y, info); } else { this.drawBody(x, y, info); } } } // Scale indicator this.drawScaleBar(rect.width, rect.height); } private drawGrid(w: number, h: number) { const ctx = this.ctx; const scale = this.getScale(); // Determine grid spacing in AU let gridAU = 1; const pixelsPerGrid = gridAU * scale; if (pixelsPerGrid < 30) gridAU = 5; if (pixelsPerGrid > 200) gridAU = 0.5; if (pixelsPerGrid > 400) gridAU = 0.1; ctx.strokeStyle = 'rgba(255, 255, 255, 0.04)'; ctx.lineWidth = 0.5; // Vertical lines const startXAU = Math.floor((this.camera.x - w / 2 / scale) / gridAU) * gridAU; const endXAU = Math.ceil((this.camera.x + w / 2 / scale) / gridAU) * gridAU; for (let xAU = startXAU; xAU <= endXAU; xAU += gridAU) { const [sx] = this.auToScreen(xAU, 0); ctx.beginPath(); ctx.moveTo(sx, 0); ctx.lineTo(sx, h); ctx.stroke(); } // Horizontal lines const startYAU = Math.floor((this.camera.y - h / 2 / scale) / gridAU) * gridAU; const endYAU = Math.ceil((this.camera.y + h / 2 / scale) / gridAU) * gridAU; for (let yAU = startYAU; yAU <= endYAU; yAU += gridAU) { const [, sy] = this.auToScreen(0, yAU); ctx.beginPath(); ctx.moveTo(0, sy); ctx.lineTo(w, sy); ctx.stroke(); } // Concentric orbit reference circles (centered on Sun) ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)'; const [sunX, sunY] = this.auToScreen(0, 0); for (let r = 1; r <= 40; r++) { const radiusPx = r * scale; if (radiusPx < 10 || radiusPx > 5000) continue; ctx.beginPath(); ctx.arc(sunX, sunY, radiusPx, 0, Math.PI * 2); ctx.stroke(); } } private drawOrbit(points: Float64Array, info: BodyInfo) { if (points.length < 6) return; const ctx = this.ctx; const [r, g, b] = info.color; ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, 0.2)`; ctx.lineWidth = 0.8; ctx.beginPath(); const [sx0, sy0] = this.auToScreen(points[0], points[1]); ctx.moveTo(sx0, sy0); for (let i = 3; i < points.length; i += 3) { const [sx, sy] = this.auToScreen(points[i], points[i + 1]); ctx.lineTo(sx, sy); } ctx.closePath(); ctx.stroke(); } private drawSun(xAU: number, yAU: number, info: BodyInfo) { const ctx = this.ctx; const [sx, sy] = this.auToScreen(xAU, yAU); const [r, g, b] = info.color; // Glow const gradient = ctx.createRadialGradient(sx, sy, 0, sx, sy, 30); gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.8)`); gradient.addColorStop(0.3, `rgba(${r}, ${g}, ${b}, 0.3)`); gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(sx, sy, 30, 0, Math.PI * 2); ctx.fill(); // Core ctx.fillStyle = rgbToCss(r, g, b); ctx.beginPath(); ctx.arc(sx, sy, 6, 0, Math.PI * 2); ctx.fill(); // Label ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; ctx.font = '10px monospace'; ctx.fillText(info.name, sx + 10, sy + 4); } private drawBody(xAU: number, yAU: number, info: BodyInfo) { const ctx = this.ctx; const [sx, sy] = this.auToScreen(xAU, yAU); const [r, g, b] = info.color; // Size based on radius (with minimum for visibility) let size = Math.max(2, Math.log10(info.radius_km / 1000) * 2); // Moons are smaller if (info.radius_km < 3000) size = 1.5; // Body dot ctx.fillStyle = rgbToCss(r, g, b); ctx.beginPath(); ctx.arc(sx, sy, size, 0, Math.PI * 2); ctx.fill(); // Subtle glow ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.15)`; ctx.beginPath(); ctx.arc(sx, sy, size * 3, 0, Math.PI * 2); ctx.fill(); // Label ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; ctx.font = '9px monospace'; ctx.fillText(info.name, sx + size + 4, sy + 3); } private drawScaleBar(w: number, h: number) { const ctx = this.ctx; const scale = this.getScale(); // Find a nice round AU value for the bar let barAU = 1; let barPx = barAU * scale; if (barPx > 200) { barAU = 0.5; barPx = barAU * scale; } if (barPx > 200) { barAU = 0.1; barPx = barAU * scale; } if (barPx < 30) { barAU = 5; barPx = barAU * scale; } if (barPx < 30) { barAU = 10; barPx = barAU * scale; } const x = 20; const y = h - 20; ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + barPx, y); ctx.stroke(); // End caps ctx.beginPath(); ctx.moveTo(x, y - 4); ctx.lineTo(x, y + 4); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x + barPx, y - 4); ctx.lineTo(x + barPx, y + 4); ctx.stroke(); ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; ctx.font = '10px monospace'; ctx.fillText(`${barAU} AU`, x + barPx / 2 - 15, y - 8); } destroy() { if (this.animFrameId) cancelAnimationFrame(this.animFrameId); } }