diff --git a/Readme.md b/Readme.md index 5c8dd42..0f374ab 100644 --- a/Readme.md +++ b/Readme.md @@ -53,3 +53,4 @@ TODO - [ ] Feature: Lossy Rescaling To Widen Zoom (Handling overflow/underflow) - [ ] Enhancement: Track farthest reaches, min/max in each dimension (x, y) - [x] Task: Verify stationary pointer leads to zero pointer velocity +- [ ] Fix: Unpause panning when initiated while sim is paused diff --git a/config.js b/config.js index 0a6f84b..8ce6543 100644 --- a/config.js +++ b/config.js @@ -8,6 +8,11 @@ export const ACCELERATION_VECTOR_COLOR = 'rgba(0, 128, 0, 0.8)'; // optionally s export const ACCELERATION_VECTOR_WIDTH = 1.5; export const ACCELERATION_VECTOR_ARROWHEAD = true; +// SELECTION +export const SELECTED_GROUP_COLOR = 'rgba(0, 255, 0, 0.50)'; +export const SELECTED_SINGLE_COLOR = 'rgba(0, 255, 0, 1.0)'; +export const SELECTED_MARGIN = 5; + // PATH TRACES export const PATH_TRACES_COLOR = 'object color'; export const PATH_TRACES_OPACITY = 0.8; diff --git a/display.js b/display.js index 6451f22..38ed700 100644 --- a/display.js +++ b/display.js @@ -4,22 +4,20 @@ import { } from './config.js'; export class Display { - sim = undefined; scalePower = 0; viewOrigin = {x: 0, y: 0}; constructor(sim) { - this.sim = sim; - // Create canvas that fills the window - // If the window resizes, also resize the canvas const canvas = document.createElement('canvas') this.canvas = canvas; - this.sim.div.appendChild(canvas); - canvas.style.position = 'absolute'; - canvas.style.top = 0; - canvas.style.left = 0; - this.fullscreen(); - window.addEventListener('resize', () => this.fullscreen()); + + if (sim) { + // Create canvas that fills the window + // If the window resizes, also resize the canvas + sim.div.appendChild(canvas); + this.fullscreen(); + window.addEventListener('resize', () => this.fullscreen()); + } } frame() { diff --git a/object.js b/object.js index 684a841..a1336c5 100644 --- a/object.js +++ b/object.js @@ -9,11 +9,14 @@ import { 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, direction, div, magnitude} from './vector.js'; +import {add, components, direction, div, magnitude, zero} from './vector.js'; export class MassObject { sim = undefined; @@ -21,8 +24,8 @@ export class MassObject { mass = 0; density = 1; position = {x: undefined, y: undefined}; - velocity = {x: 0, y: 0}; - acceleration = {x: 0, y: 0}; + velocity = zero; + acceleration = zero; color = {r: undefined, g: undefined, b: undefined}; created = undefined; forces = []; // [{x, y}] @@ -58,46 +61,21 @@ export class MassObject { } getAcceleration() { - let ax = 0; - let ay = 0; - for (let {x, y} of this.forces) { - ax += x; - ay += y; - } - return { - x: ax / this.mass, - y: ay / this.mass, - }; + const netForce = this.forces.reduce((t, f) => add(t, f), zero); + return div(netForce, this.mass); } - drawObject(sim) { - const { - color: {r, g, b}, - position: {x, y}, - velocity: {x: vx, y: vy}, - acceleration, - radius, - } = this; - - const { - display: { - ctx, - height: H, - width: W, - viewOrigin: {x: ox, y: oy}, - } - } = sim; - - const cx = ox + W / 2; - const cy = oy + H / 2; + drawPath(display) { + const {ctx} = display; + const {color: {r, g, b}} = this; // Draw path traces - if (sim.getOption('display.traces') && this.history?.length) { - const dashedTraces = sim.getOption('display.dashedTraces'); + 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 / this.sim.display.scale; + ctx.lineWidth = PATH_TRACES_WIDTH / display.scale; ctx.beginPath(); let dash = false; for (let i = 0; i < this.history.length; i++) { @@ -116,6 +94,114 @@ export class MassObject { } 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; @@ -153,11 +239,11 @@ export class MassObject { 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 + 1) * OFFSCREEN_OBJECT_LINE_SCALE / this.sim.display.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); - sim.display.drawArrow(startAx, startAy, px, py, { + display.drawArrow(startAx, startAy, px, py, { style: `rgb(${r}, ${g}, ${b})`, width: OFFSCREEN_OBJECT_LINE_WIDTH, arrowheadLength: OFFSCREEN_OBJECT_ARROWHEAD_LENGTH, @@ -173,73 +259,26 @@ export class MassObject { ctx.beginPath(); ctx.arc(x, y, radius, 0, 2 * Math.PI); ctx.fill(); - - // Draw arrow for the velocity - if (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 - // TODO: Make logarithmic vector length scale optional - // Make arrow lengths appear consistent regardless of display scale - let arrowLength = Math.log10(speed + 1) * vecScale; - if (!this.sim.getOption('display.zoomVectors')) { - arrowLength /= this.sim.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; - sim.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 (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 - // TODO: Make logarithmic vector length scale optional - // 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; - sim.display.drawArrow(start.x, start.y, end.x, end.y, { - style, - width: ACCELERATION_VECTOR_WIDTH, - arrowhead: ACCELERATION_VECTOR_ARROWHEAD, - fill: false, - ifShort: 'tail' - }); - } - } + 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(); + } + } } diff --git a/select.js b/select.js index 187ade5..67144c4 100644 --- a/select.js +++ b/select.js @@ -1,33 +1,74 @@ +import {copy} from './vector.js'; + export class Select { sim = undefined; box = { start: undefined, end: undefined, }; + selectedSingle = undefined; + selectedGroup = []; constructor(sim) { this.sim = sim; + + // TODO: Move this to a new Keyboard class singleton + window.addEventListener('keydown', (e) => { + switch (e.key) { + case 'Tab': { + e.preventDefault(); + if (!this.selectedGroup.length) return; + const currentIdx = this.selectedGroup.indexOf(this.selectedSingle); + const newIdx = (currentIdx + 1) % this.selectedGroup.length; + this.selectedSingle = this.selectedGroup[newIdx]; + break; + } + } + }); } - + handlePointerDown({x: clientX, y: clientY}) { this.box.start = this.sim.screenToSim(clientX, clientY); + this.box.end = this.box.start; + this.getSelectedObjects(); } handlePointerMove({x: clientX, y: clientY}) { + if (!this.box.start) return; this.box.end = this.sim.screenToSim(clientX, clientY); } handlePointerUp() { - console.log('selection box', {...this.box}); + if (!this.box.start) return; + const start = copy(this.box.start); + const end = copy(this.box.end); + this.box.start = { + x: Math.min(start.x, end.x), + y: Math.min(start.y, end.y), + }; + this.box.end = { + x: Math.max(start.x, end.x), + y: Math.max(start.y, end.y), + }; + this.getSelectedObjects(); this.box = { start: undefined, end: undefined, }; } + getSelectedObjects() { + const {start, end} = this.box; + if (!start) return; + this.selectedGroup = this.sim.system.filter(({position: {x, y}}) => { + return x >= start.x && x <= end.x && y >= start.y && y <= end.y; + }); + // For now, first object in group is selected single + this.selectedSingle = this.selectedGroup[0] ?? undefined; + } + frame() { if (!this.box.start) return; - this.sim.display.drawBox(this.box) } } diff --git a/sim-tools.js b/sim-tools.js index 45f98b4..0abf9ab 100644 --- a/sim-tools.js +++ b/sim-tools.js @@ -1,4 +1,6 @@ import {ModeSwitch} from './tool/modes.js'; +import {ObjectTool} from './tool/object.js'; +import {ObjectsTool} from './tool/objects.js'; import {OptionsTool} from './tool/options.js'; import {PlayPause} from './tool/play-pause.js'; import {UtilityTool} from './tool/utility.js'; @@ -7,43 +9,40 @@ import {ToolbarGroup} from './toolbar-group.js'; import {Toolbar} from './toolbar.js'; export function initializeTools(sim) { - sim.toolbars = { - tools: new Toolbar(sim, 'Tools') - .addTool(new Zoom()) - .addTool(new PlayPause()), - modes: new Toolbar(sim, 'Modes') - .addTool(new ModeSwitch()), - utils: new Toolbar(sim, 'Utils', {expanded: false}) - .addTool(new UtilityTool()), - options: new Toolbar(sim, 'Options') - .addTool(new OptionsTool([ - 'pauseDuring', - 'display', - 'compensate', - ])), - params: new Toolbar(sim, 'Params') - .addTool(new OptionsTool([ - 'param' - ])), - debug: new Toolbar(sim, 'Debug', {expanded: false}) - .addTool(new OptionsTool([ - 'debug' - ])), - }; - const {tools, modes, options, params, debug, utils} = sim.toolbars; sim.toolbarGroups = { left: new ToolbarGroup(sim) - .addToolbar(tools) - .addToolbar(modes) - .addToolbar(utils), + .addToolbar(new Toolbar(sim, 'Tools') + .addTool(new Zoom()) + .addTool(new PlayPause()) + ) + .addToolbar(new Toolbar(sim, 'Modes') + .addTool(new ModeSwitch()) + ) + .addToolbar(new Toolbar(sim, 'Utils', {expanded: false}) + .addTool(new UtilityTool()) + ) + .addToolbar(new Toolbar(sim, 'Objects') + .addTool(new ObjectsTool()), + ), right: new ToolbarGroup(sim).topRight() - .addToolbar(options) - .addToolbar(params) - .addToolbar(debug), + .addToolbar(new Toolbar(sim, 'Options') + .addTool(new OptionsTool(['pauseDuring', 'display', 'compensate'])) + ) + .addToolbar(new Toolbar(sim, 'Params') + .addTool(new OptionsTool([ + 'param' + ])), + ) + .addToolbar(new Toolbar(sim, 'Debug', {expanded: false}) + .addTool(new OptionsTool(['debug'])), + ) + .addToolbar(new Toolbar(sim, 'Object') + .addTool(new ObjectTool()), + ), }; - for (const id in sim.toolbars) { - const toolbar = sim.toolbars[id]; - toolbar.applyExpanded(); + for (const groupName in sim.toolbarGroups) { + const group = sim.toolbarGroups[groupName]; + group.applyExpanded(); } } diff --git a/style.css b/style.css index 54a4002..22652d5 100644 --- a/style.css +++ b/style.css @@ -27,6 +27,12 @@ div[id=simulator] { width: 100%; } +div[id=simulator] > canvas { + position: absolute; + top: 0; + left: 0; +} + /* normal toolbar group */ div.lhg-toolbar-group div.lhg-tool { width: 12em; @@ -138,10 +144,3 @@ div.lhg-option-group { display: flex; flex-wrap: wrap; } - -/* -div.lhg-option-group > * { - display: inline-block; - vertical-align: top; -} -*/ diff --git a/system.js b/system.js index ff991b6..637fd34 100644 --- a/system.js +++ b/system.js @@ -317,7 +317,11 @@ export class System { } drawObjects() { - this.forEachObject(obj => obj.drawObject(this.sim), {alive: null}); + // Draw all paths, all objects, and then all arrows + this.forEachObject(obj => obj.drawPath(this.sim.display), {alive: null}); + this.forEachObject(obj => obj.drawObject(this.sim.display), {alive: true}); + this.forEachObject(obj => obj.drawSelection(this.sim.display), {alive: true}); + this.forEachObject(obj => obj.drawArrows(this.sim.display), {alive: true}); } // cb: (acc, obj, idx) => {} @@ -332,6 +336,18 @@ export class System { return acc; } + // cb: (obj, idx) => boolean + filter(cb, opts) { + let objects = []; + this.forEachObject((obj, idx) => { + const ret = cb(obj, idx); + if (ret) { + objects.push(obj); + } + }, opts); + return objects; + } + computeForces() { const gravity = this.sim.getOption('param.gravity'); if (this.objects.length < 2) return; diff --git a/tool/object.js b/tool/object.js new file mode 100644 index 0000000..222c11a --- /dev/null +++ b/tool/object.js @@ -0,0 +1,49 @@ +import {Display} from '../display.js'; +import {Tool} from '../tool.js'; +import {div, sub} from '../vector.js'; + +export class ObjectTool extends Tool { + selected = undefined; + display = undefined; + textEl = undefined; + + setContainer(container) { + super.setContainer(container); + } + + constructor() { + super(); + + this.display = new Display(); + this.display.canvas.width = 150; + this.display.canvas.height = 150; + + this.textEl = document.createElement('div'); + + this.div.appendChild(this.textEl); + this.div.appendChild(this.display.canvas); + } + + frame() { + this.display.frame(); + const obj = this.sim.select.selectedSingle; + this.textEl.innerHTML = `obj id: ${obj?.id}`; + if (!obj) return; + + // Show object details + // Distance from center of screen + // Render the object + + // Set up this.display.viewOrigin + + const {radius, position} = obj; + const widthRatio = Math.abs(2 * radius) / this.display.canvas.width; + const heightRatio = Math.abs(2 * radius) / this.display.canvas.height; + const ratio = Math.max(widthRatio, heightRatio) * 2; + this.display.scalePower = Math.log2(1 / ratio); + this.display.viewOrigin = sub(position, + div({x: this.display.width, y: this.display.height}, 2)); + obj.drawObject(this.display); + obj.drawArrows(this.display); + } +} diff --git a/tool/objects.js b/tool/objects.js new file mode 100644 index 0000000..a7d61d7 --- /dev/null +++ b/tool/objects.js @@ -0,0 +1,11 @@ +import {Tool} from '../tool.js'; + +export class ObjectsTool extends Tool { + setContainer(container) { + super.setContainer(container); + } + + constructor() { + super(); + } +} diff --git a/tool/zoom.js b/tool/zoom.js index 6906be4..f01e8ba 100644 --- a/tool/zoom.js +++ b/tool/zoom.js @@ -24,8 +24,8 @@ export class Zoom extends Tool { }); } - constructor(container) { - super(container); + constructor() { + super(); const currentScale = document.createElement('button') const zoomOut = document.createElement('button'); diff --git a/toolbar-group.js b/toolbar-group.js index 67abc07..c0bd5c4 100644 --- a/toolbar-group.js +++ b/toolbar-group.js @@ -28,8 +28,14 @@ export class ToolbarGroup { return this; } + applyExpanded() { + for (const toolbar of this.toolbars) { + toolbar.applyExpanded(); + } + } + frame() { - for (let toolbar of this.toolbars) { + for (const toolbar of this.toolbars) { toolbar.frame(); } }