307 lines
9.0 KiB
JavaScript
307 lines
9.0 KiB
JavaScript
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, 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;
|
|
|
|
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,
|
|
// TODO: optional export history
|
|
};
|
|
}
|
|
|
|
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;
|
|
// TODO: optional import history
|
|
this.history = [];
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|