const G = 1.5e-3, // Controls overall simulation speed TRAIL_LENGTH = 250, // # simulation samples TRAIL_MAX_ALPHA = 0.5, // trail opacity upper limit TRAIL_THICKNESS = 0.08; // relative to body's diameter const width = window.innerWidth, height = window.innerHeight; let bodyDistortion = 1, lockOn, zoomLevel = 1, au = d3.scaleLinear() // Astronomical unit .range([0, Math.min(width, height)]); // Size canvas d3.select('#canvas') .attr('width', width) .attr('height', height) .call(d3.zoom() .scaleExtent([1, 1e6]) .on('zoom', function() { zoomed(d3.event.transform.k); }) ); const forceSim = d3.forceSimulation() .alphaDecay(0) .velocityDecay(0) .force('gravity', d3.forceMagnetic() .id(d => d.id) .charge(node => node.mass) ) .on('tick', ticked); // function ticked() { const TAU = 2*Math.PI, F = 1e8; // Scale factor, to prevent bug of (scaled) arcs with r<0.002 from disappearing const ctx = d3.select('#canvas') .attr('width', width) // Wipe it .attr('height', height) .node().getContext('2d'); // 0,0 at canvas center ctx.translate(width/2, height/2); // Apply zoom if (zoomLevel) { ctx.scale(zoomLevel/F, zoomLevel/F); } // Lock on body if (lockOn) { ctx.translate(-lockOn.x*F, -lockOn.y*F); } const nodes = forceSim.nodes(); for (let i=0; i TRAIL_LENGTH) node.trail.shift(); } } function zoomed(newZoomLevel=zoomLevel) { const changeRatio = zoomLevel/newZoomLevel, sqrtChangeRatio = Math.sqrt(changeRatio); zoomLevel = newZoomLevel; d3.select('#au-100px-scale').text(Math.round(au.invert(100) / zoomLevel * 1000) / 1000); // Slow down motion on zoom-in forceSim.stop(); forceSim.force('gravity').strength(forceSim.force('gravity').strength()()*changeRatio); forceSim.nodes().forEach(d => { d.vx *= sqrtChangeRatio; d.vy *= sqrtChangeRatio; }); forceSim.restart(); } function load(jsonFile) { d3.json(jsonFile, (error, bodies) => { const maxDistance = d3.max(bodies.map(d => d3.max(d.satellites.map(s => d.distance + s.distance)))); au.domain([0, maxDistance * 2.1]); zoomed(); // Display scale const pxG = G * Math.pow(au(1), 3); // in cube of AUs forceSim.nodes(parseBodies(bodies)) .force('gravity').strength(pxG); // Add lock radio buttons const bodyLock = d3.select('#bodylock').selectAll('div') .data(forceSim.nodes()).enter().append('div'); lockOn = forceSim.nodes()[0]; // Lock on first body bodyLock.append('input') .attr('type', 'radio') .attr('name', 'bodylock') .attr('id', d => `bodylock-${d.name}`) .attr('value', d => d.name) .attr('checked', d => d.name === 'sun' ? true : null) .on("change", function() { forceSim.nodes().some(d => { if (d.name === this.value) { lockOn = d; return true; } }); }); bodyLock.append('label') .attr('for', d => `bodylock-${d.name}`) .style('color', d => d.color) .text(d => `${d.symbol?`${d.symbol} `:''}${d.name}`); // function parseBodies(bodies, parentMass = 0, posOffset = [0,0], velocityOffset = [0,0]) { return [].concat(...bodies.map(body => { const ang = (body.phase || (Math.random() * 360)) * Math.PI/180, // Random init angle if not specified (to prevent aligned init forces from distorting orbits) x = posOffset[0] + au(body.distance) * Math.sin(ang), y = posOffset[1] - au(body.distance) * Math.cos(ang), relVelocity = (body.distance ? Math.sqrt(pxG * parentMass / au(body.distance)): 0) * (body.factorV || 1), // orbital velocity: sqrt(GM/d) vx = velocityOffset[0] + relVelocity * Math.cos(ang), vy = velocityOffset[1] + relVelocity * Math.sin(ang); return [{ name: body.name, symbol: body.symbol || null, color: body.color || 'darkgrey', r: au(body.r || Math.cbrt(body.mass)), mass: body.mass, // mass in solar masses x: x, // radius, distance & velocity in AUs y: y, vx: vx, vy: vy, trail: [] // Store previous positions }, ...parseBodies(body.satellites || [], body.mass, [x,y], [vx,vy]) ] })); } }); } // Event handlers function onBodyDistortionChange(dist) { bodyDistortion = dist; d3.select('#bodydistortion-val').text(dist); }