Compare commits

..

2 Commits

Author SHA1 Message Date
d96aefd3f2
scroll to zoom 2025-12-25 12:15:08 -06:00
2024c55888
offscreen object arrows 2025-12-25 09:27:48 -06:00
6 changed files with 210 additions and 45 deletions

View File

@ -1,5 +1,14 @@
export const MASS_CREATION_RATE = 0.001; export const MASS_CREATION_RATE = 0.001;
export const DISPLAY_OBJECTS_INFO = false; export const DISPLAY_OBJECTS_INFO = false;
export const DISPLAY_CURSOR_INFO = false;
export const DISPLAY_VELOCITY_VECTORS = true; export const DISPLAY_VELOCITY_VECTORS = true;
export const POINTER_HISTORY_SIZE = 20; export const POINTER_HISTORY_SIZE = 20;
export const VELOCITY_VECTOR_SCALE = 0.2;
export const VELOCITY_VECTOR_COLOR = 'rgb(150, 150, 150)'; // optionally set to 'object color'
export const VELOCITY_VECTOR_WIDTH = 1.5;
export const VELOCITY_VECTOR_ARROWHEAD = true;
export const ARROWHEAD_LENGTH = 10;
export const ARROWHEAD_WIDTH = 7;
export const MOTION_TIME_SCALE = 0.001;
export const OFFSCREEN_OBJECT_LINE_SCALE = 5;
export const OFFSCREEN_OBJECT_LINE_WIDTH = 1.5;
export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15;

View File

