Compare commits

...

2 Commits

Author SHA1 Message Date
b8bf93269f
draggability and fine-tuning arrows 2025-12-26 13:00:18 -06:00
6fd3bcae15
play/pause button 2025-12-25 23:41:29 -06:00
11 changed files with 267 additions and 47 deletions

View File

@ -1,14 +1,19 @@
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 = true;
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_SCALE = 0.2;
export const VELOCITY_VECTOR_COLOR = 'rgb(150, 150, 150)'; // optionally set to 'object color' 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_WIDTH = 1.5;
export const VELOCITY_VECTOR_ARROWHEAD = true; export const VELOCITY_VECTOR_ARROWHEAD = true;
export const ARROWHEAD_LENGTH = 10; export const ARROWHEAD_LENGTH = 7;
export const ARROWHEAD_WIDTH = 7; export const ARROWHEAD_WIDTH = 5;
export const MOTION_TIME_SCALE = 0.001; export const MOTION_TIME_SCALE = 0.001;
export const OFFSCREEN_OBJECT_LINE_SCALE = 5; export const OFFSCREEN_OBJECT_LINE_SCALE = 7;
export const OFFSCREEN_OBJECT_LINE_WIDTH = 1.5; export const OFFSCREEN_OBJECT_LINE_WIDTH = 2;
export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15; export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15;
export const ZOOM_IN_FACTOR = 2;
export const ZOOM_OUT_FACTOR = 0.5;
export const SCALE_MAX = 256;
export const SCALE_MIN = 1/256;

View File

