diff --git a/config.js b/config.js index 9de7133..e87478e 100644 --- a/config.js +++ b/config.js @@ -6,6 +6,7 @@ export const DISPLAY_CURRENT_SCALE = true; export const DISPLAY_CURRENT_MODE = false; export const DISPLAY_VELOCITY_VECTORS = true; export const DISPLAY_ACCELERATION_VECTORS = true; +export const DISPLAY_PATH_TRACES = false; // VELOCITY export const VELOCITY_VECTOR_SCALE = 5E0; @@ -19,6 +20,11 @@ export const ACCELERATION_VECTOR_COLOR = 'rgb(0, 128, 0)'; // optionally set to export const ACCELERATION_VECTOR_WIDTH = 1.5; export const ACCELERATION_VECTOR_ARROWHEAD = true; +// PATH TRACES +// export const PATH_TRACES_COLOR = 'rgb(128, 128, 0)'; // optionally set to 'object color' +export const PATH_TRACES_COLOR = 'object color'; +export const PATH_TRACES_WIDTH = 1.5; + // SCALING FACTORS export const MASS_CREATION_RATE = 1E1; export const POINTER_HISTORY_SIZE = 15; @@ -45,3 +51,4 @@ export const MODE_PAN_VIEW = 'pan-view'; // OPTIONS export const PAUSE_DURING_CREATION = true; export const PAUSE_DURING_SELECTION = true; +export const MERGE_ON_COLLIDE = true; diff --git a/display.js b/display.js index c43d707..1908bb8 100644 --- a/display.js +++ b/display.js @@ -13,8 +13,8 @@ import { OFFSCREEN_OBJECT_LINE_WIDTH, OFFSCREEN_OBJECT_ARROWHEAD_LENGTH, DISPLAY_CANVAS_SIZE, - DISPLAY_VELOCITY_VECTORS, - DISPLAY_ACCELERATION_VECTORS, + PATH_TRACES_COLOR, + PATH_TRACES_WIDTH, } from './config.js'; export class Display { @@ -63,6 +63,13 @@ export class Display { }; } + simToScreen(x, y) { + return { + x: (x - this.viewOrigin.x) * this.scale, + y: (y - this.viewOrigin.y) * this.scale, + }; + } + fullscreen() { this.canvas.width = document.documentElement.clientWidth; this.canvas.height = document.documentElement.clientHeight; @@ -77,8 +84,7 @@ export class Display { ctx.fillRect(this.viewOrigin.x, this.viewOrigin.y, this.width, this.height); } - drawObject(idx) { - const obj = this.sim.objects.object(idx); + drawObject(obj) { const ctx = this.ctx; const {r, g, b} = obj.color; const {x, y} = obj.position; @@ -90,6 +96,47 @@ export class Display { const oy = this.viewOrigin.y; const cx = ox + W / 2; const cy = oy + H / 2; + + // Draw path traces + if (this.sim.getOption('display.traces') && obj.history?.length) { + ctx.strokeStyle = PATH_TRACES_COLOR === 'object color' ? + `rgb(${r}, ${g}, ${b})` : PATH_TRACES_COLOR; + ctx.lineWidth = PATH_TRACES_WIDTH / this.scale; + ctx.beginPath(); + let dash = false; + const skip = 1; + let skipped = 0; + { + const [{position}] = obj.history; + const x = position.x; + const y = position.y; + + if (Math.abs(x - cx) <= W / 2 && + Math.abs(y - cy) <= H / 2) { + ctx.moveTo(x, y); + dash = true; + } + } + for (let i = 1; i < obj.history.length; i++) { + if (++skipped < skip) continue; + skipped = 0; + const {position} = obj.history[i]; + const x = position.x; + const y = position.y; + if (dash) { + ctx.lineTo(x, y); + dash = false; + } else if (Math.abs(x - cx) <= W / 2 && + Math.abs(y - cy) <= H / 2) { + ctx.moveTo(x, y); + dash = true; + } + } + ctx.stroke(); + } + + if (!obj.alive) return; + // If the object is outside the display area, draw an arrow at the edge of the display if (Math.abs(x - cx) - radius >= W / 2 || Math.abs(y - cy) - radius >= H / 2) { @@ -146,7 +193,7 @@ export class Display { ctx.fill(); // Draw arrow for the velocity - if (DISPLAY_VELOCITY_VECTORS) { + if (this.sim.getOption('show.velocity')) { const speed = Math.sqrt(vx ** 2 + vy ** 2); const endVx = x + VELOCITY_VECTOR_SCALE * vx / speed * Math.log(speed); const endVy = y + VELOCITY_VECTOR_SCALE * vy / speed * Math.log(speed); @@ -162,7 +209,7 @@ export class Display { } // Draw arrow for acceleration - if (DISPLAY_ACCELERATION_VECTORS) { + if (this.sim.getOption('show.acceleration')) { const accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2); const endAx = x + ACCELERATION_VECTOR_SCALE * acceleration.x / accelerationMagnitude * Math.log(accelerationMagnitude); @@ -181,9 +228,7 @@ export class Display { } drawObjects() { - for (let i = 0; i < this.sim.objects.length; i++) { - this.drawObject(i); - } + this.sim.objects.forEachObject(obj => this.drawObject(obj), null); } drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength, fill, ifShort}) { diff --git a/index.html b/index.html index 7a84a0e..b74fc83 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ body { background-color: #000; color: #8f8; font-family: monospace; - font-size: 14pt; + font-size: 12pt; overflow: hidden; user-select: none; -webkit-user-select: none; diff --git a/object.js b/object.js index 9bdfd0b..d05daab 100644 --- a/object.js +++ b/object.js @@ -1,5 +1,4 @@ export class MassObject { - index = undefined; mass = 0; density = 1; position = {x: undefined, y: undefined}; @@ -8,9 +7,10 @@ export class MassObject { color = {r: undefined, g: undefined, b: undefined}; created = undefined; forces = []; // [{x, y}] + history = []; + alive = true; - constructor(x, y, index) { - this.index = index; + constructor(x, y) { this.position.x = x; this.position.y = y; this.color.r = Math.random() * 256; diff --git a/objects.js b/objects.js index 112e975..2ea8c93 100644 --- a/objects.js +++ b/objects.js @@ -83,8 +83,7 @@ export class Objects { start: {x: undefined, y: undefined}, end: {x: undefined, y: undefined}, }; - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; + this.forEachObject(obj => { if (box.start.x === undefined) { box.start = {...obj.position}; box.end = {...obj.position}; @@ -94,19 +93,18 @@ export class Objects { if (obj.position.y < box.start.y) box.start.y = obj.position.y; if (obj.position.y > box.end.y) box.end.y = obj.position.y; } - } + }); return box; } objectAtLocation(x, y) { - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; + this.forEachObject(obj => { // If distance to object is less than object's radius, we are touching the object const dist = Math.pow((obj.position.x - x)**2 + (obj.position.y - y)**2, 1/2); if (dist <= obj.radius) { return i; } - } + }); } handlePointerDown({x, y}) { @@ -138,15 +136,23 @@ export class Objects { } } + // cb: (obj, idx) => {} + forEachObject(cb, alive = true, startWith = 0) { + for (let i = startWith; i < this.objects.length; i++) { + const obj = this.objects[i]; + if (alive === null || alive == obj.alive) { + cb(obj, i); + } + } + } + computeForces() { if (this.objects.length < 2) return; - for (let i = 0; i < this.objects.length; i++) { - this.objects[i].forces = []; - } - for (let i = 0; i < this.objects.length; i++) { - const A = this.objects[i]; - for (let j = i + 1; j < this.objects.length; j++) { - const B = this.objects[j]; + this.forEachObject(obj => { + obj.forces = []; + }); + this.forEachObject((A, i) => { + this.forEachObject(B => { const dx = (B.position.x - A.position.x); const dy = (B.position.y - A.position.y); const dSquared = dx ** 2 + dy ** 2; @@ -156,14 +162,12 @@ export class Objects { const Fy = F * dy / d; A.forces.push({ x: Fx, y: Fy }); B.forces.push({ x: -Fx, y: -Fy }); - } - } + }, true, i + 1); + }); // Also compute acceleration - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; + this.forEachObject(obj => { obj.acceleration = obj.getAcceleration(); - } - + }); } computeFrame(elapsedTime) { @@ -181,22 +185,55 @@ export class Objects { if (this.sim.playing) { // Predict positions (Velocity verlet method) - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; + this.forEachObject(obj => { obj.currentAcceleration = {...obj.acceleration}; obj.position.x += elapsedTime * (obj.velocity.x + 1/2 * obj.currentAcceleration.x * elapsedTime); obj.position.y += elapsedTime * (obj.velocity.y + 1/2 * obj.currentAcceleration.y * elapsedTime); - } + }); + + // Collisions + this.forEachObject((A, i) => { + this.forEachObject((B, j) => { + const dx = (B.position.x - A.position.x); + const dy = (B.position.y - A.position.y); + const dSquared = dx ** 2 + dy ** 2; + const d = Math.sqrt(dSquared); + if (d < A.radius + B.radius) { + // Merge B into A: + // Set position = center of mass + // Set velocity = total momentum / total mass + // Combine forces + // Add masses + // Average color? + // If B was selected or being created, select A instead + // Remove B using splice + A.position.x = (A.position.x * A.mass + B.position.x * B.mass) / (A.mass + B.mass); + A.position.y = (A.position.y * A.mass + B.position.y * B.mass) / (A.mass + B.mass); + A.velocity.x = (A.velocity.x * A.mass + B.velocity.x * B.mass) / (A.mass + B.mass); + A.velocity.y = (A.velocity.y * A.mass + B.velocity.y * B.mass) / (A.mass + B.mass); + A.forces.push(...B.forces); + A.mass += B.mass; + A.color = { + r: (A.color.r + B.color.r) / 2, + g: (A.color.g + B.color.g) / 2, + b: (A.color.b + B.color.b) / 2, + }; + if (this.creatingObject === j) this.creatingObject = i; + if (this.selectedObject === j) this.selectedObject = i; + B.alive = false; + B.forces = []; + } + }, true, i + 1); + }); // Recompute forces this.computeForces(); // Predict velocities - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; + this.forEachObject(obj => { const acceleration = {...obj.acceleration}; obj.acceleration = { x: (obj.currentAcceleration.x + acceleration.x) / 2, @@ -204,15 +241,17 @@ export class Objects { }; obj.velocity.x += obj.acceleration.x * elapsedTime; obj.velocity.y += obj.acceleration.y * elapsedTime; - } - // TODO: Collisions + // Append to object history + // TODO: enforce object history length + // TODO: store object color changes in history + obj.history.push({position: {...obj.position}}); + }); } // Display objects info if (DISPLAY_OBJECTS_INFO) { - for (let i = 0; i < this.objects.length; i++) { - const obj = this.objects[i]; + this.forEachObject(obj => { const speed = Math.pow(obj.velocity.x ** 2 + obj.velocity.y ** 2, 1/2); // Invert y so that the angle is counterclockwise from x-axis const direction = Math.atan2(-obj.velocity.y, obj.velocity.x) * 180 / Math.PI; @@ -222,7 +261,7 @@ export class Objects { `${obj.mass.toPrecision(6)} kg, `, `${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`, ]; - } + }); } } } diff --git a/tool/options.js b/tool/options.js index ef5ca69..9822602 100644 --- a/tool/options.js +++ b/tool/options.js @@ -5,6 +5,7 @@ import { DISPLAY_VELOCITY_VECTORS, PAUSE_DURING_CREATION, PAUSE_DURING_SELECTION, + MERGE_ON_COLLIDE, } from '../config.js'; export class Options extends Tool { @@ -13,13 +14,18 @@ export class Options extends Tool { items: [ { type: 'boolean', name: 'creation', title: 'Creation', default: PAUSE_DURING_CREATION }, { type: 'boolean', name: 'selection', title: 'Selection', default: PAUSE_DURING_SELECTION }, - ]}, { - type: 'group', name: 'show', title: 'Show Vectors', + ]}, { + type: 'group', name: 'display', title: 'Display', items: [ { type: 'boolean', name: 'velocity', title: 'Velocity', default: DISPLAY_VELOCITY_VECTORS }, { type: 'boolean', name: 'acceleration', title: 'Acceleration', default: DISPLAY_ACCELERATION_VECTORS }, - ] - }]; + { type: 'boolean', name: 'traces', title: 'Trace Path', default: DISPLAY_ACCELERATION_VECTORS }, + ]}, { + type: 'group', name: 'collision', title: 'Collisions', + items: [ + { type: 'boolean', name: 'merge', title: 'Merge', default: MERGE_ON_COLLIDE }, + ]}, + ]; values = {}; @@ -58,6 +64,7 @@ export class Options extends Tool { const heading = document.createElement('h2'); heading.innerHTML = 'Options'; this.div.appendChild(heading); + this.div.style.paddingTop = '0px'; for (const item of this.options) { const child = this.visitItem(item); this.div.appendChild(child); diff --git a/tool/zoom.js b/tool/zoom.js index 38307ba..2d6b993 100644 --- a/tool/zoom.js +++ b/tool/zoom.js @@ -11,22 +11,18 @@ export class Zoom extends Tool { const zoomOut = document.createElement('button'); const zoomIn = document.createElement('button'); const zoomAll = document.createElement('button'); - const zeroNetMomentum = document.createElement('button'); this.div.appendChild(zoomOut); this.div.appendChild(zoomIn); this.div.appendChild(document.createElement('br')); this.div.appendChild(zoomAll); this.div.appendChild(document.createElement('br')); - this.div.appendChild(zeroNetMomentum); zoomAll.classList.add('wide'); - zeroNetMomentum.classList.add('wide'); zoomOut.innerHTML = '

