import { ACCELERATION_VECTOR_ARROWHEAD, ACCELERATION_VECTOR_COLOR, ACCELERATION_VECTOR_WIDTH, OFFSCREEN_OBJECT_ARROWHEAD_LENGTH, OFFSCREEN_OBJECT_LINE_SCALE, OFFSCREEN_OBJECT_LINE_WIDTH, PATH_TRACES_COLOR, PATH_TRACES_DASHED_OPACITY, PATH_TRACES_OPACITY, PATH_TRACES_WIDTH, SELECTED_GROUP_COLOR, SELECTED_MARGIN, SELECTED_SINGLE_COLOR, VELOCITY_VECTOR_ARROWHEAD, VELOCITY_VECTOR_COLOR, VELOCITY_VECTOR_WIDTH, } from './config.js'; import {add, components, copy, direction, div, magnitude, mult, square, zero} from './vector.js'; export class MassObject { sim = undefined; id = undefined; mass = 0; density = 1; position = {x: undefined, y: undefined}; velocity = zero; acceleration = zero; color = {r: undefined, g: undefined, b: undefined}; timeCreated = undefined; forces = []; // [{x, y}] history = []; alive = true; workDoneByPointer = 0; workDoneByForces = 0; currentPosition = undefined; currentAcceleration = undefined; constructor(sim, x, y) { this.sim = sim; this.id = crypto.randomUUID(); this.position.x = x; this.position.y = y; this.color.r = Math.random() * 256; this.color.g = Math.random() * 256; this.color.b = Math.random() * 256; this.timeCreated = this.sim.time; } toJSON() { return { id: this.id, mass: this.mass, density: this.density, position: this.position, velocity: this.velocity, color: this.color, timeCreated: this.timeCreated, alive: this.alive, workDoneByPointer: this.workDoneByPointer, workDoneByForces: this.workDoneByForces, }; } fromJSON(obj) { this.id = obj.id; this.mass = obj.mass; this.density = obj.density; this.position = copy(obj.position); this.velocity = copy(obj.velocity); this.color = obj.color; this.timeCreated = obj.timeCreated; this.alive = obj.alive; this.history = []; this.workDoneByPointer = obj.workDoneByPointer; this.workDoneByForces = obj.workDoneByForces; } get age() { return this.sim.time - this.timeCreated; } get radius() { // radius should be proportional to cube root of mass return Math.pow(this.mass / this.density, 1 / 3); } getAcceleration() { const netForce = this.forces.reduce((t, f) => add(t, f), zero); return div(netForce, this.mass); } get kineticEnergy() { return this.mass * square(this.velocity) / 2; } get momentum() { return mult(this.mass, this.velocity); } drawPath(display) { const {ctx} = display; const {color: {r, g, b}} = this; // Draw path traces if (this.sim.getOption('display.traces') && this.history?.length) { const dashedTraces = this.sim.getOption('display.dashedTraces'); const opacity = dashedTraces ? PATH_TRACES_DASHED_OPACITY : PATH_TRACES_OPACITY; ctx.strokeStyle = PATH_TRACES_COLOR === 'object color' ? `rgba(${r}, ${g}, ${b}, ${opacity})` : PATH_TRACES_COLOR; ctx.lineWidth = PATH_TRACES_WIDTH / display.scale; ctx.beginPath(); let dash = false; for (let i = 0; i < this.history.length; i++) { // if (i % 2 > 0) continue; const {position: {x, y}} = this.history[i]; if (dashedTraces) { if (dash) { ctx.lineTo(x, y); } else { ctx.moveTo(x, y); } dash = !dash; } else { ctx.lineTo(x, y); } } ctx.stroke(); } } drawArrows(display) { const { color: {r, g, b}, position: {x, y}, velocity: {x: vx, y: vy}, acceleration, radius, } = this; const { height: H, width: W, viewOrigin: {x: ox, y: oy}, } = display; const cx = ox + W / 2; const cy = oy + H / 2; if (!this.alive) return; // If the object is outside the display area, skip drawing arrows if (Math.abs(x - cx) - radius >= W / 2 || Math.abs(y - cy) - radius >= H / 2) { return; } // Draw arrow for the velocity if (this.sim.getOption('display.velocity')) { const vecScale = this.sim.getOption('display.velocityScale'); const selected = this.sim.system.getSelectedOrCreating(); const isSelected = selected?.id === this.id; let velocity = {x: vx, y: vy}; if (isSelected) { // If this object is being dragged by the user, // show the pointer velocity instead of object velocity let pointerV = this.sim.pointer.latestVelocity; if (this.sim.getOption('compensate.timeScale')) { pointerV = div(pointerV, this.sim.timeScale); } velocity = add(velocity, pointerV); } const speed = magnitude(velocity); const arrowDirection = direction(velocity); // Prevent negative numbers by adding 1 // Make arrow lengths appear consistent regardless of display scale let arrowLength = Math.log10(speed + 1) * vecScale; if (!this.sim.getOption('display.zoomVectors')) { arrowLength /= display.scale; } // const offset = mult({x: Math.cos( const offset = components(radius, arrowDirection) const start = add({x, y}, offset); const end = add(start, components(arrowLength, arrowDirection)); const style = VELOCITY_VECTOR_COLOR === 'object color' ? `rgb(${r}, ${g}, ${b})` : VELOCITY_VECTOR_COLOR; display.drawArrow(start.x, start.y, end.x, end.y, { style, width: VELOCITY_VECTOR_WIDTH, arrowhead: VELOCITY_VECTOR_ARROWHEAD, fill: false, ifShort: 'head' }); } // Draw arrow for acceleration if (this.sim.getOption('display.acceleration')) { const vecScale = this.sim.getOption('display.accelerationScale'); const accel = magnitude(acceleration); const arrowDirection = direction(acceleration); // Prevent negative numbers by adding 1 // Make arrow lengths appear consistent regardless of display scale let arrowLength = Math.log10(accel + 1) * vecScale; if (!this.sim.getOption('display.zoomVectors')) { arrowLength /= this.sim.display.scale; } const offset = components(radius, arrowDirection) const start = add({x, y}, offset); const end = add(start, components(arrowLength, arrowDirection)); const style = ACCELERATION_VECTOR_COLOR === 'object color' ? `rgb(${r}, ${g}, ${b})` : ACCELERATION_VECTOR_COLOR; display.drawArrow(start.x, start.y, end.x, end.y, { style, width: ACCELERATION_VECTOR_WIDTH, arrowhead: ACCELERATION_VECTOR_ARROWHEAD, fill: false, ifShort: 'tail' }); } } drawObject(display) { const { color: {r, g, b}, position: {x, y}, radius, } = this; const { ctx, height: H, width: W, viewOrigin: {x: ox, y: oy}, } = display; const cx = ox + W / 2; const cy = oy + H / 2; if (!this.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) { // Find where a line from center of display to object intersects display edge let px, py; if (y <= cy) { // Line intersects y = 0: const y0px = cx + (H / 2) / (cy - y) * (x - cx); if (Math.abs(y0px - cx) <= W / 2) { px = y0px; py = oy; } } else { // Line intersects y = H const yHpx = cx + (H / 2) / (y - cy) * (x - cx); if (Math.abs(yHpx - cx) <= W / 2) { px = yHpx; py = oy + H; } } if (px === undefined) { if (x <= cx) { // Line intersects x = 0: px = ox; py = cy + (W / 2) / (cx - x) * (y - cy); } else { // Line intersects x = W: px = ox + W; py = cy + (W / 2) / (x - cx) * (y - cy); } } const arrowDirection = Math.atan2(py - cy, px - cx); // Length of arrow based on distance (logarithmic scale) const d = Math.sqrt((x - px) ** 2, (y - py) ** 2); const arrowLength = Math.log(d + 1) * OFFSCREEN_OBJECT_LINE_SCALE / display.scale; const startAx = px - arrowLength * Math.cos(arrowDirection); const startAy = py - arrowLength * Math.sin(arrowDirection); display.drawArrow(startAx, startAy, px, py, { style: `rgb(${r}, ${g}, ${b})`, width: OFFSCREEN_OBJECT_LINE_WIDTH, arrowheadLength: OFFSCREEN_OBJECT_ARROWHEAD_LENGTH, fill: false, ifShort: 'head', }); return; } // Draw filled circle for the object ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; ctx.beginPath(); ctx.arc(x, y, radius, 0, 2 * Math.PI); ctx.fill(); } drawSelection(display) { const { position: {x, y}, radius, } = this; const {ctx} = display; if (!this.alive) return; // If object is currently selected, draw a circle around it const isInSelectedGroup = this.sim.select.selectedGroup?.some(({id}) => id === this.id); const isSelectedSingle = this.sim.select.selectedSingle?.id === this.id; if (isSelectedSingle || isInSelectedGroup) { ctx.strokeStyle = isSelectedSingle ? SELECTED_SINGLE_COLOR : SELECTED_GROUP_COLOR; ctx.beginPath(); ctx.arc(x, y, radius + SELECTED_MARGIN / display.scale, 0, 2 * Math.PI); ctx.stroke(); } } }