agentscape
Version:
Agentscape is a library for creating agent-based simulations. It provides a simple API for defining agents and their behavior, and for defining the environment in which the agents interact. Agentscape is designed to be flexible and extensible, allowing
485 lines (401 loc) • 13.2 kB
text/typescript
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<U extends Agent|Cell> = (entity: U) => {
fill: Color,
stroke?: Color
}
export type StyleFunction<T extends Agent> = (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<T extends Cell>(world: CellGrid<T>,
opts:{
colorFunction?: ColorFunction<T>
} = {}
) {
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<T extends Agent>(
agents: AgentSet<T>,
opts: {
colorFunction?: ColorFunction<T>,
styleFunction?: StyleFunction<T>
} = {}
) {
const { colorFunction, styleFunction } = opts
agents.forEach(agent => {
this.drawAgent(agent, { colorFunction, styleFunction })
})
}
drawAgent<T extends Agent>(
agent: T,
opts: {
colorFunction?: ColorFunction<T>,
styleFunction?: StyleFunction<T>
} = {}
) {
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