@ -110,14 +110,16 @@ export class Display {
const arrowDirection = Math.atan2(py - cy, px - cx); const arrowDirection = Math.atan2(py - cy, px - cx);
// Length of arrow based on distance (logarithmic scale) // Length of arrow based on distance (logarithmic scale)
const distance = Math.sqrt((x - px)**2, (y - py)**2); const distance = Math.sqrt((x - px)**2, (y - py)**2) * this.scale;
const arrowLength = Math.log(distance) * OFFSCREEN_OBJECT_LINE_SCALE / this.scale; const arrowLength = Math.log(distance) * OFFSCREEN_OBJECT_LINE_SCALE / this.scale;
const startAx = px - arrowLength * Math.cos(arrowDirection); const startAx = px - arrowLength * Math.cos(arrowDirection);
const startAy = py - arrowLength * Math.sin(arrowDirection); const startAy = py - arrowLength * Math.sin(arrowDirection);
this.drawArrow(startAx, startAy, px, py, { this.drawArrow(startAx, startAy, px, py, {
style: `rgb(${r}, ${g}, ${b})`, style: `rgb(${r}, ${g}, ${b})`,
width: OFFSCREEN_OBJECT_LINE_WIDTH, width: OFFSCREEN_OBJECT_LINE_WIDTH,
arrowheadLength: OFFSCREEN_OBJECT_ARROWHEAD_LENGTH arrowheadLength: OFFSCREEN_OBJECT_ARROWHEAD_LENGTH,
fill: false,
ifShort: 'head',
}); });
return; return;
@ -136,7 +138,9 @@ export class Display {
this.drawArrow(x, y, endVx, endVy, { this.drawArrow(x, y, endVx, endVy, {
style, style,
width: VELOCITY_VECTOR_WIDTH, width: VELOCITY_VECTOR_WIDTH,
arrowhead: VELOCITY_VECTOR_ARROWHEAD arrowhead: VELOCITY_VECTOR_ARROWHEAD,
fill: false,
ifShort: 'head'
}); });
// TODO: Draw arrow for acceleration // TODO: Draw arrow for acceleration
@ -148,31 +152,55 @@ export class Display {
} }
} }
drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength}) { drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength, fill, ifShort}) {
arrowhead = arrowhead ?? true;
arrowheadLength = arrowheadLength ?? ARROWHEAD_LENGTH;
const ctx = this.ctx; const ctx = this.ctx;
ctx.strokeStyle = style; ctx.strokeStyle = style;
// Keep arrows at normal scale // Keep arrows at normal scale
ctx.lineWidth = width / this.scale; ctx.lineWidth = width / this.scale;
arrowhead = arrowhead ?? true;
arrowheadLength = arrowheadLength ?? ARROWHEAD_LENGTH;
const scaledArrowheadLength = arrowheadLength / this.scale; const scaledArrowheadLength = arrowheadLength / this.scale;
ifShort = ifShort ?? 'tail';
const arrowDirection = Math.atan2(endY - startY, endX - startX); const arrowDirection = Math.atan2(endY - startY, endX - startX);
const endAx = arrowhead ? endX - (scaledArrowheadLength - 1) * Math.cos(arrowDirection) : endX; const length = Math.sqrt((endX - startX)**2 + (endY - startY)**2);
const endAy = arrowhead ? endY - (scaledArrowheadLength - 1) * Math.sin(arrowDirection) : endY; let tail = true;
if (!length) {
return;
}
if (length <= scaledArrowheadLength) {
switch (ifShort) {
case 'head': {
arrowhead = true;
tail = false;
break;
}
case 'tail': {
arrowhead = false;
tail = true;
break;
}
}
}
if (tail) {
const endAx = arrowhead ? endX - (scaledArrowheadLength) * Math.cos(arrowDirection) : endX;
const endAy = arrowhead ? endY - (scaledArrowheadLength) * Math.sin(arrowDirection) : endY;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(startX, startY); ctx.moveTo(startX, startY);
ctx.lineTo(endAx, endAy); ctx.lineTo(endAx, endAy);
ctx.stroke(); ctx.stroke();
}
if (arrowhead) { if (arrowhead) {
this.drawArrowHead(endX, endY, arrowDirection, {style, length: arrowheadLength}); this.drawArrowHead(endX, endY, arrowDirection, {style, length: arrowheadLength, fill});
} }
} }
drawArrowHead(x, y, direction, {style, length}) { drawArrowHead(x, y, direction, {style, length, fill}) {
const arrowheadLength = length ?? ARROWHEAD_LENGTH; const arrowheadLength = length ?? ARROWHEAD_LENGTH;
const arrowheadWidth = arrowheadLength / ARROWHEAD_LENGTH * ARROWHEAD_WIDTH; const arrowheadWidth = arrowheadLength / ARROWHEAD_LENGTH * ARROWHEAD_WIDTH;
const ctx = this.ctx; const ctx = this.ctx;
ctx.fillStyle = style; ctx.fillStyle = style;
ctx.strokeStyle = style;
// To make this simple, draw the arrowhead and then rotate and translate it as needed. // To make this simple, draw the arrowhead and then rotate and translate it as needed.
// Keep arrows at normal scale // Keep arrows at normal scale
const scaledArrowheadLength = arrowheadLength / this.scale; const scaledArrowheadLength = arrowheadLength / this.scale;
@ -184,7 +212,11 @@ export class Display {
ctx.lineTo(-scaledArrowheadLength, -scaledArrowheadWidth / 2); ctx.lineTo(-scaledArrowheadLength, -scaledArrowheadWidth / 2);
ctx.lineTo(-scaledArrowheadLength, scaledArrowheadWidth / 2); ctx.lineTo(-scaledArrowheadLength, scaledArrowheadWidth / 2);
ctx.closePath(); ctx.closePath();
if (fill !== false) {
ctx.fill(); ctx.fill();
} else {
ctx.stroke();
}
ctx.resetTransform(); ctx.resetTransform();
} }
} }

2
find Executable file
View File

@ -0,0 +1,2 @@
#!/bin/env bash
find . -not -path ".git" -name "*.html" -or -name "*.js" -print0 | xargs -0 grep -n --color "$@"

View File

