import { Angle, Color } from '../../numbers' import { AgentSet, CellGrid } from '../../structures' import { Agent, Cell } from '../../entities' import { AgentStyle } from '../../entities/Agent' export interface Render2DConstructor { root: HTMLElement renderWidth: number renderHeight: number worldWidth: number id?: string title?: string autoPlay?: boolean frameRate?: number onCellClick?: (position: [number, number], renderer: Render2D) => void } export type ColorFunction = (entity: U) => { fill: Color, stroke?: Color } export type StyleFunction = (entity: T) => AgentStyle export class Render2D { renderWidth: number // in pixels renderHeight: number // in pixels ctx: CanvasRenderingContext2D frameRate: number cellWidth: number cellHeight: number constructor(opts: Render2DConstructor) { const { root, worldWidth, renderHeight, renderWidth, title, id, autoPlay, frameRate } = opts // create a draggable canvas element const draggable = document.createElement('drag-pane') draggable.style.zIndex = '1' draggable.id = 'canvas_main' draggable.setAttribute('heading', title ) draggable.setAttribute('key', id) this.renderHeight = renderHeight this.renderWidth = renderWidth this.cellWidth = renderWidth / worldWidth this.cellHeight = renderHeight / worldWidth const canvas = document.createElement('canvas') this.ctx = canvas.getContext('2d')! canvas.width = renderWidth canvas.height = renderHeight // create a click event handler for the canvas canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top // dispatch a custom event with the clicked cell coordinates const event = new CustomEvent('inspectClick', { detail: [Math.floor(x / this.cellWidth),Math.floor(y / this.cellHeight)] }) if (opts.onCellClick) { opts.onCellClick([Math.floor(x / this.cellWidth), Math.floor(y / this.cellHeight)], this) } window.dispatchEvent(event) }) draggable.appendChild(canvas) const controlBar = document.createElement('animation-toolbar') controlBar.setAttribute('autoPlay', autoPlay.toString()) controlBar.setAttribute('fps', frameRate.toString()) draggable.appendChild(controlBar) root.appendChild(draggable) } clear() { this.ctx.clearRect(0, 0, this.renderWidth, this.renderHeight) } drawCellGrid(world: CellGrid, opts:{ colorFunction?: ColorFunction } = {} ) { const { colorFunction } = opts for (let x = 0; x < world.width; x++) { for (let y = 0; y < world.height; y++) { const cell = world.getCell([x, y]) const color = colorFunction ? colorFunction(cell) : { fill: cell.color, stroke: cell.strokeColor } this.drawRectangle( x, y, { width: this.cellWidth, height: this.cellHeight, fill: color.fill, stroke: color.stroke } ) } } } /** * Draws agents on the canvas * @param agents */ drawAgentSet( agents: AgentSet, opts: { colorFunction?: ColorFunction, styleFunction?: StyleFunction } = {} ) { const { colorFunction, styleFunction } = opts agents.forEach(agent => { this.drawAgent(agent, { colorFunction, styleFunction }) }) } drawAgent( agent: T, opts: { colorFunction?: ColorFunction, styleFunction?: StyleFunction } = {} ) { const { colorFunction, styleFunction } = opts const color = colorFunction ? colorFunction(agent) : { fill: agent.color, stroke: agent.strokeColor } const style = styleFunction ? styleFunction(agent) : agent.style const [x, y] = agent.position.components const options = { width: agent.radius, height: agent.radius, radius: agent.radius, rotation: agent.rotation, fill: color.fill, stroke: color.stroke } if (style === AgentStyle.CIRCLE) { this.drawCircle( x, y, options ) } if (style === AgentStyle.TRIANGLE) { this.drawTriangle( x, y, options ) } if (style === AgentStyle.SQUARE) { this.drawRectangle( x, y, options ) } } public drawCircle( x: number, y: number, options?: { radius?: number, rotation?: Angle, fill?: Color, stroke?: Color lineWidth?: number showRotation?: boolean } ) { const { radius = 1, rotation = new Angle(0, 'rad'), fill = Color.fromName('blue'), stroke = undefined, showRotation = true, lineWidth = 1 } = options ?? {} const _x = x * this.cellWidth const _y = y * this.cellHeight const _radius = radius * (this.cellWidth / 2) this.ctx.fillStyle = fill.toRGB() this.ctx.beginPath() this.ctx.arc(_x, _y, _radius, 0, 2 * Math.PI) this.ctx.fill() if (stroke) { this.ctx.strokeStyle = stroke.toRGB() this.ctx.stroke() } // draw a line from the center of the circle to the edge to show rotation if (rotation && showRotation) { this.ctx.beginPath() this.ctx.moveTo(_x, _y) this.ctx.lineTo(_x + _radius * Math.cos(rotation.asRadians()), _y + _radius * Math.sin(rotation.asRadians())) } if (stroke) { this.ctx.strokeStyle = stroke.toRGB() this.ctx.lineWidth = lineWidth this.ctx.stroke() } } /** * Draws a unit rectangle centered at x,y */ public drawRectangle( x: number, y: number, options?: { width?: number, height?: number, rotation?: Angle, fill?: Color, stroke?: Color lineWidth?: number } ) { const { width = 1, height = 1, rotation = new Angle(0, 'rad'), fill = Color.fromName('blue'), stroke = undefined, lineWidth = 1 } = options ?? {} const _x = x * this.cellWidth const _y = y * this.cellHeight const _width = width * this.cellWidth const _height = height * this.cellHeight this.ctx.fillStyle = fill.toRGB() this.ctx.fillRect(_x, _y, _width, _height) if (stroke) { this.ctx.strokeStyle = stroke.toRGB() this.ctx.strokeRect(_x, _y, _width, _height) } // draw a line from the center of the rectangle to the edge to show rotation if (rotation) { this.ctx.beginPath() this.ctx.moveTo(_x + _width / 2, _y + _height / 2) this.ctx.lineTo(_x + _width / 2 + _width / 2 * Math.cos(rotation.asRadians()), _y + _height / 2 + _height / 2 * Math.sin(rotation.asRadians())) if (stroke) { this.ctx.strokeStyle = stroke.toRGB() this.ctx.lineWidth = lineWidth this.ctx.stroke() } } } public drawLine( x1: number, y1: number, x2: number, y2: number, stroke: Color, options?: { lineWidth?: number } ) { this.ctx.beginPath() this.ctx.moveTo(x1 * this.cellWidth, y1 * this.cellHeight) this.ctx.lineTo(x2 * this.cellWidth, y2 * this.cellHeight) this.ctx.strokeStyle = stroke.toRGB() this.ctx.lineWidth = options?.lineWidth ?? 1 this.ctx.stroke() } public drawTriangle( x: number, y: number, options?: { width?: number, rotation?: Angle, fill?: Color stroke?: Color, lineWidth?: number } ) { const { width = 1, rotation = new Angle(0, 'rad'), fill = Color.fromName('blue'), stroke = undefined, } = options const _width = width * this.cellWidth const _x = x * this.cellWidth const _y = y * this.cellHeight // Define the triangle's vertices relative to the origin const halfWidth = _width / 2 const height = (Math.sqrt(3) / 2) * _width + _width / 2 // Height of an equilateral triangle const vertices = [ { x: 0, y: -height / 2 }, // Top vertex { x: -halfWidth, y: height / 2 }, // Bottom-left vertex { x: halfWidth, y: height / 2 } // Bottom-right vertex ] // Save the current state of the canvas this.ctx.save() // Translate to the origin this.ctx.translate(_x, _y) // Rotate by R radians this.ctx.rotate(rotation.asDegrees()) // Begin drawing the triangle this.ctx.beginPath() this.ctx.moveTo(vertices[0].x, vertices[0].y) // Move to the first vertex for (let i = 1; i < vertices.length; i++) { this.ctx.lineTo(vertices[i].x, vertices[i].y) } this.ctx.closePath() // Fill and stroke the triangle this.ctx.fillStyle = fill.toRGB() this.ctx.fill() this.ctx.strokeStyle = stroke.toRGB() this.ctx.lineWidth = options?.lineWidth ?? 1 this.ctx.stroke() // Restore the canvas to its original state this.ctx.restore() } public drawPolygon( points: [number, number][], options?: { fill?: Color stroke?: Color, lineWidth?: number } ): void { const { fill = Color.fromName('blue'), stroke = undefined } = options ?? {} if (points.length < 3) { throw new Error('A polygon must have at least 3 vertices') } // Begin the path this.ctx.beginPath() // Move to the first point const firstPoint = points[0] const x0 = firstPoint[0] * this.cellWidth const y0 = firstPoint[1] * this.cellHeight this.ctx.moveTo(x0, y0) // Draw lines to the remaining points for (let i = 1; i < points.length; i++) { const point = points[i] const x = point[0] * this.cellWidth const y = point[1] * this.cellHeight this.ctx.lineTo(x, y) } // Close the path this.ctx.closePath() // Fill the polygon if (fill) { this.ctx.fillStyle = fill.toRGB() this.ctx.fill() } // Stroke the polygon if (stroke) { this.ctx.strokeStyle = stroke.toRGB() this.ctx.lineWidth = options?.lineWidth ?? 1 this.ctx.stroke() } // Restore the context this.ctx.restore() } public drawPolyline( points: [number, number][], stroke: Color, options?: { lineWidth?: number } ): void { if (points.length < 2) { throw new Error('A polyline must have at least 2 vertices') } // Begin the path this.ctx.beginPath() // Move to the first point const firstPoint = points[0] const x0 = firstPoint[0] * this.cellWidth const y0 = firstPoint[1] * this.cellHeight this.ctx.moveTo(x0, y0) // Draw lines to the remaining points for (let i = 1; i < points.length; i++) { const point = points[i] const x = point[0] * this.cellWidth const y = point[1] * this.cellHeight this.ctx.lineTo(x, y) } // Stroke the polyline this.ctx.lineWidth = options?.lineWidth ?? 1 this.ctx.strokeStyle = stroke.toRGB() this.ctx.stroke() // Restore the context this.ctx.restore() } } export default Render2D