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 velocities: Float64Array = new Float64Array(0); private orbitPoints: Map = new Map(); private stationPositions: Float64Array = new Float64Array(0); private stationNames: string[] = []; private showStations: boolean = true; private routeLegs: { from: number; to: number }[] = []; 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, velocities?: Float64Array) { this.bodyInfos = infos; this.positions = positions; if (velocities) this.velocities = velocities; } updateOrbit(bodyId: number, points: Float64Array) { this.orbitPoints.set(bodyId, points); } updateStations(positions: Float64Array, names: string[], visible: boolean) { this.stationPositions = positions; this.stationNames = names; this.showStations = visible; } updateRoute(legs: { from: number; to: number }[]) { this.routeLegs = legs; } 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(i, x, y, info); } } } // Draw stations if (this.showStations && this.stationPositions.length > 0) { for (let i = 0; i < this.stationNames.length; i++) { const x = this.stationPositions[i * 3]; const y = this.stationPositions[i * 3 + 1]; this.drawStation(x, y, this.stationNames[i]); } } // Draw route legs if (this.routeLegs.length > 0 && this.stationPositions.length > 0) { this.drawRoute(); } // 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(bodyIndex: number, 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; // Velocity tail if (this.velocities.length > bodyIndex * 3 + 2) { const vx = this.velocities[bodyIndex * 3]; const vy = this.velocities[bodyIndex * 3 + 1]; const speed = Math.sqrt(vx * vx + vy * vy); if (speed > 1e-10) { // Scale tail length: normalize velocity and multiply by a visual factor const tailScale = this.getScale() * 0.15; // pixels per (AU/day) const tx = sx + vx * tailScale; const ty = sy - vy * tailScale; // flip Y // Gradient tail from body color to transparent const gradient = ctx.createLinearGradient(sx, sy, tx, ty); gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.6)`); gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`); ctx.strokeStyle = gradient; ctx.lineWidth = size * 0.8; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(tx, ty); ctx.stroke(); } } // 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 drawRoute() { const ctx = this.ctx; const colors = ['#ff6a33', '#33ff88', '#ff33aa', '#33aaff', '#ffaa33']; for (let i = 0; i < this.routeLegs.length; i++) { const leg = this.routeLegs[i]; const fromX = this.stationPositions[leg.from * 3]; const fromY = this.stationPositions[leg.from * 3 + 1]; const toX = this.stationPositions[leg.to * 3]; const toY = this.stationPositions[leg.to * 3 + 1]; const [sx1, sy1] = this.auToScreen(fromX, fromY); const [sx2, sy2] = this.auToScreen(toX, toY); const color = colors[i % colors.length]; // Draw line ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(sx1, sy1); ctx.lineTo(sx2, sy2); ctx.stroke(); ctx.setLineDash([]); // Arrow head const angle = Math.atan2(sy2 - sy1, sx2 - sx1); const arrowLen = 8; ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(sx2, sy2); ctx.lineTo( sx2 - arrowLen * Math.cos(angle - 0.3), sy2 - arrowLen * Math.sin(angle - 0.3), ); ctx.lineTo( sx2 - arrowLen * Math.cos(angle + 0.3), sy2 - arrowLen * Math.sin(angle + 0.3), ); ctx.closePath(); ctx.fill(); // Leg number const mx = (sx1 + sx2) / 2; const my = (sy1 + sy2) / 2; ctx.fillStyle = color; ctx.font = 'bold 10px monospace'; ctx.fillText(`${i + 1}`, mx + 5, my - 5); } } private drawStation(xAU: number, yAU: number, name: string) { const ctx = this.ctx; const [sx, sy] = this.auToScreen(xAU, yAU); // Diamond shape const s = 3; ctx.fillStyle = 'rgba(74, 158, 255, 0.6)'; ctx.strokeStyle = 'rgba(74, 158, 255, 0.8)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(sx, sy - s); ctx.lineTo(sx + s, sy); ctx.lineTo(sx, sy + s); ctx.lineTo(sx - s, sy); ctx.closePath(); ctx.fill(); ctx.stroke(); // Label (only show when zoomed in enough) if (this.getScale() > 50) { ctx.fillStyle = 'rgba(74, 158, 255, 0.5)'; ctx.font = '8px monospace'; ctx.fillText(name, sx + s + 3, 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); } }