const OrbitalCanvas = Kapsule({ props: { initialV: {}, initialVAngle: { default: -90 }, // 0: right, 90 down numSamples: { default: 5000 } }, init(domElem, state, { width = window.innerWidth, height = window.innerHeight}) { state.width = width; state.height = height; const orbitalD = state.width/3; state.satelliteInit = { x: -orbitalD, y: 0 }; state.stars = [{ x: -state.width*.2, y: 0}, { x: state.width*.2, y: 0}]; // Proportional to cube of satellite distance to maintain behavior over different widths const G = 5e-4 * Math.pow(orbitalD, 3); // Determines motion speed state.totalMass = 1; if (state.initialV === null) { // Generate default const magicRatio = 1.131; // Sync for approx H (1.131) or eight-shaped (1.308) orbit in default layout state.initialV = Math.sqrt(magicRatio * G * state.totalMass / orbitalD); } state.forceSim = d3.forceSimulation() .alphaDecay(0) .velocityDecay(0) .stop() .force('gravity', d3.forceMagnetic() .strength(G) .charge(d => d.mass) ) .on('tick', () => { state.elSatellite .attr('cx', d => d.x) .attr('cy', d => d.y); }); // Dom init state.trails = d3.select(domElem) .append('canvas') .attr('class', 'trails'); state.canvas = d3.select(domElem) .append('svg') .attr('class', 'scaffold') .attr('width', state.width) .attr('height', state.height) .attr('viewBox', `${-state.width/2} ${-state.height/2} ${state.width} ${state.height}`) .on('dblclick', () => { d3.event.stopPropagation(); state.stars.push({ x: d3.event.x-state.width/2, y: d3.event.y-state.height/2 }); state._rerender(); }); state.elSatellite = state.canvas.append('circle').attr('class', 'satellite'); state.canvas.append('circle').attr('class', 'ghost') .call(d3.drag().on('drag', () => { state.satelliteInit.x = d3.event.x; state.satelliteInit.y = d3.event.y; state._rerender(); })); d3.select(domElem).append('div').attr('class', 'info') .text('double-click to add/remove | click-drag to reposition'); }, update(state) { const satellite = { mass: 0 }; state.forceSim.stop(); resetNodes(); satelliteAnchorDigest(); starDigest(); // Predict orbit trajectories // Clear canvas const ctx = state.trails .attr('width', state.width) .attr('height', state.height) .node() .getContext('2d'); ctx.translate(state.width/2, state.height/2); ctx.fillStyle = 'rgba(0, 0, 75, .6)'; d3.range(state.numSamples).forEach(() => { state.forceSim.tick(); ctx.beginPath(); ctx.fillRect(satellite.x, satellite.y, 1.4, 1.4); ctx.fill(); }); // Animate satellite resetNodes(); state.elSatellite.datum(satellite); state.forceSim.restart(); // function resetNodes() { satellite.x = state.satelliteInit.x; satellite.y = state.satelliteInit.y; satellite.vx = state.initialV * Math.cos(state.initialVAngle*Math.PI/180); satellite.vy = state.initialV * Math.sin(state.initialVAngle*Math.PI/180); state.forceSim.nodes([ satellite, ...state.stars.map(star => ({ x: star.x, y: star.y, fx: star.x, fy: star.y, mass: state.totalMass/state.stars.length })) ]); } function satelliteAnchorDigest() { state.canvas.select('.ghost').datum(state.satelliteInit) .attr('cx', d => d.x) .attr('cy', d => d.y); } function starDigest() { const star = state.canvas.selectAll('.star').data(state.stars); star.exit().remove(); star.merge( star.enter() .append('circle').attr('class', 'star') .call(d3.drag().on('drag', d => { d.x = d3.event.x; d.y = d3.event.y; state._rerender(); })) .on('dblclick', d => { d3.event.stopPropagation(); state.stars.splice(state.stars.indexOf(d), 1); state._rerender(); }) ) .attr('cx', d => d.x) .attr('cy', d => d.y); } } });