@ -1,7 +1,19 @@
import {
VELOCITY_VECTOR_SCALE,
VELOCITY_VECTOR_COLOR,
VELOCITY_VECTOR_WIDTH,
VELOCITY_VECTOR_ARROWHEAD,
ARROWHEAD_LENGTH,
ARROWHEAD_WIDTH,
OFFSCREEN_OBJECT_LINE_SCALE,
OFFSCREEN_OBJECT_LINE_WIDTH,
OFFSCREEN_OBJECT_ARROWHEAD_LENGTH,
} from './config.js';
export class Display { export class Display {
sim = undefined; sim = undefined;
scale = 1;
VELOCITY_VECTOR_SCALE = 0.2; viewOrigin = {x: 0, y: 0};
constructor(sim) { constructor(sim) {
this.sim = sim; this.sim = sim;
@ -9,7 +21,6 @@ export class Display {
// If the window resizes, also resize the canvas // If the window resizes, also resize the canvas
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
this.canvas = canvas; this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.sim.div.appendChild(canvas); this.sim.div.appendChild(canvas);
canvas.style.position = 'absolute'; canvas.style.position = 'absolute';
canvas.style.top = 0; canvas.style.top = 0;
@ -18,6 +29,29 @@ export class Display {
window.addEventListener('resize', () => this.fullscreen()); window.addEventListener('resize', () => this.fullscreen());
} }
get ctx() {
const ctx = this.canvas.getContext("2d");
ctx.resetTransform();
ctx.scale(this.scale, this.scale);
ctx.translate(-this.viewOrigin.x, -this.viewOrigin.y);
return ctx;
}
get width() {
return this.canvas.width / this.scale;
}
get height() {
return this.canvas.height / this.scale;
}
screenToSim(x, y) {
return {
x: x / this.scale + this.viewOrigin.x,
y: y / this.scale + this.viewOrigin.y,
};
}
fullscreen() { fullscreen() {
this.canvas.width = document.documentElement.clientWidth; this.canvas.width = document.documentElement.clientWidth;
this.canvas.height = document.documentElement.clientHeight; this.canvas.height = document.documentElement.clientHeight;
@ -27,7 +61,7 @@ export class Display {
fillCanvas() { fillCanvas() {
const ctx = this.ctx; const ctx = this.ctx;
ctx.fillStyle = '#000'; ctx.fillStyle = '#000';
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); ctx.fillRect(this.viewOrigin.x, this.viewOrigin.y, this.width, this.height);
} }
drawObject(idx) { drawObject(idx) {
@ -37,6 +71,57 @@ export class Display {
const {x, y} = obj.position; const {x, y} = obj.position;
const {x: vx, y: vy} = obj.velocity; const {x: vx, y: vy} = obj.velocity;
const radius = obj.radius; const radius = obj.radius;
const {height: H, width: W} = this;
const ox = this.viewOrigin.x;
const oy = this.viewOrigin.y;
const cx = ox + W / 2;
const cy = oy + H / 2;
// 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 = this.viewOrigin.y;
}
} else {
// Line intersects y = H
const yHpx = cx + (H / 2) / (y - cy) * (x - cx);
if (Math.abs(yHpx - cx) <= W / 2) {
px = yHpx;
py = this.viewOrigin.y + H;
}
}
if (px === undefined) {
if (x <= cx) {
// Line intersects x = 0:
px = this.viewOrigin.x;
py = cy + (W / 2) / (cx - x) * (y - cy);
} else {
// Line intersects x = W:
px = this.viewOrigin.x + 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 distance = Math.sqrt((x - px)**2, (y - py)**2);
const arrowLength = Math.log(distance) * OFFSCREEN_OBJECT_LINE_SCALE / this.scale;
const startAx = px - arrowLength * Math.cos(arrowDirection);
const startAy = py - arrowLength * Math.sin(arrowDirection);
this.drawArrow(startAx, startAy, px, py, {
style: `rgb(${r}, ${g}, ${b})`,
width: OFFSCREEN_OBJECT_LINE_WIDTH,
arrowheadLength: OFFSCREEN_OBJECT_ARROWHEAD_LENGTH
});
return;
}
// Draw filled circle for the object // Draw filled circle for the object
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
@ -44,16 +129,17 @@ export class Display {
ctx.arc(x, y, radius, 0, 2*Math.PI); ctx.arc(x, y, radius, 0, 2*Math.PI);
ctx.fill(); ctx.fill();
// Draw line for the velocity // Draw arrow for the velocity
// TODO: Arrow const endVx = x + VELOCITY_VECTOR_SCALE * vx;
ctx.strokeStyle = ctx.fillStyle; const endVy = y + VELOCITY_VECTOR_SCALE * vy;
ctx.lineWidth = 2.0; const style = VELOCITY_VECTOR_COLOR === 'object color' ? ctx.fillStyle : VELOCITY_VECTOR_COLOR;
ctx.beginPath(); this.drawArrow(x, y, endVx, endVy, {
ctx.moveTo(x, y); style,
ctx.lineTo(x + this.VELOCITY_VECTOR_SCALE * vx, y + this.VELOCITY_VECTOR_SCALE * vy); width: VELOCITY_VECTOR_WIDTH,
ctx.stroke(); arrowhead: VELOCITY_VECTOR_ARROWHEAD
});
// TODO: Draw line for acceleration // TODO: Draw arrow for acceleration
} }
drawObjects() { drawObjects() {
@ -61,4 +147,44 @@ export class Display {
this.drawObject(i); this.drawObject(i);
} }
} }
drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength}) {
arrowhead = arrowhead ?? true;
arrowheadLength = arrowheadLength ?? ARROWHEAD_LENGTH;
const ctx = this.ctx;
ctx.strokeStyle = style;
// Keep arrows at normal scale
ctx.lineWidth = width / this.scale;
const scaledArrowheadLength = arrowheadLength / this.scale;
const arrowDirection = Math.atan2(endY - startY, endX - startX);
const endAx = arrowhead ? endX - (scaledArrowheadLength - 1) * Math.cos(arrowDirection) : endX;
const endAy = arrowhead ? endY - (scaledArrowheadLength - 1) * Math.sin(arrowDirection) : endY;
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endAx, endAy);
ctx.stroke();
if (arrowhead) {
this.drawArrowHead(endX, endY, arrowDirection, {style, length: arrowheadLength});
}
}
drawArrowHead(x, y, direction, {style, length}) {
const arrowheadLength = length ?? ARROWHEAD_LENGTH;
const arrowheadWidth = arrowheadLength / ARROWHEAD_LENGTH * ARROWHEAD_WIDTH;
const ctx = this.ctx;
ctx.fillStyle = style;
// To make this simple, draw the arrowhead and then rotate and translate it as needed.
// Keep arrows at normal scale
const scaledArrowheadLength = arrowheadLength / this.scale;
const scaledArrowheadWidth = arrowheadWidth / this.scale;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.translate(x, y);
ctx.rotate(direction);
ctx.lineTo(-scaledArrowheadLength, -scaledArrowheadWidth / 2);
ctx.lineTo(-scaledArrowheadLength, scaledArrowheadWidth / 2);
ctx.closePath();
ctx.fill();
ctx.resetTransform();
}
} }

View File

@ -1,4 +1,5 @@
export class MassObject { export class MassObject {
index = undefined;
mass = 0; mass = 0;
density = 1; density = 1;
position = {x: undefined, y: undefined}; position = {x: undefined, y: undefined};
@ -6,7 +7,8 @@ export class MassObject {
color = {r: undefined, g: undefined, b: undefined}; color = {r: undefined, g: undefined, b: undefined};
created = undefined; created = undefined;
constructor(x, y) { constructor(x, y, index) {
this.index = index;
this.position.x = x; this.position.x = x;
this.position.y = y; this.position.y = y;
this.color.r = Math.random() * 256; this.color.r = Math.random() * 256;

View File

@ -1,5 +1,9 @@
import { MassObject } from './object.js'; import { MassObject } from './object.js';
import { MASS_CREATION_RATE, DISPLAY_OBJECTS_INFO } from './config.js'; import {
MASS_CREATION_RATE,
DISPLAY_OBJECTS_INFO,
MOTION_TIME_SCALE,
} from './config.js';
export class Objects { export class Objects {
objects = []; objects = [];
@ -12,8 +16,8 @@ export class Objects {
// Create an object with mass that grows as pointer is held down // Create an object with mass that grows as pointer is held down
createObject(x, y) { createObject(x, y) {
const obj = new MassObject(x, y);
const idx = this.objects.length; const idx = this.objects.length;
const obj = new MassObject(x, y, idx);
this.creatingObject = idx; this.creatingObject = idx;
this.objects.push(obj); this.objects.push(obj);
} }
@ -65,6 +69,13 @@ export class Objects {
obj.mass += rate * elapsedTime; obj.mass += rate * elapsedTime;
} }
// Update positions. Simple Euler method for now.
for (let i = 0; i < this.objects.length; i++) {
const obj = this.objects[i];
obj.position.x += obj.velocity.x * MOTION_TIME_SCALE;
obj.position.y += obj.velocity.y * MOTION_TIME_SCALE;
}
// Display objects info // Display objects info
if (DISPLAY_OBJECTS_INFO) { if (DISPLAY_OBJECTS_INFO) {
for (let i = 0; i < this.objects.length; i++) { for (let i = 0; i < this.objects.length; i++) {

View File

@ -1,4 +1,4 @@
import { DISPLAY_CURSOR_INFO, POINTER_HISTORY_SIZE } from './config.js'; import { POINTER_HISTORY_SIZE } from './config.js';
export class Pointer { export class Pointer {
sim = undefined; sim = undefined;
@ -11,38 +11,31 @@ export class Pointer {
// Monitor mouse movements // Monitor mouse movements
const el = window; const el = window;
el.addEventListener('mousemove', e => { el.addEventListener('mousemove', e => {
if (DISPLAY_CURSOR_INFO) { this.handlePointerMove(this.sim.screenToSim(e.clientX, e.clientY));
this.sim.info['Mouse move'] = [`${e.clientX}, `, `${e.clientY}`];
}
this.handlePointerMove(e.clientX, e.clientY);
}); });
// Monitor touch events // Monitor touch events
el.addEventListener('touchmove', e => { el.addEventListener('touchmove', e => {
if (DISPLAY_CURSOR_INFO) { const {pageX, pageY} = e.touches[0];
this.sim.info['Touch move'] = [`${e.touches[0].pageX}, `, `${e.touches[0].pageY}`]; this.handlePointerMove(this.sim.screenToSim(pageX, pageY));
}
this.handlePointerMove(e.touches[0].pageX, e.touches[0].pageY);
}); });
el.addEventListener('pointerdown', e => { el.addEventListener('pointerdown', e => {
if (DISPLAY_CURSOR_INFO) { this.handlePointerDown(this.sim.screenToSim(e.clientX, e.clientY));
this.sim.info['Pointer down'] = [`${e.clientX}, `, `${e.clientY}`];
}
this.handlePointerDown(e.clientX, e.clientY);
}); });
el.addEventListener('pointerup', e => { el.addEventListener('pointerup', e => {
if (DISPLAY_CURSOR_INFO) { this.handlePointerUp(this.sim.screenToSim(e.clientX, e.clientY));
this.sim.info['Pointer up'] = [`${e.clientX}, `, `${e.clientY}`];
}
this.handlePointerUp(e.clientX, e.clientY);
}); });
el.addEventListener('click', e => { el.addEventListener('click', e => {
if (DISPLAY_CURSOR_INFO) { });
this.sim.info['Click'] = [`${e.clientX}, `, `${e.clientY}`];
} // Monitor wheel events
el.addEventListener('wheel', e => {
// Wheel scroll down => positive deltaY => ZOOM IN
const factor = e.deltaY > 0 ? 2 : 0.5;
this.sim.scheduleZoom(this.sim.screenToSim(e.clientX, e.clientY), factor);
}); });
} }
@ -65,7 +58,7 @@ export class Pointer {
this.pointerHistory = []; this.pointerHistory = [];
} }
updatePointer(x, y) { updatePointer({x, y}) {
const t = document.timeline.currentTime; const t = document.timeline.currentTime;
this.pointerHistory.push({x, y, t}); this.pointerHistory.push({x, y, t});
if (this.pointerHistory.length > POINTER_HISTORY_SIZE) { if (this.pointerHistory.length > POINTER_HISTORY_SIZE) {
@ -73,9 +66,9 @@ export class Pointer {
} }
} }
handlePointerDown(x, y) { handlePointerDown({x, y}) {
this.clearPointerHistory(); this.clearPointerHistory();
this.updatePointer(x, y); this.updatePointer({x, y});
// If pointer is touching an object, select the object // If pointer is touching an object, select the object
const touchingObject = this.sim.objects.objectAtLocation(x, y); const touchingObject = this.sim.objects.objectAtLocation(x, y);
@ -88,20 +81,20 @@ export class Pointer {
} }
} }
handlePointerUp(x, y) { handlePointerUp({x, y}) {
this.sim.objects.doneCreatingObject(); this.sim.objects.doneCreatingObject();
this.sim.objects.deselect(); this.sim.objects.deselect();
} }
// Handle cursor (mouse or touch) movement // Handle cursor (mouse or touch) movement
handlePointerMove(x, y) { handlePointerMove({x, y}) {
// TODO: If e.touches.length > 1, user may be engaging pinch to zoom // TODO: If e.touches.length > 1, user may be engaging pinch to zoom
// If the cursor moves while creating an object, or while an object is selected, // If the cursor moves while creating an object, or while an object is selected,
// update the position and velocity of the object // update the position and velocity of the object
const obj = this.sim.objects.getSelectedOrCreating(); const obj = this.sim.objects.getSelectedOrCreating();
if (obj !== undefined) { if (obj !== undefined) {
this.updatePointer(x, y); this.updatePointer({x, y});
const {x: vx, y: vy} = this.getPointerVelocity(); const {x: vx, y: vy} = this.getPointerVelocity();
obj.position.x = x; obj.position.x = x;
obj.position.y = y; obj.position.y = y;

View File

@ -7,6 +7,7 @@ export class Sim {
info = {}; info = {};
frame = 0; frame = 0;
time = undefined; time = undefined;
nextZoom = undefined;
display = undefined; display = undefined;
overlay = undefined; overlay = undefined;
@ -28,14 +29,37 @@ export class Sim {
requestAnimationFrame(t => this.loop(t)); requestAnimationFrame(t => this.loop(t));
} }
scheduleZoom({x, y}, factor) {
this.nextZoom = {x, y, factor};
}
zoom({x, y, factor}) {
// x, y are the mouse coordinates, which should be the center of the new view frame
// the new view origin should be x, y minus half the new view width and height
// compute new scale
this.display.scale = this.display.scale * factor;
// compute coordinates of new view frame
this.display.viewOrigin.x = x - this.display.width / 2;
this.display.viewOrigin.y = y - this.display.height / 2;
}
screenToSim(x, y) {
return this.display.screenToSim(x, y);
}
// Main loop // Main loop
loop(currentTime) { loop(currentTime) {
const elapsedTime = currentTime - this.time; const elapsedTime = currentTime - this.time;
this.time = currentTime; this.time = currentTime;
if (this.nextZoom) {
this.zoom(this.nextZoom);
this.nextZoom = undefined;
}
this.display.fillCanvas();
this.objects.computeFrame(elapsedTime); this.objects.computeFrame(elapsedTime);
this.display.fillCanvas(); this.info['scale'] = this.display.scale;
this.display.drawObjects(); this.display.drawObjects();
this.overlay.renderInfo(); this.overlay.renderInfo();