import { VELOCITY_VECTOR_SCALE, VELOCITY_VECTOR_COLOR, VELOCITY_VECTOR_WIDTH, VELOCITY_VECTOR_ARROWHEAD, ACCELERATION_VECTOR_SCALE, ACCELERATION_VECTOR_COLOR, ACCELERATION_VECTOR_WIDTH, ACCELERATION_VECTOR_ARROWHEAD, ARROWHEAD_LENGTH, ARROWHEAD_WIDTH, OFFSCREEN_OBJECT_LINE_SCALE, OFFSCREEN_OBJECT_LINE_WIDTH, OFFSCREEN_OBJECT_ARROWHEAD_LENGTH, DISPLAY_CANVAS_SIZE, PATH_TRACES_COLOR, PATH_TRACES_WIDTH, } 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()); } get scale() { return 2 ** this.scalePower; } 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, }; } simToScreen(x, y) { return { x: (x - this.viewOrigin.x) * this.scale, y: (y - this.viewOrigin.y) * this.scale, }; } fullscreen() { this.canvas.width = document.documentElement.clientWidth; this.canvas.height = document.documentElement.clientHeight; if (DISPLAY_CANVAS_SIZE) { this.sim.info['Canvas'] = `${this.canvas.width} x ${this.canvas.height}`; } } fillCanvas() { const ctx = this.ctx; ctx.fillStyle = '#000'; ctx.fillRect(this.viewOrigin.x, this.viewOrigin.y, this.width, this.height); } drawObject(obj) { const ctx = this.ctx; const {r, g, b} = obj.color; const {x, y} = obj.position; const {x: vx, y: vy} = obj.velocity; const acceleration = obj.acceleration; 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; // Draw path traces if (this.sim.getOption('display.traces') && obj.history?.length) { ctx.strokeStyle = PATH_TRACES_COLOR === 'object color' ? `rgb(${r}, ${g}, ${b})` : PATH_TRACES_COLOR; ctx.lineWidth = PATH_TRACES_WIDTH / this.scale; ctx.beginPath(); let dash = false; const skip = 1; let skipped = 0; { const [{position}] = obj.history; const x = position.x; const y = position.y; if (Math.abs(x - cx) <= W / 2 && Math.abs(y - cy) <= H / 2) { ctx.moveTo(x, y); dash = true; } } for (let i = 1; i < obj.history.length; i++) { if (++skipped < skip) continue; skipped = 0; const {position} = obj.history[i]; const x = position.x; const y = position.y; if (dash) { ctx.lineTo(x, y); dash = false; } else if (Math.abs(x - cx) <= W / 2 && Math.abs(y - cy) <= H / 2) { ctx.moveTo(x, y); dash = true; } } ctx.stroke(); } if (!obj.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 = 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) * this.scale; 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, 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(); // Draw arrow for the velocity if (this.sim.getOption('display.velocity')) { console.log('velocity vector'); const speed = Math.sqrt(vx ** 2 + vy ** 2); const endVx = x + VELOCITY_VECTOR_SCALE * vx / speed * Math.log(speed); const endVy = y + VELOCITY_VECTOR_SCALE * vy / speed * Math.log(speed); const style = VELOCITY_VECTOR_COLOR === 'object color' ? `rgb(${r}, ${g}, ${b})` : VELOCITY_VECTOR_COLOR; this.drawArrow(x, y, endVx, endVy, { style, width: VELOCITY_VECTOR_WIDTH, arrowhead: VELOCITY_VECTOR_ARROWHEAD, fill: false, ifShort: 'head' }); } // Draw arrow for acceleration if (this.sim.getOption('display.acceleration')) { const accelerationMagnitude = Math.sqrt(acceleration.x ** 2 + acceleration.y ** 2); const endAx = x + ACCELERATION_VECTOR_SCALE * acceleration.x / accelerationMagnitude * Math.log(accelerationMagnitude); const endAy = y + ACCELERATION_VECTOR_SCALE * acceleration.y / accelerationMagnitude * Math.log(accelerationMagnitude); const style = ACCELERATION_VECTOR_COLOR === 'object color' ? `rgb(${r}, ${g}, ${b})` : ACCELERATION_VECTOR_COLOR; this.drawArrow(x, y, endAx, endAy, { style, width: ACCELERATION_VECTOR_WIDTH, arrowhead: ACCELERATION_VECTOR_ARROWHEAD, fill: false, ifShort: 'tail' }); } } drawObjects() { this.sim.objects.forEachObject(obj => this.drawObject(obj), null); } drawArrow(startX, startY, endX, endY, {style, width, arrowhead, arrowheadLength, fill, ifShort}) { const ctx = this.ctx; ctx.strokeStyle = style; // Keep arrows at normal scale ctx.lineWidth = width / this.scale; arrowhead = arrowhead ?? true; arrowheadLength = arrowheadLength ?? ARROWHEAD_LENGTH; const scaledArrowheadLength = arrowheadLength / this.scale; ifShort = ifShort ?? 'tail'; const arrowDirection = Math.atan2(endY - startY, endX - startX); const length = Math.sqrt((endX - startX)**2 + (endY - startY)**2); 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.moveTo(startX, startY); ctx.lineTo(endAx, endAy); ctx.stroke(); } if (arrowhead) { this.drawArrowHead(endX, endY, arrowDirection, {style, length: arrowheadLength, fill}); } } drawArrowHead(x, y, direction, {style, length, fill}) { const arrowheadLength = length ?? ARROWHEAD_LENGTH; const arrowheadWidth = arrowheadLength / ARROWHEAD_LENGTH * ARROWHEAD_WIDTH; const ctx = this.ctx; ctx.fillStyle = style; ctx.strokeStyle = 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(); if (fill !== false) { ctx.fill(); } else { ctx.stroke(); } ctx.resetTransform(); } }