@ -15,7 +15,7 @@ body {
} }
div[id=simulator] { div[id=simulator] {
position: absolute; position: float;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;

View File

@ -69,12 +69,14 @@ export class Objects {
obj.mass += rate * elapsedTime; obj.mass += rate * elapsedTime;
} }
if (this.sim.playing) {
// Update positions. Simple Euler method for now. // Update positions. Simple Euler method for now.
for (let i = 0; i < this.objects.length; i++) { for (let i = 0; i < this.objects.length; i++) {
const obj = this.objects[i]; const obj = this.objects[i];
obj.position.x += obj.velocity.x * MOTION_TIME_SCALE; obj.position.x += obj.velocity.x * MOTION_TIME_SCALE;
obj.position.y += obj.velocity.y * 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) {

View File

@ -7,12 +7,12 @@ export class Overlay {
const infoBox = document.createElement('div'); const infoBox = document.createElement('div');
this.sim.div.appendChild(infoBox); this.sim.div.appendChild(infoBox);
this.infoBox = infoBox; this.infoBox = infoBox;
infoBox.style.position = 'absolute'; infoBox.style.position = 'relative';
infoBox.style.top = 0; infoBox.style.top = 0;
infoBox.style.left = 0; infoBox.style.left = 0;
infoBox.width = 'fit-content'; infoBox.width = 'fit-content';
infoBox.style.zIndex = 1; infoBox.style.zIndex = 1;
infoBox.classList.add('draggable');
} }
renderInfo() { renderInfo() {
@ -34,4 +34,22 @@ export class Overlay {
this.infoBox.appendChild(table); this.infoBox.appendChild(table);
} }
// Update positions of draggable items
updateDraggable() {
const elements = document.querySelectorAll('.draggable');
for (let el of elements) {
if (!el.dragging) continue;
const {
elementStart: {x: x0, y: y0},
pointerStart: {x: x1, y: y1},
pointerEnd: {x: x2, y: y2}
} = el.dragging;
const dx = x2 - x1;
const dy = y2 - y1;
const left = x0 + dx;
const top = y0 + dy;
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}
}
} }

View File

@ -1,40 +1,89 @@
import { POINTER_HISTORY_SIZE } from './config.js'; import {
POINTER_HISTORY_SIZE,
ZOOM_IN_FACTOR,
ZOOM_OUT_FACTOR,
DISPLAY_CURSOR_INFO,
} from './config.js';
function dispatchEvent(target, eventType, data) {
const ev = new CustomEvent(eventType, {detail: data});
target.dispatchEvent(ev);
}
export class Pointer { export class Pointer {
sim = undefined; sim = undefined;
pointerHistory = []; pointerHistory = [];
draggingElement = undefined;
constructor(sim) { constructor(sim) {
this.sim = sim; this.sim = sim;
// Monitor mouse movements // Monitor mouse movements
const el = window; const el = window;
el.addEventListener('mousemove', e => {
this.handlePointerMove(this.sim.screenToSim(e.clientX, e.clientY));
});
// Monitor touch events el.addEventListener('pointermove', e => {
el.addEventListener('touchmove', e => { // const velocity = this.sim.pointer.getPointerVelocity();
const {pageX, pageY} = e.touches[0]; if (this.draggingElement) {
this.handlePointerMove(this.sim.screenToSim(pageX, pageY)); // e.preventDefault();
this.draggingElement.dragging.pointerEnd = {
x: e.clientX,
y: e.clientY,
};
} else {
const {x, y} = this.sim.screenToSim(e.clientX, e.clientY);
this.handlePointerMove({x, y});
}
}); });
el.addEventListener('pointerdown', e => { el.addEventListener('pointerdown', e => {
// e.preventDefault();
let target = e.target;
while (target && !target.classList.contains('draggable')) {
target = target.parentElement;
}
if (target?.classList.contains('draggable')) {
// e.preventDefault();
this.draggingElement = target;
this.draggingElement.dragging = {
elementStart: {
x: parseInt(this.draggingElement.style.left),
y: parseInt(this.draggingElement.style.top),
},
pointerStart: {
x: e.clientX,
y: e.clientY,
},
pointerEnd: {
x: e.clientX,
y: e.clientY,
},
};
} else {
this.handlePointerDown(this.sim.screenToSim(e.clientX, e.clientY)); this.handlePointerDown(this.sim.screenToSim(e.clientX, e.clientY));
}
}); });
el.addEventListener('pointerup', e => { el.addEventListener('pointerup', e => {
// e.preventDefault();
if (this.draggingElement) {
this.draggingElement.dragging = undefined;
this.draggingElement = undefined;
this.lastPosition = {x: undefined, y: undefined};
} else {
this.handlePointerUp(this.sim.screenToSim(e.clientX, e.clientY)); this.handlePointerUp(this.sim.screenToSim(e.clientX, e.clientY));
}
}); });
el.addEventListener('click', e => { el.addEventListener('click', e => {
// e.preventDefault();
}); });
// Monitor wheel events // Monitor wheel events
el.addEventListener('wheel', e => { el.addEventListener('wheel', e => {
// e.preventDefault();
// Wheel scroll down => positive deltaY => ZOOM IN // Wheel scroll down => positive deltaY => ZOOM IN
const factor = e.deltaY > 0 ? 2 : 0.5; const factor = e.deltaY > 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
this.sim.scheduleZoom(this.sim.screenToSim(e.clientX, e.clientY), factor); this.sim.scheduleZoom(this.sim.screenToSim(e.clientX, e.clientY), factor);
}); });
@ -43,7 +92,7 @@ export class Pointer {
getPointerVelocity() { getPointerVelocity() {
// Average over pointer history // Average over pointer history
if (this.pointerHistory.length < 2) { if (this.pointerHistory.length < 2) {
return {x: 0, y: 0}; return {x: 0, y: 0, dt: 1};
} }
const start = this.pointerHistory[0]; const start = this.pointerHistory[0];
const end = this.pointerHistory[this.pointerHistory.length - 1]; const end = this.pointerHistory[this.pointerHistory.length - 1];
@ -51,6 +100,7 @@ export class Pointer {
return { return {
x: (end.x - start.x) / dt, x: (end.x - start.x) / dt,
y: (end.y - start.y) / dt, y: (end.y - start.y) / dt,
dt
}; };
} }
@ -88,13 +138,13 @@ export class Pointer {
// Handle cursor (mouse or touch) movement // Handle cursor (mouse or touch) movement
handlePointerMove({x, y}) { handlePointerMove({x, y}) {
this.updatePointer({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});
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

@ -2,12 +2,15 @@ import { Display } from './display.js';
import { Overlay } from './overlay.js'; import { Overlay } from './overlay.js';
import { Pointer } from './pointer.js'; import { Pointer } from './pointer.js';
import { Objects } from './objects.js'; import { Objects } from './objects.js';
import { Toolbar } from './toolbar.js';
import { PlayPause } from './tools/play-pause.js';
import { SCALE_MAX, SCALE_MIN} from './config.js';
export class Sim { export class Sim {
info = {}; info = {};
frame = 0;
time = undefined; time = undefined;
nextZoom = undefined; nextZoom = undefined;
playing = true;
display = undefined; display = undefined;
overlay = undefined; overlay = undefined;
@ -23,6 +26,10 @@ export class Sim {
this.overlay = new Overlay(this); this.overlay = new Overlay(this);
this.pointer = new Pointer(this); this.pointer = new Pointer(this);
this.objects = new Objects(this); this.objects = new Objects(this);
this.toolbar = new Toolbar(this);
// Set up toolbar
this.toolbar.addTool(new PlayPause(this.toolbar));
// Initiate main loop // Initiate main loop
this.time = document.timeline.currentTime; this.time = document.timeline.currentTime;
@ -38,31 +45,40 @@ export class Sim {
// the new view origin should be x, y minus half the new view width and height // the new view origin should be x, y minus half the new view width and height
// compute new scale // compute new scale
this.display.scale = this.display.scale * factor; this.display.scale = this.display.scale * factor;
if (this.display.scale > SCALE_MAX) this.display.scale = SCALE_MAX;
if (this.display.scale < SCALE_MIN) this.display.scale = SCALE_MIN;
// compute coordinates of new view frame // compute coordinates of new view frame
this.display.viewOrigin.x = x - this.display.width / 2; this.display.viewOrigin.x = x - this.display.width / 2;
this.display.viewOrigin.y = y - this.display.height / 2; this.display.viewOrigin.y = y - this.display.height / 2;
} }
// Transform display coordinates to simulator coordinates using scale and viewOrigin
screenToSim(x, y) { screenToSim(x, y) {
return this.display.screenToSim(x, y); return this.display.screenToSim(x, y);
} }
play() {
this.playing = true;
}
pause() {
this.playing = false;
}
// Main loop // Main loop
loop(currentTime) { loop(currentTime) {
this.overlay.updateDraggable();
const elapsedTime = currentTime - this.time; const elapsedTime = currentTime - this.time;
this.time = currentTime; this.time = currentTime;
if (this.nextZoom) { if (this.nextZoom) {
this.zoom(this.nextZoom); this.zoom(this.nextZoom);
this.nextZoom = undefined; this.nextZoom = undefined;
} }
this.display.fillCanvas();
this.objects.computeFrame(elapsedTime);
this.info['scale'] = this.display.scale; this.info['scale'] = this.display.scale;
this.objects.computeFrame(elapsedTime);
this.display.fillCanvas();
this.display.drawObjects(); this.display.drawObjects();
this.overlay.renderInfo(); this.overlay.renderInfo();
requestAnimationFrame(t => this.loop(t)); requestAnimationFrame(t => this.loop(t));
} }
} }

24
tool.js Normal file
View File

@ -0,0 +1,24 @@
// Idea here is, tool can declare its parameters;
// can call back to toolbar for whatever...
// through toolbar can access sim
export class Tool {
toolbar = undefined;
sim = undefined;
constructor(toolbar) {
this.toolbar = toolbar;
this.sim = this.toolbar.sim;
const div = document.createElement('div');
this.div = div;
div.style.position = 'relative';
div.style.top = 0;
div.style.left = 0;
div.style.border = '1px #0fb solid';
div.style.margin = '5px';
div.style.padding = '5px';
div.classList.add('draggable');
}
frame() {}
}

31
toolbar.js Normal file
View File

@ -0,0 +1,31 @@
export class Toolbar {
sim = undefined;
tools = [];
constructor(sim) {
this.sim = sim;
// Create ourselves a div, as child of sim's div
const div = document.createElement('div');
this.div = div;
this.sim.div.appendChild(div);
div.style.position = 'relative';
div.style.width = 'fit-content';
div.style.top = 0;
div.style.left = 0;
div.style.zIndex = 2;
}
// tool: instance of Tool
addTool(tool) {
this.div.appendChild(tool.div);
this.tools.push(tool);
}
frame() {
for (let tool in this.tools) {
// TODO: tool.frame()
}
}
}

40
tools/play-pause.js Normal file
View File

@ -0,0 +1,40 @@
import { Tool } from '../tool.js';
export class PlayPause extends Tool {
playHTML = 'Play';
pauseHTML = 'Pause';
constructor(toolbar) {
super(toolbar);
// For now, use a regular button
const button = document.createElement('button');
button.style.width = '50px';
button.style.height = '50px';
this.div.appendChild(button);
if (this.playing) {
button.innerHTML = this.pauseHTML;
} else {
button.innerHTML = this.playHTML;
}
button.addEventListener('click', (e) => {
e.stopPropagation();
if (this.playing) {
button.innerHTML = this.playHTML;
this.playing = false;
} else {
button.innerHTML = this.pauseHTML;
this.playing = true;
}
});
}
get playing() {
return this.sim.playing;
}
set playing(playing) {
return this.sim.playing = playing;
}
}