|
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<nodes.length; i++) { |
|
const node = nodes[i], |
|
r = Math.min(node.name==='sun'?2:Infinity, node.r * bodyDistortion), |
|
color = chroma(node.color); |
|
|
|
ctx.fillStyle = color.css(); |
|
ctx.beginPath(); |
|
ctx.arc(node.x*F, node.y*F, r*F, 0, TAU); |
|
ctx.fill(); |
|
|
|
// Add orbit trails |
|
const relAlpha = TRAIL_MAX_ALPHA/node.trail.length, |
|
trailR = r*TRAIL_THICKNESS*F, |
|
rgb = color.rgb().join(','); |
|
|
|
for (let idx=0, len=node.trail.length; idx<len; idx++) { |
|
const pnt = node.trail[idx]; |
|
|
|
ctx.fillStyle = `rgba(${rgb},${(idx+1)*relAlpha})`; |
|
ctx.beginPath(); |
|
ctx.arc(pnt[0]*F, pnt[1]*F, trailR, 0, TAU); |
|
//ctx.fillRect(pnt[0], pnt[1], trailR, trailR); // rects have better performance than arcs |
|
ctx.fill(); |
|
|
|
} |
|
|
|
// Push current coords to trail buffer |
|
node.trail.push([node.x, node.y]); |
|
if (node.trail.length > 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); |
|
} |