import { CellGrid } from '../structures' import Agent from './Agent' import { Angle, Color, RandomGenerator, Vector2 } from '../numbers' /** * Derive a value from a function of the cell's current state * instead of storing it as a constant. */ export function Dynamic(f: (cell: T) => V) { return function(target: T, propertyKey: string) { Object.defineProperty(target, propertyKey, { get: function() { return f(this as T) }, set: function(value) { return value } }) } } export type CellConstructor = { /** * The [x,y] location of the cell in the grid beginning at [0,0]. */ location: [number, number], color?: Color, strokeColor?: Color, energy?: number, } /** * A cell in a grid. The basic unit of space for a model. * May be used as-is or extended to include additional properties. * * The default cell has a `location`, `color`, and `energy`. Only the `location` is required. * The default color is white and the default energy is 0. * * Additionally, a cell has a set of `tenantAgents` which are agents that occupy the cell * at the current time step. */ export default class Cell { public location: [number, number] public tenantAgents = new Set() public color: Color public strokeColor: Color public energy: number constructor(opts: CellConstructor) { this.location = opts.location this.energy = opts.energy ?? 0 this.color = opts.color ?? Color.fromName('white') this.strokeColor = opts.strokeColor ?? undefined } /** * Gives the distance between this Cell and another Cell or Agent. */ public distanceTo( world: CellGrid, other: T, options?: { metric?: 'euclidean' | 'manhattan' } ): number { const { metric = 'euclidean' } = options ?? {} const otherPosition = other instanceof Agent ? other.position : new Vector2(other.location) if (world.boundaryCondition === 'periodic') { const width = world.cells.length const dx = Math.abs(this.location[0] - otherPosition.x) const dy = Math.abs(this.location[1] - otherPosition.y) const toroidalDx = Math.min(dx, width - dx) const toroidalDy = Math.min(dy, width - dy) if (metric === 'euclidean') { return Math.sqrt(toroidalDx ** 2 + toroidalDy ** 2) } else { return toroidalDx + toroidalDy } } else { if (metric === 'euclidean') { return Math.sqrt((this.location[0] - otherPosition.x) ** 2 + (this.location[1] - otherPosition.y) ** 2) } else { return Math.abs(this.location[0] - otherPosition.x) + Math.abs(this.location[1] - otherPosition.y) } } } /** * Gets all Cells within a given square radius. */ public getNeighborsInRadius( world: CellGrid, options?: { includeSelf?: boolean, radius?: number } ): Array { const cells = world.cells const neighbors = new Set() const x = this.location[0] const y = this.location[1] const { radius = 1, includeSelf = false } = options ?? {} const width = cells.length // assert degree is a positive integer if (!Number.isInteger(radius) || radius < 1) { throw new Error('getNeighborsInRadius radius option must be a positive integer.') } for (let i = -radius; i <= radius; i++) { for (let j = -radius; j <= radius; j++) { const newX = x + i const newY = y + j if (newX >= 0 && newX < width && newY >= 0 && newY < width) { neighbors.add(cells[newX][newY]) } else if (world.boundaryCondition === 'periodic') { neighbors.add(cells[(newX + width) % width][(newY + width) % width]) } } } if (!includeSelf) { neighbors.delete(this as unknown as T) } return Array.from(neighbors) } /** * Diffuses a property to neighboring cells. * The property function should return a number for each cell. * The cell diffuses equal shares of `rate` percentage of the property to each neighbor. */ public diffuse( world: CellGrid, property: (cell: T) => number, setter: (cell: T, value: number) => void, rate: number, range: number = 1 ): void { // Get the property value for this cell const value = property(this as unknown as T) const neighbors = this.getNeighborsInRadius(world, { radius: range }) // Calculate the total amount to diffuse const totalDiffusionAmount = value * rate // Calculate the amount to diffuse to each neighbor const amountPerNeighbor = totalDiffusionAmount / neighbors.length // Deplete the property on this cell by the total amount diffused setter(this as unknown as T, value - totalDiffusionAmount) // Diffuse the property to each neighbor for (const neighbor of neighbors) { const neighborValue = property(neighbor) setter(neighbor, neighborValue + amountPerNeighbor) } } /** * Performs a 2D convolution on the cell's neighbors using the given kernel. * The kernel is a 3x3 matrix of numbers. * The property function should return a number for each cell. * */ public getNeighborConvolution(world: CellGrid, kernel: number[][], property: (cell: T) => number): number { const neighbors = [] const x = this.location[0] const y = this.location[1] const width = world.cells.length // TODO: confirm this works with this.getNeighbors // gets the values of the neighbors in row-major order // with finite boundary conditions if (world.boundaryCondition === 'finite') { for (let i = -1; i <= 1; i++) { for (let j = -1; j <= 1; j++) { const newX = x + i const newY = y + j if (newX >= 0 && newX < width && newY >= 0 && newY < width) { const cell = world.cells[newX][newY] neighbors.push(property(cell)) } else { neighbors.push(0) } } } } else { // gets the values of the neighbors in row-major order // with toroidal boundary conditions for (let i = -1; i <= 1; i++) { for (let j = -1; j <= 1; j++) { const newX = (x + i + width) % width const newY = (y + j + width) % width const cell = world.cells[newX][newY] neighbors.push(property(cell)) } } } // perform the convolution let result = 0 for (let i = 0; i < kernel.length; i++) { for (let j = 0; j < kernel[i].length; j++) { result += kernel[i][j] * neighbors[i * 3 + j] } } return result } /** * Gets the four neighbors of the cell. * Optionally includes the four diagonal neighbors and this cell. */ public getNeighbors( world: CellGrid, options?: { includeDiagonals?: boolean, includeSelf?: boolean, } ): Array { const boundaryCondition = world.boundaryCondition const { includeDiagonals = false, includeSelf = false, } = options ?? {} const cells = world.cells const neighbors = new Set() const wrap = (coord, max) => (coord + max) % max const gridWidth = world.width const gridHeight = world.height const x = this.location[0] const y = this.location[1] if (boundaryCondition === 'periodic') { // left neighbors.add(cells[wrap(x - 1, gridWidth)][wrap(y, gridHeight)]) // right neighbors.add(cells[wrap(x + 1, gridWidth)][wrap(y, gridHeight)]) // top neighbors.add(cells[wrap(x, gridHeight)][wrap(y - 1, gridHeight)]) // bottom neighbors.add(cells[wrap(x, gridHeight)][wrap(y + 1, gridHeight)]) if (includeDiagonals) { // top left neighbors.add(cells[wrap(x - 1, gridHeight)][wrap(y - 1, gridHeight)]) // top right neighbors.add(cells[wrap(x + 1, gridHeight)][wrap(y - 1, gridHeight)]) // bottom left neighbors.add(cells[wrap(x - 1, gridHeight)][wrap(y + 1, gridHeight)]) // bottom right neighbors.add(cells[wrap(x + 1, gridHeight)][wrap(y + 1, gridHeight)]) } } else if (boundaryCondition === 'finite') { // left if (x > 0) { neighbors.add(cells[x - 1][y]) } // right if (x < gridWidth - 1) { neighbors.add(cells[x + 1][y]) } // up if (y > 0) { neighbors.add(cells[x][y - 1]) } // down if (y < gridHeight - 1) { neighbors.add(cells[x][y + 1]) } if (includeDiagonals) { if (x > 0 && y > 0) { // top-left neighbors.add(cells[x - 1][y - 1]) } if (x > 0 && y < gridHeight - 1) { // bottom-left neighbors.add(cells[x - 1][y + 1]) } if (x < gridWidth - 1 && y > 0) { // top-right neighbors.add(cells[x + 1][y - 1]) } if (x < gridWidth - 1 && y < gridHeight - 1) { neighbors.add(cells[x + 1][y + 1]) } } } else { throw new Error(`Unknown boundary condition: ${boundaryCondition}`) } if (includeSelf) { neighbors.add(this as unknown as T) } return Array.from(neighbors) } public toJSON(): object { const propNames = Object.getOwnPropertyNames(this) propNames.forEach((key) => { if (typeof this[key] === 'function') { delete this[key] } }) const json = {} propNames.forEach((key) => { if (this[key] instanceof Set) { json[key] = Array.from(this[key]) return } if (this[key] instanceof Vector2) { json[key] = this[key].components return } if (this[key] instanceof RandomGenerator) { return } if (this[key] instanceof Map) { json[key] = Array.from(this[key].entries()) return } if (this[key] instanceof Angle) { json[key] = `${this[key].asDegrees()} deg` return } if (this[key] instanceof Color) { json[key] = this[key].toRGB() return } json[key] = this[key] }) return json } }