diff --git a/config.js b/config.js index 542a123..c5a531b 100644 --- a/config.js +++ b/config.js @@ -1,12 +1,14 @@ export const MASS_CREATION_RATE = 0.001; export const DISPLAY_OBJECTS_INFO = false; -export const DISPLAY_CURSOR_INFO = false; export const DISPLAY_VELOCITY_VECTORS = true; 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_ARROW_SCALE = 5; +export const OFFSCREEN_OBJECT_LINE_SCALE = 5; +export const OFFSCREEN_OBJECT_LINE_WIDTH = 1.5; +export const OFFSCREEN_OBJECT_ARROWHEAD_LENGTH = 15; diff --git a/display.js b/display.js index 67cadf0..f928ecc 100644 --- a/display.js +++ b/display.js @@ -2,13 +2,18 @@ import { VELOCITY_VECTOR_SCALE, VELOCITY_VECTOR_COLOR, VELOCITY_VECTOR_WIDTH, + VELOCITY_VECTOR_ARROWHEAD, ARROWHEAD_LENGTH, ARROWHEAD_WIDTH, - OFFSCREEN_OBJECT_ARROW_SCALE, + OFFSCREEN_OBJECT_LINE_SCALE, + OFFSCREEN_OBJECT_LINE_WIDTH, + OFFSCREEN_OBJECT_ARROWHEAD_LENGTH, } from './config.js'; export class Display { sim = undefined; + scale = 1; + viewOrigin = {x: 0, y: 0}; constructor(sim) { this.sim = sim; @@ -16,7 +21,6 @@ export class Display { // If the window resizes, also resize the canvas const canvas = document.createElement('canvas') this.canvas = canvas; - this.ctx = canvas.getContext("2d"); this.sim.div.appendChild(canvas); canvas.style.position = 'absolute'; canvas.style.top = 0; @@ -25,6 +29,29 @@ export class Display { 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() { this.canvas.width = document.documentElement.clientWidth; this.canvas.height = document.documentElement.clientHeight; @@ -34,7 +61,7 @@ export class Display { fillCanvas() { const ctx = this.ctx; 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) { @@ -44,54 +71,54 @@ export class Display { const {x, y} = obj.position; const {x: vx, y: vy} = obj.velocity; const radius = obj.radius; - const {height: H, width: W} = this.canvas; - + 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 (x - radius >= W || x + radius <= 0 || - y - radius >= H || y + radius <= 0) { + 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 - const cx = W / 2; - const cy = H / 2; let px, py; if (y <= cy) { // Line intersects y = 0: - const y0px = cx + cy / (cy - y) * (x - cx); - if (y0px >= 0 && y0px <= W) { + const y0px = cx + (H / 2) / (cy - y) * (x - cx); + if (Math.abs(y0px - cx) <= W / 2) { px = y0px; - py = 0; + py = this.viewOrigin.y; } } else { // Line intersects y = H - const yHpx = cx + cy / (y - cy) * (x - cx); - if (yHpx >= 0 && yHpx <= W) { + const yHpx = cx + (H / 2) / (y - cy) * (x - cx); + if (Math.abs(yHpx - cx) <= W / 2) { px = yHpx; - py = H; + py = this.viewOrigin.y + H; } } if (px === undefined) { - if (x <= 0) { + if (x <= cx) { // Line intersects x = 0: - px = 0; - py = cy + cx / (cx - x) * (y - cy); + px = this.viewOrigin.x; + py = cy + (W / 2) / (cx - x) * (y - cy); } else { // Line intersects x = W: - px = W; - py = cy + cx / (x - cx) * (y - cy); + px = this.viewOrigin.x + W; + py = cy + (W / 2) / (x - cx) * (y - cy); } } const arrowDirection = Math.atan2(py - cy, px - cx); - this.drawArrowHead(px, py, arrowDirection, `rgb(${r}, ${g}, ${b})`); // 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_ARROW_SCALE; - const ax = px - arrowLength * Math.cos(arrowDirection); - const ay = py - arrowLength * Math.sin(arrowDirection); - ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`; - ctx.beginPath(); - ctx.moveTo(ax, ay); - ctx.lineTo(px, py); - ctx.stroke(); + 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; } @@ -102,18 +129,17 @@ export class Display { ctx.arc(x, y, radius, 0, 2*Math.PI); ctx.fill(); - // Draw line for the velocity + // Draw arrow for the velocity const endVx = x + VELOCITY_VECTOR_SCALE * vx; const endVy = y + VELOCITY_VECTOR_SCALE * vy; - ctx.strokeStyle = VELOCITY_VECTOR_COLOR === 'object color' ? ctx.fillStyle : VELOCITY_VECTOR_COLOR; - ctx.lineWidth = VELOCITY_VECTOR_WIDTH; - ctx.beginPath(); - ctx.moveTo(x, y); - ctx.lineTo(endVx, endVy); - ctx.stroke(); - this.drawArrowHead(endVx, endVy, Math.atan2(vy, vx), ctx.strokeStyle); + const style = VELOCITY_VECTOR_COLOR === 'object color' ? ctx.fillStyle : VELOCITY_VECTOR_COLOR; + this.drawArrow(x, y, endVx, endVy, { + style, + width: VELOCITY_VECTOR_WIDTH, + arrowhead: VELOCITY_VECTOR_ARROWHEAD + }); - // TODO: Draw line for acceleration + // TODO: Draw arrow for acceleration } drawObjects() { @@ -122,18 +148,41 @@ export class Display { } } - drawArrowHead(x, y, direction, fillStyle) { + drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength}) { + arrowhead = arrowhead ?? true; + arrowheadLength = arrowheadLength ?? ARROWHEAD_LENGTH; const ctx = this.ctx; - if (fillStyle) { - ctx.fillStyle = fillStyle; + 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(-ARROWHEAD_LENGTH, -ARROWHEAD_WIDTH / 2); - ctx.lineTo(-ARROWHEAD_LENGTH, ARROWHEAD_WIDTH / 2); + ctx.lineTo(-scaledArrowheadLength, -scaledArrowheadWidth / 2); + ctx.lineTo(-scaledArrowheadLength, scaledArrowheadWidth / 2); ctx.closePath(); ctx.fill(); ctx.resetTransform(); diff --git a/object.js b/object.js index 15d43d2..82b6429 100644 --- a/object.js +++ b/object.js @@ -1,4 +1,5 @@ export class MassObject { + index = undefined; mass = 0; density = 1; position = {x: undefined, y: undefined}; @@ -6,7 +7,8 @@ export class MassObject { color = {r: undefined, g: undefined, b: undefined}; created = undefined; - constructor(x, y) { + constructor(x, y, index) { + this.index = index; this.position.x = x; this.position.y = y; this.color.r = Math.random() * 256; diff --git a/objects.js b/objects.js index d94c9c6..ed101ef 100644 --- a/objects.js +++ b/objects.js @@ -16,8 +16,8 @@ export class Objects { // Create an object with mass that grows as pointer is held down createObject(x, y) { - const obj = new MassObject(x, y); const idx = this.objects.length; + const obj = new MassObject(x, y, idx); this.creatingObject = idx; this.objects.push(obj); } diff --git a/pointer.js b/pointer.js index d0a8b9e..c70ac3f 100644 --- a/pointer.js +++ b/pointer.js @@ -1,4 +1,4 @@ -import { DISPLAY_CURSOR_INFO, POINTER_HISTORY_SIZE } from './config.js'; +import { POINTER_HISTORY_SIZE } from './config.js'; export class Pointer { sim = undefined; @@ -11,38 +11,31 @@ export class Pointer { // Monitor mouse movements const el = window; el.addEventListener('mousemove', e => { - if (DISPLAY_CURSOR_INFO) { - this.sim.info['Mouse move'] = [`${e.clientX}, `, `${e.clientY}`]; - } - this.handlePointerMove(e.clientX, e.clientY); + this.handlePointerMove(this.sim.screenToSim(e.clientX, e.clientY)); }); // Monitor touch events el.addEventListener('touchmove', e => { - if (DISPLAY_CURSOR_INFO) { - this.sim.info['Touch move'] = [`${e.touches[0].pageX}, `, `${e.touches[0].pageY}`]; - } - this.handlePointerMove(e.touches[0].pageX, e.touches[0].pageY); + const {pageX, pageY} = e.touches[0]; + this.handlePointerMove(this.sim.screenToSim(pageX, pageY)); }); el.addEventListener('pointerdown', e => { - if (DISPLAY_CURSOR_INFO) { - this.sim.info['Pointer down'] = [`${e.clientX}, `, `${e.clientY}`]; - } - this.handlePointerDown(e.clientX, e.clientY); + this.handlePointerDown(this.sim.screenToSim(e.clientX, e.clientY)); }); el.addEventListener('pointerup', e => { - if (DISPLAY_CURSOR_INFO) { - this.sim.info['Pointer up'] = [`${e.clientX}, `, `${e.clientY}`]; - } - this.handlePointerUp(e.clientX, e.clientY); + this.handlePointerUp(this.sim.screenToSim(e.clientX, e.clientY)); }); 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 = []; } - updatePointer(x, y) { + updatePointer({x, y}) { const t = document.timeline.currentTime; this.pointerHistory.push({x, y, t}); if (this.pointerHistory.length > POINTER_HISTORY_SIZE) { @@ -73,9 +66,9 @@ export class Pointer { } } - handlePointerDown(x, y) { + handlePointerDown({x, y}) { this.clearPointerHistory(); - this.updatePointer(x, y); + this.updatePointer({x, y}); // If pointer is touching an object, select the object 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.deselect(); } // 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 // If the cursor moves while creating an object, or while an object is selected, // update the position and velocity of the object const obj = this.sim.objects.getSelectedOrCreating(); if (obj !== undefined) { - this.updatePointer(x, y); + this.updatePointer({x, y}); const {x: vx, y: vy} = this.getPointerVelocity(); obj.position.x = x; obj.position.y = y; diff --git a/simulator.js b/simulator.js index 2942d0e..1109c73 100644 --- a/simulator.js +++ b/simulator.js @@ -7,6 +7,7 @@ export class Sim { info = {}; frame = 0; time = undefined; + nextZoom = undefined; display = undefined; overlay = undefined; @@ -28,14 +29,37 @@ export class Sim { 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 loop(currentTime) { const elapsedTime = currentTime - this.time; this.time = currentTime; - + if (this.nextZoom) { + this.zoom(this.nextZoom); + this.nextZoom = undefined; + } + this.display.fillCanvas(); this.objects.computeFrame(elapsedTime); - this.display.fillCanvas(); + this.info['scale'] = this.display.scale; + this.display.drawObjects(); this.overlay.renderInfo();