Zoom
Out

'; zoomIn.innerHTML = '

Zoom
In

'; zoomAll.innerHTML = '

Zoom to Fit

'; - zeroNetMomentum.innerHTML = '

Zero Net Momentum

'; zoomOut.addEventListener('click', (e) => { // Aim at center of view @@ -60,32 +56,5 @@ export class Zoom extends Tool { } } }); - - zeroNetMomentum.addEventListener('click', (e) => { - const { objects } = this.sim.objects; - - // Find total momentum - let totalMomentum = objects.reduce((total, obj) => { - const px = obj.mass * obj.velocity.x; - const py = obj.mass * obj.velocity.y; - return { - x: total.x + px, - y: total.y + py, - }; - }, {x: 0, y: 0}); - - // Find average momentum - let averageMomentum = { - x: totalMomentum.x / objects.length, - y: totalMomentum.y / objects.length, - }; - - // Subtract average from each - for (let i = 0; i < objects.length; i++) { - const obj = objects[i]; - obj.velocity.x -= averageMomentum.x / obj.mass; - obj.velocity.y -= averageMomentum.y / obj.mass; - } - }); } } diff --git a/toolbar.js b/toolbar.js index 24e6188..7b37ff6 100644 --- a/toolbar.js +++ b/toolbar.js @@ -10,7 +10,7 @@ export class Toolbar { this.div = div; this.sim.div.appendChild(div); div.style.position = 'relative'; - div.style.width = 'fit-content'; + div.style.width = '20EM'; div.style.top = 0; div.style.left = 0; div.style.zIndex = 2;