├── docs ├── .nojekyll ├── ant-256.png ├── ant-32.png ├── _coverpage.md └── index.html ├── .npmignore ├── .gitignore ├── __tests__ ├── centroid-distance.js ├── test-helpers.js ├── overlap.js ├── within.js ├── centroid-within.js ├── boundary-distance.js ├── inside-distance.js ├── x-set.js ├── from-functions.js ├── vector.js ├── polyline.js └── helpers.js ├── README.md ├── src ├── centroid-distance.js ├── index.js ├── random.js ├── centroid-within.js ├── overlap.js ├── within.js ├── grid.js ├── inside-distance.js ├── boundary-distance.js ├── from-functions.js ├── x-set.js ├── simplify.js ├── vector.js ├── random-circles.js ├── agent.js ├── square.js ├── polyline.js ├── regions.js ├── zone.js ├── autotile.js ├── helpers.js ├── actor.js └── simulation.js ├── package.json ├── LICENSE └── CHANGELOG.md /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .vscode/ 3 | docs/ 4 | __tests__/ 5 | scratch.js -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | npm-debug.log 4 | .vscode/ 5 | scratch.js -------------------------------------------------------------------------------- /docs/ant-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gjmcn/atomic-agents/HEAD/docs/ant-256.png -------------------------------------------------------------------------------- /docs/ant-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gjmcn/atomic-agents/HEAD/docs/ant-32.png -------------------------------------------------------------------------------- /docs/_coverpage.md: -------------------------------------------------------------------------------- 1 | ![logo](ant-256.png) 2 | 3 | # Atomic Agents 4 | 5 | > Spatial Agent-based Modeling in JavaScript 6 | 7 | [GitHub](https://github.com/gjmcn/atomic-agents) 8 | [Examples](https://observablehq.com/collection/@gjmcn/atomic-agents) 9 | [Documentation](#introduction) -------------------------------------------------------------------------------- /__tests__/centroid-distance.js: -------------------------------------------------------------------------------- 1 | import { centroidDistance, centroidDistanceSqd } 2 | from '../src/centroid-distance'; 3 | 4 | const u = {x: 2, y: 6}; 5 | const v = {x: -1, y: 10}; 6 | 7 | test('centroidDistance', () => { 8 | expect(centroidDistance(u, v)).toBe(5) 9 | }); 10 | 11 | test('centroidDistanceSqd', () => { 12 | expect(centroidDistanceSqd(u, v)).toBe(25) 13 | }); -------------------------------------------------------------------------------- /__tests__/test-helpers.js: -------------------------------------------------------------------------------- 1 | export function circle(x, y, radius) { 2 | return { 3 | _shape: 'circle', 4 | x, 5 | y, 6 | radius 7 | }; 8 | } 9 | 10 | export function rect(xMin, xMax, yMin, yMax) { 11 | return { 12 | _shape: 'rect', 13 | xMin, 14 | xMax, 15 | yMin, 16 | yMax, 17 | x: (xMin + xMax) / 2, 18 | y: (yMin + yMax) / 2 19 | }; 20 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Atomic Agents 2 | 3 | __Spatial agent-based modeling in JavaScript__ 4 | 5 | * [Docs](https://gjmcn.github.io/atomic-agents) 6 | 7 | * [Examples](https://observablehq.com/collection/@gjmcn/atomic-agents) 8 | 9 | This module was written for the _Visualising Contact Networks in Response to COVID-19_ UKRI-funded project (University of Warwick and Swansea University). It is still under active development — contributions are welcome. -------------------------------------------------------------------------------- /src/centroid-distance.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Centroid-to-centroid distance functions. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | export function centroidDistance(u, v) { 6 | return Math.sqrt((v.x - u.x) ** 2 + (v.y - u.y) ** 2); 7 | } 8 | 9 | export function centroidDistanceSqd(u, v) { 10 | return (v.x - u.x) ** 2 + (v.y - u.y) ** 2; 11 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 2 | export { random } from './random.js'; 3 | export { 4 | shuffle, loop, randomElement, frame, gridInRect, gridInHex, partitionRect 5 | } from './helpers.js'; 6 | export { autotile } from './autotile.js'; 7 | export { XSet } from './x-set.js'; 8 | export { Vector } from './vector.js'; 9 | export { Polyline } from './polyline.js'; 10 | export { Simulation } from './simulation.js'; 11 | export { Actor } from './actor.js'; 12 | export { Square } from './square.js'; 13 | export { Zone } from './zone.js'; -------------------------------------------------------------------------------- /__tests__/overlap.js: -------------------------------------------------------------------------------- 1 | import { circle, rect } from './test-helpers'; 2 | import { overlap } from '../src/overlap'; 3 | 4 | const c1 = circle(1, 2, 3); 5 | const c2 = circle(5, 3, 2); 6 | const c3 = circle(8, 0, 4); 7 | 8 | const r1 = rect(1, 7, 4, 11); 9 | const r2 = rect(6, 8, 5, 8); 10 | const r3 = rect(9, 11, 6, 7); 11 | 12 | test('c1, c2: overlap', () => { expect(overlap(c1, c2)).toBe(true) }); 13 | test('c2, c3: overlap', () => { expect(overlap(c2, c3)).toBe(true) }); 14 | test('c1, c3: no overlap', () => { expect(overlap(c1, c3)).toBe(false) }); 15 | test('r1, r2: overlap', () => { expect(overlap(r1, r2)).toBe(true) }); 16 | test('r1, r3: no overlap', () => { expect(overlap(r1, r3)).toBe(false) }); 17 | test('r1, c1: overlap', () => { expect(overlap(r1, c1)).toBe(true) }); 18 | test('c1, r1: overlap', () => { expect(overlap(c1, r1)).toBe(true) }); 19 | test('r3, c3: no overlap', () => { expect(overlap(r3, c3)).toBe(false) }); 20 | test('c3, r3: no overlap', () => { expect(overlap(c3, r3)).toBe(false) }); -------------------------------------------------------------------------------- /__tests__/within.js: -------------------------------------------------------------------------------- 1 | import { circle, rect } from './test-helpers'; 2 | import { within } from '../src/within'; 3 | 4 | const c1 = circle(4, 3, 2); 5 | const c2 = circle(4.5, 3.5, 1); 6 | 7 | const r1 = rect(2, 7, 1, 6); 8 | const r2 = rect(3, 6, 2, 5); 9 | const r3 = rect(4, 5, 2, 3); 10 | 11 | test('c2, c1: within', () => { expect(within(c2, c1)).toBe(true) }); 12 | test('c1, c2: within', () => { expect(within(c1, c2)).toBe(false) }); 13 | 14 | test('r2, r1: within', () => { expect(within(r2, r1)).toBe(true) }); 15 | test('r1, r2: within', () => { expect(within(r1, r2)).toBe(false) }); 16 | test('r3, r2: within', () => { expect(within(r3, r2)).toBe(true) }); 17 | test('r2, r2: within', () => { expect(within(r2, r2)).toBe(true) }); 18 | 19 | test('c2, r2: within', () => { expect(within(c2, r2)).toBe(true) }); 20 | test('c1, r2: within', () => { expect(within(c1, r2)).toBe(false) }); 21 | test('r3, c1: within', () => { expect(within(r3, c1)).toBe(true) }); 22 | test('r2, c1: within', () => { expect(within(r2, c1)).toBe(false) }); -------------------------------------------------------------------------------- /src/random.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3-random'; 2 | 3 | export const random = {}; 4 | 5 | random.seed = function(s) { 6 | 7 | const source = s === null || s === undefined 8 | ? Math.random 9 | : d3.randomLcg(s); 10 | 11 | for (let d of [ 12 | 'uniform', 'int', 'normal', 'logNormal', 'bates', 'irwinHall', 13 | 'exponential', 'pareto', 'bernoulli', 'geometric', 'binomial', 'gamma', 14 | 'beta', 'weibull', 'cauchy', 'logistic', 'poisson' 15 | ]) { 16 | const d3Name = `random${d[0].toUpperCase()}${d.slice(1)}`; 17 | random[d] = d3[d3Name].source(source); 18 | } 19 | 20 | random.categorical = function(probs) { 21 | let total = 0; 22 | let cumuProbs = []; 23 | for (let p of probs) cumuProbs.push(total += p); 24 | return function() { 25 | const v = source() * total; 26 | for (var j = 0; j < cumuProbs.length - 1; j++) { 27 | if (v < cumuProbs[j]) return j; 28 | } 29 | return j; 30 | } 31 | }; 32 | 33 | random.uniform_01 = source; 34 | 35 | }; 36 | 37 | random.seed(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gjmcn/atomic-agents", 3 | "version": "0.1.11", 4 | "description": "Spatial Agent-based Modeling in JavaScript.", 5 | "type": "module", 6 | "main": "src/index.js", 7 | "module": "dist/index.js", 8 | "scripts": { 9 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", 10 | "build": "esbuild src/index.js --format=esm --bundle --sourcemap --outfile=dist/index.js" 11 | }, 12 | "jest": { 13 | "testPathIgnorePatterns": [ 14 | "/__tests__/test-helpers.js" 15 | ] 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/gjmcn/atomic-agents.git" 20 | }, 21 | "author": "Graham McNeill", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/gjmcn/atomic-agents/issues" 25 | }, 26 | "homepage": "https://github.com/gjmcn/atomic-agents#readme", 27 | "dependencies": { 28 | "d3-ease": "^3.0.1", 29 | "d3-random": "^3.0.1" 30 | }, 31 | "devDependencies": { 32 | "esbuild": "^0.12.19", 33 | "jest": "^27.0.6" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Graham McNeill 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/centroid-within.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Centroid within function - returns true if centroid of first argument is 3 | // within second. 4 | //////////////////////////////////////////////////////////////////////////////// 5 | 6 | import { centroidDistanceSqd } from './centroid-distance.js'; 7 | 8 | export function centroidWithin(u, v) { 9 | return centroidWithinFunctions[u._shape][v._shape](u, v); 10 | } 11 | 12 | const centroidWithinFunctions = { 13 | 14 | circle: { 15 | 16 | circle(c1, c2) { 17 | return centroidDistanceSqd(c1, c2) <= c2.radius ** 2; 18 | }, 19 | 20 | rect(c, r) { 21 | return c.x >= r.xMin && 22 | c.x <= r.xMax && 23 | c.y >= r.yMin && 24 | c.y <= r.yMax; 25 | } 26 | 27 | }, 28 | 29 | rect: { 30 | 31 | circle(r, c) { 32 | return centroidDistanceSqd(r, c) <= c.radius ** 2; 33 | }, 34 | 35 | rect(r1, r2) { 36 | return r1.x >= r2.xMin && 37 | r1.x <= r2.xMax && 38 | r1.y >= r2.yMin && 39 | r1.y <= r2.yMax; 40 | } 41 | 42 | } 43 | 44 | }; -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Atomic Agents 6 | 7 | 8 | 9 | 10 | 11 | 22 | 23 | 24 |
25 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /__tests__/centroid-within.js: -------------------------------------------------------------------------------- 1 | import { circle, rect } from './test-helpers'; 2 | import { centroidWithin } from '../src/centroid-within'; 3 | 4 | const c1 = circle(4, 3, 2); 5 | const c2 = circle(4.5, 3.5, 0.5); 6 | 7 | const r1 = rect(2, 7, 1, 6); 8 | const r2 = rect(3, 6, 2, 5); 9 | const r3 = rect(4, 5, 2, 3); 10 | 11 | test('c2, c1: centroidWithin', () => { expect(centroidWithin(c2, c1)).toBe(true) }); 12 | test('c1, c2: centroidWithin', () => { expect(centroidWithin(c1, c2)).toBe(false) }); 13 | 14 | test('r2, r1: centroidWithin', () => { expect(centroidWithin(r2, r1)).toBe(true) }); 15 | test('r1, r2: centroidWithin', () => { expect(centroidWithin(r1, r2)).toBe(true) }); 16 | test('r1, r3: centroidWithin', () => { expect(centroidWithin(r1, r3)).toBe(false) }); 17 | test('r2, r2: centroidWithin', () => { expect(centroidWithin(r2, r2)).toBe(true) }); 18 | 19 | test('c1, r2: centroidWithin', () => { expect(centroidWithin(c1, r2)).toBe(true) }); 20 | test('c2, r3: centroidWithin', () => { expect(centroidWithin(c2, r3)).toBe(false) }); 21 | test('r3, c1: centroidWithin', () => { expect(centroidWithin(r3, c1)).toBe(true) }); 22 | test('r3, c2: centroidWithin', () => { expect(centroidWithin(r3, c2)).toBe(false) }); -------------------------------------------------------------------------------- /src/overlap.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Overlap function. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | import { centroidDistanceSqd } from './centroid-distance.js'; 6 | 7 | export function overlap(u, v) { 8 | return overlapFunctions[u._shape][v._shape](u, v); 9 | } 10 | 11 | const overlapFunctions = { 12 | 13 | circle: { 14 | 15 | circle(c1, c2) { 16 | return centroidDistanceSqd(c1, c2) < (c1.radius + c2.radius) ** 2; 17 | }, 18 | 19 | rect(c, r) { 20 | let testX, testY; 21 | if (c.x < r.xMin) testX = r.xMin; 22 | else if (c.x > r.xMax) testX = r.xMax; 23 | else testX = c.x; 24 | if (c.y < r.yMin) testY = r.yMin; 25 | else if (c.y > r.yMax) testY = r.yMax; 26 | else testY = c.y; 27 | return (c.x - testX) ** 2 + (c.y - testY) ** 2 < c.radius ** 2; 28 | } 29 | 30 | }, 31 | 32 | rect: { 33 | 34 | circle(r, c) { 35 | return overlapFunctions.circle.rect(c, r); 36 | }, 37 | 38 | rect(r1, r2) { 39 | return r1.xMax > r2.xMin && 40 | r1.xMin < r2.xMax && 41 | r1.yMax > r2.yMin && 42 | r1.yMin < r2.yMax; 43 | } 44 | 45 | } 46 | 47 | }; -------------------------------------------------------------------------------- /src/within.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Within function - returns true if first argument is within second. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | import { centroidDistance } from './centroid-distance.js'; 6 | 7 | export function within(u, v) { 8 | return withinFunctions[u._shape][v._shape](u, v); 9 | } 10 | 11 | const withinFunctions = { 12 | 13 | circle: { 14 | 15 | circle(c1, c2) { 16 | return centroidDistance(c1, c2) + c1.radius <= c2.radius; 17 | }, 18 | 19 | rect(c, r) { 20 | return c.x - c.radius >= r.xMin && 21 | c.x + c.radius <= r.xMax && 22 | c.y - c.radius >= r.yMin && 23 | c.y + c.radius <= r.yMax; 24 | } 25 | 26 | }, 27 | 28 | rect: { 29 | 30 | circle(r, c) { 31 | return Math.max((r.xMax - c.x) ** 2, (r.xMin - c.x) ** 2) + 32 | Math.max((r.yMax - c.y) ** 2, (r.yMin - c.y) ** 2) 33 | <= c.radius ** 2; 34 | }, 35 | 36 | rect(r1, r2) { // check opposite corners of r1 are both in r2 37 | return r1.xMin >= r2.xMin && 38 | r1.xMin <= r2.xMax && 39 | r1.yMin >= r2.yMin && 40 | r1.yMin <= r2.yMax && 41 | r1.xMax >= r2.xMin && 42 | r1.xMax <= r2.xMax && 43 | r1.yMax >= r2.yMin && 44 | r1.yMax <= r2.yMax; 45 | } 46 | 47 | } 48 | 49 | }; 50 | 51 | -------------------------------------------------------------------------------- /__tests__/boundary-distance.js: -------------------------------------------------------------------------------- 1 | import { circle, rect } from './test-helpers'; 2 | import { boundaryDistance } from '../src/boundary-distance'; 3 | 4 | const c1 = circle(0.5, 0.5, 0.5); 5 | const c2 = circle(3, 2, 1); 6 | const c3 = circle(5, 3, 2); 7 | 8 | const r1 = rect(3, 5, 1, 2); 9 | const r2 = rect(0, 2, 4, 5); 10 | const r3 = rect(1, 2, 3, 6); 11 | 12 | // circle-circle 13 | test('c1, c2: boundaryDistance', () => { 14 | expect(boundaryDistance(c1, c2)) 15 | .toBeCloseTo(Math.sqrt(2.5 ** 2 + 1.5 ** 2) - 1.5) 16 | }); 17 | test('c2, c1: boundaryDistance', () => { 18 | expect(boundaryDistance(c2, c1)) 19 | .toBeCloseTo(Math.sqrt(2.5 ** 2 + 1.5 ** 2) - 1.5) 20 | }); 21 | test('c2, c3: boundaryDistance', () => { 22 | expect(boundaryDistance(c2, c3)) 23 | .toBe(0) 24 | }); 25 | 26 | // rect-rect 27 | test('r1, r2: boundaryDistance', () => { 28 | expect(boundaryDistance(r1, r2)) 29 | .toBeCloseTo(Math.sqrt(5)) 30 | }); 31 | test('r2, r1: boundaryDistance', () => { 32 | expect(boundaryDistance(r2, r1)) 33 | .toBeCloseTo(Math.sqrt(5)) 34 | }); 35 | test('r2, r3: boundaryDistance', () => { 36 | expect(boundaryDistance(r2, r3)) 37 | .toBe(0) 38 | }); 39 | 40 | // circle-rect 41 | test('c2, r2: boundaryDistance', () => { 42 | expect(boundaryDistance(c2, r2)) 43 | .toBeCloseTo(Math.sqrt(5) - 1) 44 | }); 45 | test('r2, c2: boundaryDistance', () => { 46 | expect(boundaryDistance(r2, c2)) 47 | .toBeCloseTo(Math.sqrt(5) - 1) 48 | }); 49 | test('r1, c2: boundaryDistance', () => { 50 | expect(boundaryDistance(r1, c2)) 51 | .toBe(0) 52 | }); -------------------------------------------------------------------------------- /__tests__/inside-distance.js: -------------------------------------------------------------------------------- 1 | import { circle, rect } from './test-helpers'; 2 | import { insideDistance } from '../src/inside-distance'; 3 | 4 | const c1 = circle(3, 6, 2); 5 | const c2 = circle(2.5, 5.5, 0.5); 6 | const c3 = circle(8, 5, 1); 7 | const c4 = circle(7, 6, Math.SQRT2); 8 | const c5 = circle(5.5, 2.5, 0.6); 9 | const c6 = circle(8, 1, 3); 10 | 11 | const r1 = rect(0, 6, 2, 9); 12 | 13 | test('c2, c1: insideDistance', () => { 14 | expect(insideDistance(c2, c1)) 15 | .toBeCloseTo(2 - 0.5 - Math.SQRT1_2) 16 | }); 17 | 18 | test('c1, c2: insideDistance', () => { 19 | expect(insideDistance(c1, c2)) 20 | .toBeCloseTo(-(2 + Math.SQRT1_2 - 0.5)) 21 | }); 22 | 23 | test('c3, c1: insideDistance', () => { 24 | expect(insideDistance(c3, c1)) 25 | .toBeCloseTo(-(Math.sqrt(26) - 1)) 26 | }); 27 | 28 | test('c3, c4: insideDistance', () => { 29 | expect(insideDistance(c3, c4)) 30 | .toBeCloseTo(-1) 31 | }); 32 | 33 | test('c4, c3: insideDistance', () => { 34 | expect(insideDistance(c4, c3)) 35 | .toBeCloseTo(-(2 * Math.SQRT2 - 1)) 36 | }); 37 | 38 | test('c1, r1: insideDistance', () => { 39 | expect(insideDistance(c1, r1)) 40 | .toBeCloseTo(1) 41 | }); 42 | 43 | test('c3, r1: insideDistance', () => { 44 | expect(insideDistance(c3, r1)) 45 | .toBeCloseTo(-3) 46 | }); 47 | 48 | test('c4, r1: insideDistance', () => { 49 | expect(insideDistance(c4, r1)) 50 | .toBeCloseTo(-(1 + Math.SQRT2)) 51 | }); 52 | 53 | test('c5, r1: insideDistance', () => { 54 | expect(insideDistance(c5, r1)) 55 | .toBeCloseTo(-0.1) 56 | }); 57 | 58 | test('c6, r1: insideDistance', () => { 59 | expect(insideDistance(c6, r1)) 60 | .toBeCloseTo(-(Math.sqrt(5) + 3)) 61 | }); -------------------------------------------------------------------------------- /__tests__/x-set.js: -------------------------------------------------------------------------------- 1 | import { XSet } from '../src/x-set'; 2 | 3 | test('copy', () => { 4 | expect( 5 | (new XSet([3, 4])).copy() 6 | ).toStrictEqual(new XSet([3, 4])) 7 | }); 8 | 9 | test('adds', () => { 10 | expect( 11 | (new XSet([3, 4])).adds([5, 6]) 12 | ).toStrictEqual(new XSet([3, 4, 5, 6])); 13 | }); 14 | 15 | test('deletes', () => { 16 | expect( 17 | (new XSet([3, 4, 5, 6])).deletes([4, 6]) 18 | ).toStrictEqual(new XSet([3, 5])); 19 | }); 20 | 21 | test('filter', () => { 22 | expect( 23 | (new XSet([3, 4, 5])).filter(e => e !== 4) 24 | ).toStrictEqual(new XSet([3, 5])) 25 | }); 26 | 27 | test('filter, return array', () => { 28 | expect( 29 | (new XSet([3, 4, 5])).filter(e => e !== 4, true) 30 | ).toStrictEqual([3, 5]) 31 | }); 32 | 33 | test('find', () => { 34 | expect( 35 | (new XSet([3, 4, 5])).find(e => e > 3) 36 | ).toBe(4) 37 | }); 38 | 39 | test('every', () => { 40 | expect( 41 | (new XSet([3, 4, 5])).every(e => e > 3) 42 | ).toBe(false) 43 | }); 44 | 45 | test('some', () => { 46 | expect( 47 | (new XSet([3, 4, 5])).some(e => e > 3) 48 | ).toBe(true) 49 | }); 50 | 51 | test('first', () => { 52 | expect( 53 | (new XSet([3, 4, 5])).first() 54 | ).toBe(3) 55 | }); 56 | 57 | test('difference', () => { 58 | expect( 59 | (new XSet([3, 4, 5, 6])).difference(new XSet([5, 3, 9])) 60 | ).toStrictEqual(new XSet([4, 6])) 61 | }); 62 | 63 | test('intersection', () => { 64 | expect( 65 | (new XSet([3, 4, 5, 6])).intersection(new XSet([5, 3, 9])) 66 | ).toStrictEqual(new XSet([3, 5])) 67 | }); 68 | 69 | test('union', () => { 70 | expect( 71 | (new XSet([3, 4, 5, 6])).union(new XSet([5, 3, 9])) 72 | ).toStrictEqual(new XSet([3, 4, 5, 6, 9])) 73 | }); -------------------------------------------------------------------------------- /src/grid.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Grid class. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | import { Square } from './square.js'; 6 | import { assertPositiveInteger } from './helpers.js'; 7 | 8 | export class Grid { 9 | 10 | constructor(sim) { 11 | 12 | // validate gridStep, width and height 13 | assertPositiveInteger(sim.width, 'simulation width'); 14 | assertPositiveInteger(sim.height, 'simulation height'); 15 | assertPositiveInteger(sim.gridStep, 'simulation grid step'); 16 | assertPositiveInteger(sim.width / sim.gridStep, 17 | 'simulation width divided by the grid step'); 18 | assertPositiveInteger(sim.height / sim.gridStep, 19 | 'simulation height divided by the grid step'); 20 | 21 | // local variables and private fields 22 | this.sim = sim; 23 | const step = this.step = sim.gridStep; 24 | const nx = this.nx = sim.width / sim.gridStep; 25 | const ny = this.ny = sim.height / sim.gridStep; 26 | const squares = this.squares = []; 27 | 28 | // create square agents 29 | let i = 0; 30 | for (let yi = 0; yi < ny; yi++) { 31 | const row = []; 32 | squares.push(row); 33 | for (let xi = 0; xi < nx; xi++) { 34 | const sq = new Square({ 35 | x: (xi + 0.5) * step, 36 | y: (yi + 0.5) * step 37 | }); 38 | sq.xMin = xi * step; 39 | sq.xMax = (xi + 1) * step; 40 | sq.yMin = yi * step; 41 | sq.yMax = (yi + 1) * step; 42 | sq.index = i++; 43 | sq.xIndex = xi; 44 | sq.yIndex = yi; 45 | row.push(sq); 46 | sq._addToSimulation(sim); 47 | } 48 | } 49 | } 50 | 51 | } -------------------------------------------------------------------------------- /src/inside-distance.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Inside boundary-to-boundary distance function: smallest distance between 3 | // boundaries measured inwardly from the second argument - the 'distance' is 4 | // negative if the first argument is not within the second. 5 | // 6 | // Currently only implemented for circular first argument. 7 | //////////////////////////////////////////////////////////////////////////////// 8 | 9 | import { centroidDistance } from './centroid-distance.js'; 10 | 11 | export function insideDistance(u, v) { 12 | return distanceFunctions[u._shape][v._shape](u, v); 13 | } 14 | 15 | function dist(x1, x2, y1, y2) { 16 | return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); 17 | } 18 | 19 | const distanceFunctions = { 20 | 21 | circle: { 22 | 23 | circle(c1, c2) { 24 | return c2.radius - centroidDistance(c1, c2) - c1.radius; 25 | }, 26 | 27 | rect(c, r) { 28 | 29 | const d = Math.min( 30 | c.x - c.radius - r.xMin, 31 | r.xMax - c.x - c.radius, 32 | c.y - c.radius - r.yMin, 33 | r.yMax - c.y - c.radius, 34 | ); 35 | 36 | // if the circle centroid is outside the rectangle, may need to use 37 | // distance from corner of rectangle 38 | if (d < -c.radius) { 39 | let dCorner = 0; 40 | if (c.x < r.xMin) { 41 | if (c.y < r.yMin) dCorner = dist(c.x, r.xMin, c.y, r.yMin); 42 | else if (c.y > r.yMax) dCorner = dist(c.x, r.xMin, c.y, r.yMax); 43 | } 44 | else if (c.x > r.xMax) { 45 | if (c.y < r.yMin) dCorner = dist(c.x, r.xMax, c.y, r.yMin); 46 | else if (c.y > r.yMax) dCorner = dist(c.x, r.xMax, c.y, r.yMax); 47 | } 48 | return Math.min(d, -(dCorner + c.radius)); 49 | } 50 | 51 | return d; 52 | 53 | } 54 | 55 | } 56 | 57 | }; -------------------------------------------------------------------------------- /src/boundary-distance.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Boundary-to-boundary distance function. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | import { centroidDistance } from './centroid-distance.js'; 6 | 7 | export function boundaryDistance(u, v) { 8 | return Math.max(distanceFunctions[u._shape][v._shape](u, v), 0); 9 | } 10 | 11 | const distanceFunctions = { 12 | 13 | circle: { 14 | 15 | circle(c1, c2) { 16 | return centroidDistance(c1, c2) - c1.radius - c2.radius; 17 | }, 18 | 19 | rect(c, r) { 20 | let xDist, yDist; 21 | if (c.x < r.xMin) xDist = r.xMin - c.x; 22 | else if (c.x > r.xMax) xDist = c.x - r.xMax; 23 | else xDist = 0; 24 | if (c.y < r.yMin) yDist = r.yMin - c.y; 25 | else if (c.y > r.yMax) yDist = c.y - r.yMax; 26 | else yDist = 0; 27 | return (xDist 28 | ? (yDist ? Math.sqrt(xDist ** 2 + yDist ** 2) : xDist) 29 | : yDist 30 | ) - c.radius; 31 | } 32 | 33 | }, 34 | 35 | rect: { 36 | 37 | circle(r, c) { 38 | return distanceFunctions.circle.rect(c, r); 39 | }, 40 | 41 | rect(r1, r2) { 42 | let xDist, yDist; 43 | if (r1.xMax < r2.xMin) xDist = r2.xMin - r1.xMax; // r1 on left 44 | else if (r1.xMin > r2.xMax) xDist = r1.xMin - r2.xMax; // r1 on right 45 | else xDist = 0; // x overlap 46 | if (r1.yMax < r2.yMin) yDist = r2.yMin - r1.yMax; // r1 above 47 | else if (r1.yMin > r2.yMax) yDist = r1.yMin - r2.yMax; // r1 below 48 | else yDist = 0; // y overlap 49 | return xDist 50 | ? (yDist ? Math.sqrt(xDist ** 2 + yDist ** 2) : xDist) 51 | : yDist; 52 | } 53 | 54 | } 55 | 56 | }; 57 | 58 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | #### 0.1.11 — August 22, 2022 2 | 3 | * Add to `Simulation`: `nx`, `ny`, `randomSquare`, `randomXIndex`, `randomYIndex` and `autotile`. 4 | 5 | * Add to `Square`: `width`, `height`, `randomX` and `randomY`. 6 | 7 | * Add to `Zone`: `nx`, `ny`, `width`, `height`, `randomX`, `randomY`, `randomSquare`, `randomXIndex`, `randomYIndex` and `autotile`. 8 | 9 | * Add to `Actor`: `autotile`. 10 | 11 | * Add to `random`: `categorical`. 12 | 13 | * Add helpers: `randomElement` and `autotile`. 14 | 15 | #### 0.1.10 — July 24, 2022 16 | 17 | * Add `isClosed` and `copy` to `Polyline`. 18 | 19 | #### 0.1.9 — July 6, 2022 20 | 21 | * Add `routes` method. 22 | 23 | #### 0.1.8 — June 12, 2022 24 | 25 | * Add `direction` property to `Square` and `Zone`. 26 | 27 | * Add `z` property to `Agent`. 28 | 29 | #### 0.1.7 — June 5, 2022 30 | 31 | * Add polylines. 32 | 33 | * Add `square.checker`. 34 | 35 | #### 0.1.6 — May 22, 2022 36 | 37 | * Add vis methods. 38 | 39 | * Additional update properties: `updateActorStates`, `updateSquareStates` and `updateZoneStates`. 40 | 41 | * All update properties `true` by default. 42 | 43 | #### 0.1.5 — May 10, 2022 44 | 45 | * Fix bounce bug [#6](https://github.com/gjmcn/atomic-agents/issues/6). 46 | 47 | #### 0.1.4 — May 2, 2022 48 | 49 | * Add `zIndex` property to agents. 50 | 51 | #### 0.1.3 — April 25, 2022 52 | 53 | * Fix `insideDistance` bug [#4](https://github.com/gjmcn/atomic-agents/issues/4). 54 | 55 | * Add `insideDistance` method to `Actor`. 56 | 57 | #### 0.1.2 — April 23, 2022 58 | 59 | * Vector `add` and `sub` methods: argument can be a number or an object. 60 | 61 | #### 0.1.1 — April 21, 2022 62 | 63 | * Allow passing `updateMass`, `updateRadius` and `updatePointing` as options to the `Actor` constructor. 64 | 65 | * Remove `null` defaults for user-defined methods since blocks prototype chain. 66 | 67 | #### 0.1.0 — April 19, 2022 68 | 69 | * Initial Release. -------------------------------------------------------------------------------- /src/from-functions.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // 'From' proximity functions. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | import { overlap } from './overlap.js'; 6 | import { boundaryDistance } from './boundary-distance.js'; 7 | import { within } from './within.js'; 8 | import { centroidWithin } from './centroid-within.js'; 9 | 10 | export function neighborsFrom(target, maxDistance, candidates) { 11 | const r = []; 12 | for (let c of candidates) { 13 | if (boundaryDistance(target, c) < maxDistance) r.push(c); 14 | } 15 | return r; 16 | } 17 | 18 | export function nearestFrom(target, k, candidates) { 19 | const objects = []; 20 | for (let c of candidates) { 21 | objects.push({ 22 | candidate: c, 23 | distance: boundaryDistance(target, c) 24 | }); 25 | } 26 | objects.sort((a, b) => a.distance - b.distance); 27 | const n = Math.min(k, objects.length); 28 | const r = new Array(n); 29 | for (let j = 0; j < n; j++) { 30 | r[j] = objects[j].candidate; 31 | } 32 | return r; 33 | } 34 | 35 | export function overlappingFrom(target, candidates) { 36 | const r = []; 37 | for (let c of candidates) { 38 | if (overlap(target, c)) r.push(c); 39 | } 40 | return r; 41 | } 42 | 43 | export function withinFrom(target, candidates) { 44 | const r = []; 45 | for (let c of candidates) { 46 | if (within(target, c)) r.push(c); 47 | } 48 | return r; 49 | } 50 | 51 | export function centroidWithinFrom(target, candidates) { 52 | const r = []; 53 | for (let c of candidates) { 54 | if (centroidWithin(target, c)) r.push(c); 55 | } 56 | return r; 57 | } 58 | 59 | export function enclosingFrom(target, candidates) { 60 | const r = []; 61 | for (let c of candidates) { 62 | if (within(c, target)) r.push(c); 63 | } 64 | return r; 65 | } 66 | 67 | export function enclosingCentroidFrom(target, candidates) { 68 | const r = []; 69 | for (let c of candidates) { 70 | if (centroidWithin(c, target)) r.push(c); 71 | } 72 | return r; 73 | } -------------------------------------------------------------------------------- /src/x-set.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // XSet class. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | export class XSet extends Set { 6 | 7 | copy() { 8 | return new XSet(this); 9 | } 10 | 11 | adds(u) { 12 | for (let e of u) { 13 | this.add(e); 14 | } 15 | return this; 16 | } 17 | 18 | deletes(u) { 19 | for (let e of u) { 20 | this.delete(e); 21 | } 22 | return this; 23 | } 24 | 25 | filter(f, returnArray) { 26 | let r, methodName; 27 | if (returnArray) { 28 | r = []; 29 | methodName = 'push'; 30 | } 31 | else { 32 | r = new XSet(); 33 | methodName = 'add'; 34 | } 35 | for (let e of this) { 36 | if (f(e)) { 37 | r[methodName](e); 38 | } 39 | } 40 | return r; 41 | } 42 | 43 | find(f) { 44 | for (let e of this) { 45 | if (f(e)) { 46 | return e; 47 | } 48 | } 49 | } 50 | 51 | every(f) { 52 | for (let e of this) { 53 | if (!f(e)) { 54 | return false; 55 | } 56 | } 57 | return true; 58 | } 59 | 60 | some(f) { 61 | for (let e of this) { 62 | if (f(e)) { 63 | return true; 64 | } 65 | } 66 | return false; 67 | } 68 | 69 | first() { 70 | return this.values().next().value; 71 | } 72 | 73 | 74 | // ========== set theory methods ========== 75 | // adapted from: https://github.com/d3/d3-array 76 | 77 | difference(...others) { 78 | const values = new XSet(this); 79 | for (const other of others) { 80 | for (const value of other) { 81 | values.delete(value); 82 | } 83 | } 84 | return values; 85 | } 86 | 87 | intersection(...others) { 88 | const values = new XSet(this); 89 | others = others.map(other => other instanceof Set ? other : new Set(other)); 90 | out: for (const value of values) { 91 | for (const other of others) { 92 | if (!other.has(value)) { 93 | values.delete(value); 94 | continue out; 95 | } 96 | } 97 | } 98 | return values; 99 | } 100 | 101 | union(...others) { 102 | const values = new XSet(this); 103 | for (const other of others) { 104 | for (const o of other) { 105 | values.add(o); 106 | } 107 | } 108 | return values; 109 | } 110 | 111 | } -------------------------------------------------------------------------------- /__tests__/from-functions.js: -------------------------------------------------------------------------------- 1 | import { circle, rect } from './test-helpers'; 2 | import { 3 | neighborsFrom, 4 | nearestFrom, 5 | overlappingFrom, 6 | withinFrom, 7 | centroidWithinFrom, 8 | enclosingFrom, 9 | enclosingCentroidFrom 10 | } from '../src/from-functions'; 11 | 12 | const c1 = circle(1, 1, 1); 13 | const c2 = circle(2, 4, 0.5); 14 | const c3 = circle(3.5, 4, 1.25); 15 | 16 | const r1 = rect(4, 5, 1, 4); 17 | const r2 = rect(2, 5, 1, 2); 18 | const r3 = rect(1, 3, 4, 6); 19 | 20 | const all = [c1, c2, c3, r1, r2, r3]; 21 | function split(target) { 22 | const others = new Set(all); 23 | others.delete(target); 24 | return [target, others]; 25 | } 26 | 27 | test('neighborsFrom, 1', () => { 28 | const [target, others] = split(c3); 29 | expect(neighborsFrom(target, 1, others)) 30 | .toStrictEqual([c2, r1, r2, r3]) 31 | }); 32 | test('neighborsFrom, 2', () => { 33 | const [target, others] = split(r2) 34 | expect(neighborsFrom(target, 0.5, others)) 35 | .toStrictEqual([c1, r1]) 36 | }); 37 | 38 | test('nearestFrom, 1', () => { 39 | const [target, others] = split(c1); 40 | expect(nearestFrom(target, 3, others)) 41 | .toStrictEqual([r2, c3, c2]) 42 | }); 43 | test('nearestFrom, 2', () => { 44 | expect(nearestFrom(r3, 2, [c1, c2, r1, r2])) 45 | .toStrictEqual([c2, r1]) 46 | }); 47 | 48 | test('overlappingFrom, 1', () => { 49 | const [target, others] = split(c3); 50 | expect(overlappingFrom(target, others)) 51 | .toStrictEqual([c2, r1, r3]) 52 | }); 53 | test('overlappingFrom, 2', () => { 54 | const [target, others] = split(r1); 55 | expect(overlappingFrom(target, others)) 56 | .toStrictEqual([c3, r2]) 57 | }); 58 | 59 | test('withinFrom, 1', () => { 60 | expect(withinFrom(circle(2.9, 4.1, 0.05), all)) 61 | .toStrictEqual([c3, r3]) 62 | }); 63 | test('withinFrom, 2', () => { 64 | expect(withinFrom(rect(2.9, 2.95, 4.05, 4.1), all)) 65 | .toStrictEqual([c3, r3]) 66 | }); 67 | 68 | test('centroidWithinFrom, 1', () => { 69 | const [target, others] = split(c2); 70 | expect(centroidWithinFrom(target, others)) 71 | .toStrictEqual([r3]) 72 | }); 73 | test('centroidWithinFrom, 2', () => { 74 | expect(centroidWithinFrom(rect(0.8, 0.9, 1.1, 1.2), all)) 75 | .toStrictEqual([c1]) 76 | }); 77 | 78 | test('enclosingFrom, 1', () => { 79 | expect(enclosingFrom(circle(3.5, 1.5, 2), all)) 80 | .toStrictEqual([r2]) 81 | }); 82 | test('enclosingFrom, 2', () => { 83 | expect(enclosingFrom(rect(0, 5, 0, 2), all)) 84 | .toStrictEqual([c1, r2]) 85 | }); 86 | 87 | test('enclosingCentroidFrom, 1', () => { 88 | expect(enclosingCentroidFrom(circle(2, 5, 1.25), all)) 89 | .toStrictEqual([c2, r3]) 90 | }); 91 | test('enclosingCentroidFrom, 2', () => { 92 | expect(enclosingCentroidFrom(rect(1.5, 2.5, 3, 6), all)) 93 | .toStrictEqual([c2, r3]) 94 | }); -------------------------------------------------------------------------------- /src/simplify.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Simplify polyline. The code is from: 3 | // 4 | // https://github.com/mourner/simplify-js, (c) 2017, Vladimir Agafonkin 5 | // 6 | // but tweaked so an ES6 module - i.e. now export the simplify function directly 7 | // and have removed other export code from the end of the file. 8 | //////////////////////////////////////////////////////////////////////////////// 9 | 10 | 'use strict'; 11 | 12 | // to suit your point format, run search/replace for '.x' and '.y'; 13 | // for 3D version, see 3d branch (configurability would draw significant performance overhead) 14 | 15 | // square distance between 2 points 16 | function getSqDist(p1, p2) { 17 | 18 | var dx = p1.x - p2.x, 19 | dy = p1.y - p2.y; 20 | 21 | return dx * dx + dy * dy; 22 | } 23 | 24 | // square distance from a point to a segment 25 | function getSqSegDist(p, p1, p2) { 26 | 27 | var x = p1.x, 28 | y = p1.y, 29 | dx = p2.x - x, 30 | dy = p2.y - y; 31 | 32 | if (dx !== 0 || dy !== 0) { 33 | 34 | var t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy); 35 | 36 | if (t > 1) { 37 | x = p2.x; 38 | y = p2.y; 39 | 40 | } else if (t > 0) { 41 | x += dx * t; 42 | y += dy * t; 43 | } 44 | } 45 | 46 | dx = p.x - x; 47 | dy = p.y - y; 48 | 49 | return dx * dx + dy * dy; 50 | } 51 | // rest of the code doesn't care about point format 52 | 53 | // basic distance-based simplification 54 | function simplifyRadialDist(points, sqTolerance) { 55 | 56 | var prevPoint = points[0], 57 | newPoints = [prevPoint], 58 | point; 59 | 60 | for (var i = 1, len = points.length; i < len; i++) { 61 | point = points[i]; 62 | 63 | if (getSqDist(point, prevPoint) > sqTolerance) { 64 | newPoints.push(point); 65 | prevPoint = point; 66 | } 67 | } 68 | 69 | if (prevPoint !== point) newPoints.push(point); 70 | 71 | return newPoints; 72 | } 73 | 74 | function simplifyDPStep(points, first, last, sqTolerance, simplified) { 75 | var maxSqDist = sqTolerance, 76 | index; 77 | 78 | for (var i = first + 1; i < last; i++) { 79 | var sqDist = getSqSegDist(points[i], points[first], points[last]); 80 | 81 | if (sqDist > maxSqDist) { 82 | index = i; 83 | maxSqDist = sqDist; 84 | } 85 | } 86 | 87 | if (maxSqDist > sqTolerance) { 88 | if (index - first > 1) simplifyDPStep(points, first, index, sqTolerance, simplified); 89 | simplified.push(points[index]); 90 | if (last - index > 1) simplifyDPStep(points, index, last, sqTolerance, simplified); 91 | } 92 | } 93 | 94 | // simplification using Ramer-Douglas-Peucker algorithm 95 | function simplifyDouglasPeucker(points, sqTolerance) { 96 | var last = points.length - 1; 97 | 98 | var simplified = [points[0]]; 99 | simplifyDPStep(points, 0, last, sqTolerance, simplified); 100 | simplified.push(points[last]); 101 | 102 | return simplified; 103 | } 104 | 105 | // both algorithms combined for awesome performance 106 | export function simplify(points, tolerance, highestQuality) { 107 | 108 | if (points.length <= 2) return points; 109 | 110 | var sqTolerance = tolerance !== undefined ? tolerance * tolerance : 1; 111 | 112 | points = highestQuality ? points : simplifyRadialDist(points, sqTolerance); 113 | points = simplifyDouglasPeucker(points, sqTolerance); 114 | 115 | return points; 116 | } -------------------------------------------------------------------------------- /src/vector.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Vector class. 3 | // (inspired by p5.Vector: https://github.com/processing/p5.js) 4 | // 5 | // Where a method takes a vector argument, the 'vector' can actually be any 6 | // object with x and y properties. 7 | //////////////////////////////////////////////////////////////////////////////// 8 | 9 | import { random } from './random.js'; 10 | 11 | const directionNames = ['right', 'down', 'left', 'up']; 12 | 13 | export class Vector { 14 | 15 | constructor(x = 0, y = 0) { 16 | this.x = x; 17 | this.y = y; 18 | } 19 | 20 | static fromObject(o) { 21 | return new Vector(o.x, o.y); 22 | } 23 | 24 | static fromArray(a) { 25 | return new Vector(a[0], a[1]); 26 | } 27 | 28 | static fromPolar(m, a) { 29 | return new Vector(m * Math.cos(a), m * Math.sin(a)); 30 | } 31 | 32 | static randomAngle(m = 1) { 33 | return Vector.fromPolar(m, random.uniform_01() * 2 * Math.PI); 34 | } 35 | 36 | static randomPoint(xMin, xMax, yMin, yMax) { 37 | return new Vector( 38 | random.uniform_01() * (xMax - xMin) + xMin, 39 | random.uniform_01() * (yMax - yMin) + yMin 40 | ); 41 | } 42 | 43 | copy() { 44 | return new Vector(this.x, this.y); 45 | } 46 | 47 | set(x, y) { 48 | this.x = x; 49 | this.y = y; 50 | return this; 51 | } 52 | 53 | add(v) { 54 | if (typeof v === 'number') { 55 | this.x += v; 56 | this.y += v; 57 | } 58 | else { 59 | this.x += v.x; 60 | this.y += v.y; 61 | } 62 | return this; 63 | } 64 | 65 | sub(v) { 66 | if (typeof v === 'number') { 67 | this.x -= v; 68 | this.y -= v; 69 | } 70 | else { 71 | this.x -= v.x; 72 | this.y -= v.y; 73 | } 74 | return this; 75 | } 76 | 77 | mult(s) { 78 | this.x *= s; 79 | this.y *= s; 80 | return this; 81 | } 82 | 83 | div(s) { 84 | this.x /= s; 85 | this.y /= s; 86 | return this; 87 | } 88 | 89 | dot(v) { 90 | return this.x * v.x + this.y * v.y; 91 | } 92 | 93 | mag() { 94 | return Math.sqrt(this.dot(this)); 95 | } 96 | 97 | setMag(m) { 98 | return this.mult(m / this.mag()); 99 | } 100 | 101 | normalize() { 102 | return this.div(this.mag()); 103 | } 104 | 105 | limit(mx) { 106 | const m = this.mag(); 107 | if (m > mx) { 108 | this.mult(mx / m); 109 | } 110 | return this; 111 | } 112 | 113 | distance(v) { 114 | return Math.sqrt((this.x - v.x) ** 2 + (this.y - v.y) ** 2); 115 | } 116 | 117 | heading() { 118 | return Math.atan2(this.y, this.x); 119 | } 120 | 121 | setHeading(a) { 122 | const m = this.mag(); 123 | this.x = m * Math.cos(a); 124 | this.y = m * Math.sin(a); 125 | return this; 126 | } 127 | 128 | turn(a) { 129 | return this.setHeading(this.heading() + a); 130 | } 131 | 132 | direction() { 133 | return directionNames.at(Math.round(this.heading() / (Math.PI / 2))); 134 | } 135 | 136 | directionIndex() { 137 | let d = Math.round(this.heading() / (Math.PI / 2)); 138 | if (d === -1) d = 3; 139 | else if (d === -2) d = 2; 140 | return d; 141 | } 142 | 143 | getUnit() { 144 | return this.copy().normalize(); 145 | } 146 | 147 | getUnitNormal() { 148 | return new Vector(-this.y, this.x).normalize(); 149 | } 150 | 151 | lerp(v, s) { 152 | return new Vector( 153 | this.x + (v.x - this.x) * s, 154 | this.y + (v.y - this.y) * s 155 | ); 156 | } 157 | 158 | isZero() { 159 | return this.x === 0 && this.y === 0; 160 | } 161 | 162 | 163 | // ===== projection of this onto vector v ===== 164 | 165 | vecProjec(v) { 166 | v = new Vector(v.x, v.y); 167 | return v.mult(this.dot(v) / v.dot(v)); 168 | } 169 | 170 | scaProjec(v) { 171 | return this.dot(v) / Math.sqrt(v.x ** 2 + v.y ** 2); 172 | } 173 | 174 | vecRejec(v) { 175 | return this.vecProjec(v).sub(this).mult(-1); 176 | } 177 | 178 | scaRejec(v) { 179 | return (this.y * v.x - this.x * v.y) / Math.sqrt(v.x ** 2 + v.y ** 2); 180 | } 181 | 182 | } -------------------------------------------------------------------------------- /src/random-circles.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Generate random circles within a simulation or agent. Returns an array of 3 | // circles; each circle is an object with properties x, y and radius. Pass 4 | // options in second argument. 5 | //////////////////////////////////////////////////////////////////////////////// 6 | 7 | import { random } from './random.js'; 8 | import { 9 | assertNonnegativeInteger, assertPositiveInteger, randomPointInCircle 10 | } from './helpers.js'; 11 | import { boundaryDistance } from './boundary-distance.js'; 12 | import { insideDistance } from './inside-distance.js'; 13 | import { overlap as testOverlap} from './overlap.js'; 14 | import { within as testWithin} from './within.js'; 15 | 16 | export function randomCircles(area, { 17 | n = 1, // number of circles; not used if both nMax and nMin are 18 | // used 19 | nMax = n, // max number of circles; use Infinity to get as many 20 | // as possible - but ensure nMin is not Infinity 21 | nMin = n, // min number of circles 22 | aimForMax = true, // aim for nMax circles? If false, aims for random 23 | // integer between nMin and nMax - always aims for nMax 24 | // if it is Infinity 25 | radius = 5, // radius of circles; can be a function that returns a 26 | // radius (passed the circle's x, y and index) 27 | exclude = null, // iterable of agents that circles cannot overlap 28 | gap = 0, // min distance between circles (if overlap falsy) and 29 | // between circles and excluded agents 30 | padding = 0, // min distance between circles and edge of area 31 | overlap = false, // if true, circles can overlap 32 | retry = 5000, // max total retries after generate invalid circle 33 | } = {}) { 34 | 35 | // process and check args 36 | assertNonnegativeInteger(nMin, 'nMin'); 37 | if (nMax !== Infinity) { 38 | assertPositiveInteger(nMax, 'nMax'); 39 | if (nMin > nMax) { 40 | throw Error('nMin is greater than nMax'); 41 | } 42 | } 43 | assertNonnegativeInteger(retry, 'retry'); 44 | 45 | // useful constants 46 | const isAreaActor = area.type === 'actor'; 47 | const isRadiusFunction = typeof radius === 'function'; 48 | 49 | // target number of circles 50 | const targetCircles = 51 | nMax === Infinity || aimForMax || nMin === nMax 52 | ? nMax 53 | : random.int(nMin, nMax + 1)(); 54 | 55 | // random point 56 | const finalPadding = isRadiusFunction ? padding : padding + radius; 57 | let randomPoint; 58 | if (isAreaActor) { 59 | randomPoint = () => randomPointInCircle({ 60 | x: area.x, 61 | y: area.y, 62 | radius: area.radius - finalPadding 63 | }); 64 | } 65 | else { 66 | const randomX = random.uniform( 67 | area.xMin + finalPadding, area.xMax - finalPadding); 68 | const randomY = random.uniform( 69 | area.yMin + finalPadding, area.yMax - finalPadding); 70 | randomPoint = () => ({x: randomX(), y: randomY()}); 71 | } 72 | 73 | // validate circle 74 | function validateCircle(circle) { 75 | if (isRadiusFunction) { 76 | if (padding) { 77 | if (insideDistance(circle, area) < padding) return false; 78 | } 79 | else { 80 | if (!testWithin(circle, area)) return false; 81 | } 82 | } 83 | if (gap) { 84 | for (let agent of exclude || []) { 85 | if (boundaryDistance(circle, agent) < gap) return false; 86 | } 87 | if (!overlap) { 88 | for (let c of circles) { 89 | if (boundaryDistance(circle, c) < gap) return false; 90 | } 91 | } 92 | } 93 | else { 94 | for (let agent of exclude || []) { 95 | if (testOverlap(circle, agent)) return false; 96 | } 97 | if (!overlap) { 98 | for (let c of circles) { 99 | if (testOverlap(circle, c)) return false; 100 | } 101 | } 102 | } 103 | return true; 104 | }; 105 | 106 | // generate circles 107 | const circles = []; 108 | while (circles.length < targetCircles) { 109 | const circle = randomPoint(); 110 | circle._shape = 'circle'; // hack to make insideDistance etc. work 111 | circle.radius = isRadiusFunction 112 | ? radius(circle.x, circle.y, circles.length) 113 | : radius; 114 | if (validateCircle(circle)) { 115 | circles.push(circle); 116 | } 117 | else if (retry-- === 0) { 118 | break; 119 | } 120 | } 121 | 122 | // check have sufficient circles 123 | if (circles.length < nMin) { 124 | throw Error('number of valid circles found is less than nMin'); 125 | } 126 | for (let c of circles) { 127 | delete c._shape; 128 | } 129 | 130 | return circles; 131 | 132 | } -------------------------------------------------------------------------------- /__tests__/vector.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // At least one test for each method except randomAngle and randomPoint. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | import { Vector } from '../src/vector'; 6 | 7 | test('from-object', () => { 8 | expect(Vector.fromObject({x:3, y: 4})).toStrictEqual(new Vector(3, 4)) 9 | }); 10 | 11 | test('from-array', () => { 12 | expect(Vector.fromArray([3, 4])).toStrictEqual(new Vector(3, 4)) 13 | }); 14 | 15 | test('fromPolar-x', () => { 16 | expect(Vector.fromPolar(5, 2.214297).x).toBeCloseTo(-3) 17 | }); 18 | 19 | test('fromPolar-y', () => { 20 | expect(Vector.fromPolar(5, 2.214297).y).toBeCloseTo(4) 21 | }); 22 | 23 | test('copy', () => { 24 | expect( 25 | (new Vector(3, 4)).copy() 26 | ).toStrictEqual(new Vector(3, 4)) 27 | }); 28 | 29 | test('set', () => { 30 | expect( 31 | (new Vector(3, 4)).set(10,20) 32 | ).toStrictEqual(new Vector(10, 20)) 33 | }); 34 | 35 | test('add', () => { 36 | expect( 37 | (new Vector(3, 4)).add(new Vector(10, 20)) 38 | ).toStrictEqual(new Vector(13, 24)) 39 | }); 40 | 41 | test('add number', () => { 42 | expect( 43 | (new Vector(3, 4)).add(5) 44 | ).toStrictEqual(new Vector(8, 9)) 45 | }); 46 | 47 | test('sub', () => { 48 | expect( 49 | (new Vector(3, 4)).sub(new Vector(10, 20)) 50 | ).toStrictEqual(new Vector(-7, -16)) 51 | }); 52 | 53 | test('sub number', () => { 54 | expect( 55 | (new Vector(3, 4)).sub(5) 56 | ).toStrictEqual(new Vector(-2, -1)) 57 | }); 58 | 59 | test('mult', () => { 60 | expect( 61 | (new Vector(3, 4)).mult(5) 62 | ).toStrictEqual(new Vector(15, 20)) 63 | }); 64 | 65 | test('div', () => { 66 | expect( 67 | (new Vector(8, 6)).div(2) 68 | ).toStrictEqual(new Vector(4, 3)) 69 | }); 70 | 71 | test('dot', () => { 72 | expect( 73 | (new Vector(3, 4)).dot(new Vector(10, 20)) 74 | ).toBe(110) 75 | }); 76 | 77 | test('mag', () => { 78 | expect( 79 | (new Vector(3, 4)).mag() 80 | ).toBe(5) 81 | }); 82 | 83 | test('setMag', () => { 84 | expect( 85 | (new Vector(3, 4)).setMag(20) 86 | ).toStrictEqual(new Vector(12, 16)) 87 | }); 88 | 89 | test('normalize', () => { 90 | expect( 91 | (new Vector(3, 4)).normalize() 92 | ).toStrictEqual(new Vector(3/5, 4/5)) 93 | }); 94 | 95 | test('limit, no change', () => { 96 | expect( 97 | (new Vector(3, 4)).limit(10) 98 | ).toStrictEqual(new Vector(3, 4)) 99 | }); 100 | 101 | test('limit, change', () => { 102 | expect( 103 | (new Vector(9, 12)).limit(10) 104 | ).toStrictEqual(new Vector(6, 8)) 105 | }); 106 | 107 | test('distance', () => { 108 | expect( 109 | (new Vector(-1, 2)).distance(new Vector(2, -2)) 110 | ).toBe(5) 111 | }); 112 | 113 | test('heading', () => { 114 | expect( 115 | (new Vector(-3, 4)).heading() 116 | ).toBeCloseTo(2.214297) 117 | }); 118 | 119 | test('setHeading-x', () => { 120 | expect( 121 | (new Vector(0, 5)).setHeading(2.214297).x 122 | ).toBeCloseTo(-3) 123 | }); 124 | 125 | test('setHeading-y', () => { 126 | expect( 127 | (new Vector(0, 5)).setHeading(2.214297).y 128 | ).toBeCloseTo(4) 129 | }); 130 | 131 | test('turn-x', () => { 132 | expect( 133 | (new Vector(0, 5)).turn(Math.PI / 4).x 134 | ).toBeCloseTo(-5 / Math.SQRT2); 135 | }); 136 | 137 | test('turn-y', () => { 138 | expect( 139 | (new Vector(0, 5)).turn(Math.PI / 4).y 140 | ).toBeCloseTo(5 / Math.SQRT2); 141 | }); 142 | 143 | test('direction', () => { 144 | expect( 145 | (new Vector(1, -5)).direction() 146 | ).toBe('up'); 147 | }); 148 | 149 | test('directionIndex', () => { 150 | expect( 151 | (new Vector(1, -5)).directionIndex() 152 | ).toBe(3); 153 | }); 154 | 155 | test('getUnit', () => { 156 | expect( 157 | (new Vector(3, 4)).getUnit() 158 | ).toStrictEqual(new Vector(3/5, 4/5)) 159 | }); 160 | 161 | test('getUnitNormal', () => { 162 | expect( 163 | (new Vector(3, 4)).getUnitNormal() 164 | ).toStrictEqual(new Vector(-4/5, 3/5)) 165 | }); 166 | 167 | test('lerp', () => { 168 | expect( 169 | (new Vector(3, 4)).lerp(new Vector(11, 6), 0.5) 170 | ).toStrictEqual(new Vector(7, 5)) 171 | }); 172 | 173 | test('isZero', () => { 174 | expect( 175 | (new Vector(0, 0)).isZero() 176 | ).toBe(true) 177 | }); 178 | 179 | test('vecProjec', () => { 180 | expect( 181 | (new Vector(1, 2)).vecProjec(new Vector(3, 4)) 182 | ).toStrictEqual(new Vector(1.32, 1.76)) 183 | }); 184 | 185 | test('scaProjec', () => { 186 | expect( 187 | (new Vector(1, 2)).scaProjec(new Vector(3, 4)) 188 | ).toBe(2.2) 189 | }); 190 | 191 | test('vecRejec-x', () => { 192 | expect( 193 | (new Vector(1, 2)).vecRejec(new Vector(3, 4)).x 194 | ).toBeCloseTo(-0.32) 195 | }); 196 | 197 | test('vecRejec-y', () => { 198 | expect( 199 | (new Vector(1, 2)).vecRejec(new Vector(3, 4)).y 200 | ).toBeCloseTo(0.24) 201 | }); 202 | 203 | test('scaRejec', () => { 204 | expect( 205 | (new Vector(1, 2)).scaRejec(new Vector(3, 4)) 206 | ).toBe(0.4) 207 | }); -------------------------------------------------------------------------------- /__tests__/polyline.js: -------------------------------------------------------------------------------- 1 | import { Polyline } from '../src/polyline'; 2 | import { Vector } from '../src/vector'; 3 | 4 | const p1 = new Polyline([ 5 | {x: 1, y: 3}, 6 | {x: 2.5, y: 0}, 7 | {x: 4, y: 1}, 8 | {x: 3, y: 2}, 9 | ]); 10 | 11 | const p2 = new Polyline([ 12 | {x: 1, y: 3}, 13 | {x: 2.5, y: 0}, 14 | {x: 4, y: 1}, 15 | {x: 3, y: 2}, 16 | {x: 1, y: 3}, 17 | ]); 18 | 19 | test('constructor, 1', () => { 20 | expect(p1.pts) 21 | .toStrictEqual([ 22 | new Vector(1, 3), 23 | new Vector(2.5, 0), 24 | new Vector(4, 1), 25 | new Vector(3, 2), 26 | ]); 27 | expect([p1.xMin, p1.xMax, p1.yMin, p1.yMax, p1.x, p1.y]) 28 | .toStrictEqual([1, 4, 0, 3, 2.5, 1.5]); 29 | expect(p1.segs) 30 | .toStrictEqual([ 31 | new Vector(1.5, -3), 32 | new Vector(1.5, 1), 33 | new Vector(-1, 1), 34 | ]); 35 | expect(p1.segLengths.map(v => Number(v.toFixed(2)))) 36 | .toStrictEqual([3.35, 1.80, 1.41]); 37 | expect(p1.segLengthsCumu.map(v => Number(v.toFixed(2)))) 38 | .toStrictEqual([0.00, 3.35, 5.16, 6.57]); 39 | expect(Number(p1.lineLength.toFixed(2))).toBe(6.57); 40 | }); 41 | 42 | test('constructor, 2', () => { 43 | expect(p2.lineLength).toBeCloseTo(8.8072); 44 | expect(p2._step).toBeCloseTo(8.8072 / 5); 45 | expect(p2._intervals).toStrictEqual([0, 0, 1, 2, 3]); 46 | }); 47 | 48 | test('pointAt, 1', () => { 49 | const {x, y} = p1.pointAt(0); 50 | expect(x).toBeCloseTo(1); 51 | expect(y).toBeCloseTo(3); 52 | }); 53 | test('pointAt, 2', () => { 54 | const {x, y} = p1.pointAt(7); 55 | expect(x).toBeCloseTo(3); 56 | expect(y).toBeCloseTo(2); 57 | }) 58 | ;test('pointAt, 3', () => { 59 | const {x, y} = p1.pointAt(p1.segLengths[0]); 60 | expect(x).toBeCloseTo(2.5); 61 | expect(y).toBeCloseTo(0); 62 | }); 63 | test('pointAt, 4', () => { 64 | const {x, y} = p1.pointAt(4.25549); 65 | expect(x).toBeCloseTo(3.25); 66 | expect(y).toBeCloseTo(0.5); 67 | }); 68 | test('pointAt, 5', () => { 69 | const {x, y} = p2.pointAt(p2.lineLength + 4.25549, true); 70 | expect(x).toBeCloseTo(3.25); 71 | expect(y).toBeCloseTo(0.5); 72 | }); 73 | test('pointAt, 6', () => { 74 | const {x, y} = p2.pointAt(-p2.lineLength + 4.25549, true); 75 | expect(x).toBeCloseTo(3.25); 76 | expect(y).toBeCloseTo(0.5); 77 | }); 78 | test('pointAt, 7', () => { 79 | const {x, y} = p2.pointAt(p2.lineLength); 80 | expect(x).toBeCloseTo(1); 81 | expect(y).toBeCloseTo(3); 82 | }); 83 | 84 | test('walk, 1', () => { 85 | const pts = p1.walk(4).pts; 86 | const step = p1.lineLength / 3; 87 | const pt1 = p1.pointAt(step); 88 | const pt2 = p1.pointAt(2 * step); 89 | expect(pts.length).toBe(4); 90 | expect(pts[0].x).toBeCloseTo(1); 91 | expect(pts[0].y).toBeCloseTo(3); 92 | expect(pts[1].x).toBeCloseTo(pt1.x); 93 | expect(pts[1].y).toBeCloseTo(pt1.y); 94 | expect(pts[2].x).toBeCloseTo(pt2.x); 95 | expect(pts[2].y).toBeCloseTo(pt2.y); 96 | expect(pts[3].x).toBeCloseTo(3); 97 | expect(pts[3].y).toBeCloseTo(2); 98 | }); 99 | 100 | test('pointAtFrac, 1', () => { 101 | const {x, y} = p1.pointAtFrac(4.25549 / p1.lineLength); 102 | expect(x).toBeCloseTo(3.25); 103 | expect(y).toBeCloseTo(0.5); 104 | }); 105 | test('pointAtFrac, 2', () => { 106 | const {x, y} = 107 | p2.pointAtFrac((p2.lineLength + 4.25549) / p2.lineLength, true); 108 | expect(x).toBeCloseTo(3.25); 109 | expect(y).toBeCloseTo(0.5); 110 | }); 111 | test('pointAtFrac, 3', () => { 112 | const {x, y} = 113 | p2.pointAtFrac((-p2.lineLength + 4.25549) / p2.lineLength, true); 114 | expect(x).toBeCloseTo(3.25); 115 | expect(y).toBeCloseTo(0.5); 116 | }); 117 | 118 | test('pointNearest, 1', () => { 119 | const {point, param, scaProjec, dist} = p1.pointNearest({x: 0.5, y: 5.5}); 120 | const {x, y} = point; 121 | expect(x).toBeCloseTo(1); 122 | expect(y).toBeCloseTo(3); 123 | expect(param).toBe(0); 124 | expect(scaProjec).toBeCloseTo(-2.4597); 125 | expect(dist).toBeCloseTo(2.5495); 126 | }); 127 | test('pointNearest, 2', () => { 128 | const {point, param, scaProjec, dist} = p1.pointNearest({x: 2.8, y: 2}); 129 | const {x, y} = point; 130 | expect(x).toBeCloseTo(3); 131 | expect(y).toBeCloseTo(2); 132 | expect(param).toBeCloseTo(p1.lineLength); 133 | expect(scaProjec).toBeCloseTo(1.5556); 134 | expect(dist).toBeCloseTo(0.2); 135 | }); 136 | test('pointNearest, 3', () => { 137 | const {point, param, dist} = p1.pointNearest({x: 4.5, y: 0.5}); 138 | const {x, y} = point; 139 | expect(x).toBeCloseTo(4); 140 | expect(y).toBeCloseTo(1); 141 | expect(param).toBeCloseTo(p1.segLengthsCumu[2]); 142 | expect(dist).toBeCloseTo(0.7071); 143 | }); 144 | test('pointNearest, 4', () => { 145 | const {point, param, segIndex, dist} = p1.pointNearest({x: 4, y: 2}); 146 | const {x, y} = point; 147 | expect(x).toBeCloseTo(3.5); 148 | expect(y).toBeCloseTo(1.5); 149 | expect(param).toBeCloseTo(p1.segLengthsCumu[2] + 0.5 * p1.segLengths.at(-1)); 150 | expect(segIndex).toBe(2); 151 | expect(dist).toBeCloseTo(0.7071); 152 | }); 153 | 154 | test('isClosed, 1', () => { 155 | expect(p1.isClosed).toBe(false); 156 | }); 157 | test('isClosed, 2', () => { 158 | expect(p2.isClosed).toBe(true); 159 | }); 160 | 161 | test('copy, 1', () => { 162 | expect(p1.copy()).toStrictEqual(p1); 163 | }); 164 | test('copy, 2', () => { 165 | expect(p1.copy(true)).toStrictEqual(p2); 166 | }); -------------------------------------------------------------------------------- /__tests__/helpers.js: -------------------------------------------------------------------------------- 1 | import { 2 | shuffle, 3 | loop, 4 | bound, 5 | isPositiveInteger, 6 | isNonnegativeInteger, 7 | assertInteger, 8 | assertPositiveInteger, 9 | assertNonnegativeInteger, 10 | roughlyEqual, 11 | assertAgentType, 12 | isIterable, 13 | randomElement, 14 | randomPointInCircle, 15 | getIndexLimits, 16 | getOverlapping, 17 | moduloShift, 18 | normalizeAngle, 19 | getLayer, 20 | frame, 21 | gridInRect, 22 | gridInHex, 23 | partitionRect 24 | } from '../src/helpers'; 25 | 26 | import { random } from '../src/random'; 27 | import { Simulation } from '../src/simulation'; 28 | import { Actor } from '../src/actor'; 29 | 30 | test('shuffle, 1', () => { 31 | random.seed(0.5); 32 | expect(shuffle([51, 52, 53, 54, 55, 56, 57, 58, 59])) 33 | .toStrictEqual([56, 54, 58, 51, 55, 52, 53, 59, 57]) 34 | }); 35 | 36 | test('loop, 1', () => { 37 | const a = []; 38 | loop(3, i => a.push(10 * i)); 39 | expect(a).toStrictEqual([0, 10, 20]); 40 | }); 41 | 42 | test('bound, 1', () => { 43 | expect(bound(5, 3, 7)).toBe(5); 44 | }); 45 | test('bound, 2', () => { 46 | expect(bound(1, 3, 7)).toBe(3); 47 | }); 48 | test('bound, 3', () => { 49 | expect(bound(10, 3, 7)).toBe(7); 50 | }); 51 | 52 | test('isPositiveInteger, 1', () => { 53 | expect(isPositiveInteger(3)).toBe(true); 54 | }); 55 | test('isPositiveInteger, 2', () => { 56 | expect(isPositiveInteger('3')).toBe(false); 57 | }); 58 | test('isPositiveInteger, 3', () => { 59 | expect(isPositiveInteger('-3')).toBe(false); 60 | }); 61 | test('isPositiveInteger, 4', () => { 62 | expect(isPositiveInteger('3.1')).toBe(false); 63 | }); 64 | 65 | test('isNonnegativeInteger, 1', () => { 66 | expect(isNonnegativeInteger(0)).toBe(true); 67 | }); 68 | test('isNonnegativeInteger, 2', () => { 69 | expect(isNonnegativeInteger(3)).toBe(true); 70 | }); 71 | test('isNonnegativeInteger, 3', () => { 72 | expect(isNonnegativeInteger('0')).toBe(false); 73 | }); 74 | test('isNonnegativeInteger, 4', () => { 75 | expect(isNonnegativeInteger(-3)).toBe(false); 76 | }); 77 | test('isNonnegativeInteger, 5', () => { 78 | expect(isNonnegativeInteger(3.1)).toBe(false); 79 | }); 80 | 81 | test('assertInteger, 1', () => { 82 | expect(assertInteger(3, 'theValue')).toBe(undefined); 83 | }); 84 | test('assertInteger, 2', () => { 85 | expect(() => assertInteger(3.1, 'theValue')) 86 | .toThrow('theValue must be an integer') 87 | }); 88 | 89 | test('assertPositiveInteger, 1', () => { 90 | expect(assertPositiveInteger(3, 'theValue')).toBe(undefined); 91 | }); 92 | test('assertPositiveInteger, 2', () => { 93 | expect(() => assertPositiveInteger(3.1, 'theValue')) 94 | .toThrow('theValue must be a positive integer') 95 | }); 96 | 97 | test('assertNonnegativeInteger, 1', () => { 98 | expect(assertNonnegativeInteger(3, 'theValue')).toBe(undefined); 99 | }); 100 | test('assertNonnegativeInteger, 2', () => { 101 | expect(() => assertNonnegativeInteger(3.1, 'theValue')) 102 | .toThrow('theValue must be a non-negative integer') 103 | }); 104 | 105 | test('roughlyEqual, 1', () => { 106 | expect(roughlyEqual(5, 5.0001)).toBe(false); 107 | }); 108 | test('roughlyEqual, 2', () => { 109 | expect(roughlyEqual(5, 5 + 1e-12)).toBe(true); 110 | }); 111 | 112 | test('assertAgentType, 1', () => { 113 | expect(assertAgentType('actor')).toBe(undefined); 114 | }); 115 | test('assertAgentType, 2', () => { 116 | expect(() => assertAgentType('Actor')).toThrow('invalid agent type'); 117 | }); 118 | 119 | test('isIterable, 1', () => { 120 | expect(isIterable([4, 5])).toBe(true); 121 | }); 122 | test('isIterable, 2', () => { 123 | expect(isIterable({u: 4, v: 5})).toBe(false); 124 | }); 125 | 126 | test('randomElement, 1', () => { 127 | random.seed(0.5); 128 | const a = []; 129 | for (let i = 0; i < 100; i++) a[i] = i * 10; 130 | expect(randomElement(a)).toBe(730); 131 | }); 132 | 133 | test('randomPointInCircle, 1', () => { 134 | random.seed(0.5); 135 | const p = randomPointInCircle(1, -2, 3); 136 | const v = [ p.x.toFixed(3), p.y.toFixed(3) ]; 137 | expect(v).toStrictEqual(['0.153', '-0.844']); 138 | }); 139 | 140 | test('getIndexLimits, 1', () => { 141 | expect(getIndexLimits([2, 5, 1, 7])) 142 | .toStrictEqual([2, 5, 1, 7]); 143 | }); 144 | 145 | test('getIndexLimits, 2', () => { 146 | expect(getIndexLimits({ 147 | yMinIndex: 1, yMaxIndex: 7, xMinIndex: 2, xMaxIndex: 5 148 | })).toStrictEqual([2, 5, 1, 7]); 149 | }); 150 | 151 | test('getOverlapping, 1', () => { 152 | const sim = new Simulation({width: 100, height: 100, gridStep: 10}); 153 | const a = [ 154 | new Actor({x: 80, y: 10, radius: 3, state: { index: 0 }}).addTo(sim), 155 | new Actor({x: 60, y: 70, radius: 10, state: { index: 1 }}).addTo(sim), 156 | new Actor({x: 15, y: 35, radius: 20, state: { index: 2 }}).addTo(sim), 157 | new Actor({x: 0, y: 0, radius: 12, state: { index: 3 }}).addTo(sim) 158 | ]; 159 | const squaresRow1 = sim.squares.filter(sq => sq.yIndex === 1); 160 | const indices = [...getOverlapping(squaresRow1, 'actor', a[2])] 161 | .map(sq => sq.state.index) 162 | .sort((a, b) => a - b); 163 | expect(indices).toStrictEqual([0, 3]); 164 | }); 165 | 166 | test('moduloShift, 1', () => { 167 | expect(moduloShift(13, 5)).toBe(3); 168 | }); 169 | test('moduloShift, 2', () => { 170 | expect(moduloShift(-2, 5)).toBe(3); 171 | }); 172 | test('moduloShift, 3', () => { 173 | expect(moduloShift(3, 5)).toBe(3); 174 | }); 175 | test('moduloShift, 4', () => { 176 | expect(moduloShift(5, 5)).toBe(0); 177 | }); 178 | 179 | test('normalizeAngle, 1', () => { 180 | expect(normalizeAngle(7)).toBeCloseTo(7 - 2 * Math.PI); 181 | }); 182 | test('normalizeAngle, 2', () => { 183 | expect(normalizeAngle(-2)).toBeCloseTo(-2 + 2 * Math.PI); 184 | }); 185 | test('normalizeAngle, 3', () => { 186 | expect(normalizeAngle(15)).toBeCloseTo(15 - 4 * Math.PI); 187 | }); 188 | test('normalizeAngle, 4', () => { 189 | expect(normalizeAngle(2)).toBeCloseTo(2); 190 | }); 191 | 192 | 193 | 194 | // !! NOT FINISHED !! -------------------------------------------------------------------------------- /src/agent.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Agent class. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | import { XSet } from './x-set.js'; 6 | import { overlap } from './overlap.js'; 7 | import { boundaryDistance } from './boundary-distance.js'; 8 | import { centroidDistance } from './centroid-distance.js'; 9 | import { within } from './within.js'; 10 | import { centroidWithin } from './centroid-within.js'; 11 | import * as fromFunctions from './from-functions.js'; 12 | import { gridInRect, gridInHex } from './helpers.js'; 13 | import { randomCircles } from './random-circles.js'; 14 | 15 | export class Agent { 16 | 17 | static visOptions = new Set([ 18 | 'tint', 19 | 'alpha', 20 | 'image', 21 | 'text', 22 | 'textAlign', 23 | 'textTint', 24 | 'textAlpha', 25 | 'fontName', 26 | 'fontSize', 27 | 'advanced', 28 | 'lineColor', 29 | 'lineAlpha', 30 | 'lineWidth', 31 | 'lineAlign', 32 | 'fillColor', 33 | 'fillAlpha', 34 | ]); 35 | 36 | static updatableVisOptions = new Set([ 37 | 'tint', 38 | 'alpha', 39 | 'image', 40 | 'text', 41 | 'textTint', 42 | 'textAlpha', 43 | 'fontName', 44 | 'fontSize', 45 | ]); 46 | 47 | static vis3dOptions = new Set([ 48 | 'color', 49 | 'alpha', 50 | ]); 51 | 52 | static updatableVis3dOptions = new Set([ 53 | 'color' 54 | ]); 55 | 56 | constructor(options = {}) { 57 | this.x = options.x ?? 0; 58 | this.y = options.y ?? 0; 59 | this.z = options.z ?? null; 60 | this.state = options.state ?? {}; 61 | this.history = options.history ?? {}; 62 | if (options.updateState) this.updateState = options.updateState; 63 | this.bounceLog = new XSet(); 64 | this._simulation = null; 65 | this._labels = new Map(); 66 | this._resetOnRemove = null; 67 | this.__agent = true; 68 | // also: _visXXX and _interactionXXX properties set by vis and vis3d methods 69 | // of agent subtypes 70 | } 71 | 72 | _validateSimulation(simulation) { 73 | if (!simulation.__simulation) { 74 | throw Error('simulation object expected'); 75 | } 76 | if (this._simulation) { 77 | throw Error('agent is already in a simulation'); 78 | } 79 | } 80 | 81 | _addToSimulation(simulation) { 82 | simulation._addAgent(this); 83 | this._simulation = simulation; 84 | } 85 | 86 | addTo(simulation) { 87 | this._validateSimulation(simulation); 88 | this._addToSimulation(simulation); 89 | return this; 90 | } 91 | 92 | remove() { 93 | if (!this._simulation) { 94 | throw Error('agent is not in a simulation'); 95 | } 96 | const squaresProp = this.type + 's'; 97 | for (let sq of this.squares) { 98 | sq[squaresProp].delete(this); 99 | } 100 | this._simulation._removeAgent(this); 101 | this._simulation = null; 102 | this.bounceLog.clear(); 103 | for (let name of this._resetOnRemove) { 104 | this[name] = null; 105 | } 106 | return this; 107 | } 108 | 109 | label(name, value) { 110 | const oldValue = this._labels.get(name); 111 | if (value === undefined) { 112 | return oldValue; 113 | } 114 | else { 115 | if (value === null) { 116 | if (oldValue !== undefined) { 117 | this._simulation?._agentLabelDelete?.(this, name, oldValue); 118 | this._labels.delete(name); 119 | } 120 | } 121 | else if (oldValue === undefined) { 122 | this._simulation?._agentLabelNew?.(this, name, value) 123 | this._labels.set(name, value); 124 | } 125 | else if (value !== oldValue) { 126 | this._simulation?._agentLabelChange?.(this, name, oldValue, value); 127 | this._labels.set(name, value); 128 | } 129 | return this; 130 | } 131 | } 132 | 133 | _assertSimulation() { 134 | if (!this._simulation) { 135 | throw Error( 136 | 'this method can only be used when the calling agent is in a simulation' 137 | ); 138 | } 139 | } 140 | 141 | fitGrid(options) { 142 | this._assertSimulation(); 143 | return (this.type === 'actor' ? gridInHex : gridInRect)(this, options); 144 | } 145 | 146 | randomCircles(options) { 147 | this._assertSimulation(); 148 | return randomCircles(this, options); 149 | } 150 | 151 | populate(options) { 152 | this._assertSimulation(); 153 | return this._simulation._populate(this, options); 154 | } 155 | 156 | 157 | // ========== proximity methods ========== 158 | 159 | isOverlapping(otherAgent) { 160 | return overlap(this, otherAgent); 161 | } 162 | 163 | isWithin(otherAgent) { 164 | return within(this, otherAgent); 165 | } 166 | 167 | isCentroidWithin(otherAgent) { 168 | return centroidWithin(this, otherAgent); 169 | } 170 | 171 | isEnclosing(otherAgent) { 172 | return within(otherAgent, this); 173 | } 174 | 175 | isEnclosingCentroid(otherAgent) { 176 | return centroidWithin(otherAgent, this); 177 | } 178 | 179 | distance(otherAgent) { 180 | return boundaryDistance(this, otherAgent); 181 | } 182 | 183 | centroidDistance(otherAgent) { 184 | return centroidDistance(this, otherAgent); 185 | } 186 | 187 | neighborsFrom(maxDistance, candidates) { 188 | return fromFunctions.neighborsFrom(this, maxDistance, candidates); 189 | } 190 | 191 | overlappingFrom(candidates) { 192 | return fromFunctions.overlappingFrom(this, candidates); 193 | } 194 | 195 | withinFrom(candidates) { 196 | return fromFunctions.withinFrom(this, candidates); 197 | } 198 | 199 | centroidWithinFrom(candidates) { 200 | return fromFunctions.centroidWithinFrom(this, candidates); 201 | } 202 | 203 | enclosingFrom(candidates) { 204 | return fromFunctions.enclosingFrom(this, candidates); 205 | } 206 | 207 | enclosingCentroidFrom(candidates) { 208 | return fromFunctions.enclosingCentroidFrom(this, candidates); 209 | } 210 | 211 | } -------------------------------------------------------------------------------- /src/square.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Square class. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | import { XSet } from './x-set.js'; 6 | import { random } from './random.js'; 7 | import { 8 | assertAgentType, assertInteger, getLayer, setVisOptions, setVis3dOptions 9 | } from './helpers.js'; 10 | import { Agent } from "./agent.js"; 11 | import { insideDistance } from "./inside-distance.js"; 12 | 13 | export class Square extends Agent { 14 | 15 | static visOptions = new Set([...Agent.visOptions, 16 | 'textPosition', 17 | 'textPadding', 18 | ]); 19 | 20 | static updatableVisOptions = Agent.updatableVisOptions; 21 | 22 | static vis3dOptions = new Set([...Agent.vis3dOptions, 23 | 'tileColor', 24 | 'tileTexture', 25 | 'xScale', 26 | 'yScale', 27 | 'zScale', 28 | 'faceColors', 29 | 'mesh', 30 | ]); 31 | 32 | static updatableVis3dOptions = new Set([...Agent.updatableVis3dOptions, 33 | 'tileColor', 34 | 'tileTexture', 35 | 'xScale', 36 | 'yScale', 37 | 'zScale', 38 | 'mesh', 39 | ]); 40 | 41 | constructor(options) { 42 | super(options); 43 | this.type = 'square'; 44 | this._shape = 'rect'; 45 | this.zIndex = NaN; 46 | this.direction = 0; 47 | this.actors = new XSet(); 48 | this.zones = new XSet(); 49 | } 50 | 51 | get width() { 52 | return this._simulation.gridStep; 53 | } 54 | 55 | get height() { 56 | return this._simulation.gridStep; 57 | } 58 | 59 | get checker() { 60 | return this.xIndex % 2 ^ this.yIndex % 2; 61 | } 62 | 63 | vis(obj = {}) { 64 | return setVisOptions(this, Square, obj); 65 | } 66 | 67 | vis3d(obj = {}) { 68 | return setVis3dOptions(this, Square, obj); 69 | } 70 | 71 | remove() { 72 | throw Error('square agents cannot be removed from a simulation'); 73 | } 74 | 75 | 76 | // ========== proximity methods ========== 77 | 78 | north() { return this._simulation._grid.squares[this.yIndex - 1]?.[this.xIndex] } 79 | northeast() { return this._simulation._grid.squares[this.yIndex - 1]?.[this.xIndex + 1] } 80 | east() { return this._simulation._grid.squares[this.yIndex] [this.xIndex + 1] } 81 | southeast() { return this._simulation._grid.squares[this.yIndex + 1]?.[this.xIndex + 1] } 82 | south() { return this._simulation._grid.squares[this.yIndex + 1]?.[this.xIndex] } 83 | southwest() { return this._simulation._grid.squares[this.yIndex + 1]?.[this.xIndex - 1] } 84 | west() { return this._simulation._grid.squares[this.yIndex] [this.xIndex - 1] } 85 | northwest() { return this._simulation._grid.squares[this.yIndex - 1]?.[this.xIndex - 1] } 86 | 87 | randomX(padding = 0) { 88 | return random.uniform_01() * (this.width - 2 * padding) + 89 | padding + this.xMin; 90 | } 91 | 92 | randomY(padding = 0) { 93 | return random.uniform_01() * (this.height - 2 * padding) + 94 | padding + this.yMin; 95 | } 96 | 97 | compass() { return { 98 | north: this.north(), 99 | northeast: this.northeast(), 100 | east: this.east(), 101 | southeast: this.southeast(), 102 | south: this.south(), 103 | southwest: this.southwest(), 104 | west: this.west(), 105 | northwest: this.northwest() 106 | }} 107 | 108 | compassMain() { return { 109 | north: this.north(), 110 | east: this.east(), 111 | south: this.south(), 112 | west: this.west() 113 | }} 114 | 115 | compassCorners() { return { 116 | northeast: this.northeast(), 117 | southeast: this.southeast(), 118 | southwest: this.southwest(), 119 | northwest: this.northwest() 120 | }} 121 | 122 | layer(level = 1) { 123 | assertInteger(level); 124 | if (level) { 125 | return getLayer(this._simulation, level, { 126 | xiMin: this.xIndex, 127 | xiMax: this.xIndex, 128 | yiMin: this.yIndex, 129 | yiMax: this.yIndex 130 | }); 131 | } 132 | else { 133 | return [this]; 134 | } 135 | } 136 | 137 | layerMain(level = 1) { 138 | assertInteger(level); 139 | if (level > 0) { 140 | const s = []; 141 | const gridSquares = this._simulation._grid.squares; 142 | const { xIndex, yIndex } = this; 143 | let sq; 144 | if (sq = gridSquares[yIndex - level]?.[xIndex]) s.push(sq); 145 | if (sq = gridSquares[yIndex][xIndex + level]) s.push(sq); 146 | if (sq = gridSquares[yIndex + level]?.[xIndex]) s.push(sq); 147 | if (sq = gridSquares[yIndex][xIndex - level]) s.push(sq); 148 | return s; 149 | } 150 | else if (level === 0 || level === -1) { 151 | return [this]; 152 | } 153 | return []; 154 | } 155 | 156 | _insideNeighbors(maxDistance) { // actor neighbors, no type parameter 157 | return this.actors.filter(a => { 158 | const d = insideDistance(a, this); 159 | return d >= -a.radius && d <= maxDistance; 160 | }, 'array'); 161 | } 162 | 163 | _overlappingBoundaryCandidateSet() { // actors only, no type parameter 164 | return new XSet(this.actors); 165 | } 166 | 167 | neighbors(maxDistance, type) { 168 | assertAgentType(type); 169 | if (!(maxDistance > 0)) { 170 | return []; 171 | } 172 | let r; 173 | const { gridStep } = this._simulation; 174 | if (type === 'square') { 175 | r = this.layer(1); 176 | let i = 1; 177 | while (maxDistance > gridStep * i) { 178 | const candidates = this.layer(i + 1); 179 | if (candidates.length === 0) break; 180 | if (maxDistance > Math.sqrt(2) * gridStep * i) { 181 | r.push(...candidates); 182 | } 183 | else { 184 | for (let c of candidates) { 185 | if (c.xIndex === this.xIndex || 186 | c.yIndex === this.yIndex || 187 | this.distance(c) < maxDistance) { 188 | r.push(c); 189 | } 190 | } 191 | } 192 | i++; 193 | } 194 | } 195 | else if (type === 'zone') { 196 | r = this.zones.copy(); 197 | for (let sq of this.neighbors(maxDistance, 'square')) { 198 | r.adds(sq.zones); 199 | } 200 | r = [...r]; 201 | } 202 | else { // actor 203 | r = this.actors.copy(); 204 | for (let sq of this.neighbors(maxDistance, 'square')) { 205 | r.adds(sq.actors); 206 | } 207 | for (let a of r) { 208 | if (this.distance(a) >= maxDistance) r.delete(a); 209 | } 210 | r = [...r]; 211 | } 212 | return r; 213 | } 214 | 215 | overlapping(type) { 216 | assertAgentType(type); 217 | return type === 'square' ? [] : [...this[type + 's']]; 218 | } 219 | 220 | _within(type, testName) { 221 | assertAgentType(type); 222 | if (type === 'zone') { 223 | return [...this.zones]; 224 | } 225 | else if (type === 'actor') { 226 | return this.actors.filter(a => this[testName](a), 'array'); 227 | } 228 | return []; 229 | } 230 | 231 | within(type) { 232 | return this._within(type, 'isWithin'); 233 | } 234 | 235 | centroidWithin(type) { 236 | return this._within(type, 'isCentroidWithin'); 237 | } 238 | 239 | enclosing(type) { 240 | assertAgentType(type); 241 | if (type === 'zone') { 242 | return this.zones.filter(z => z.squares.size === 1, 'array'); 243 | } 244 | else if (type === 'actor') { 245 | return this.actors.filter(a => this.isEnclosing(a), 'array'); 246 | } 247 | return []; 248 | } 249 | 250 | enclosingCentroid(type) { 251 | assertAgentType(type); 252 | return type === 'square' 253 | ? [] 254 | : this[type + 's'].filter(a => this.isEnclosingCentroid(a), 'array'); 255 | } 256 | 257 | } -------------------------------------------------------------------------------- /src/polyline.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Polyline class. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | import { Vector } from './vector.js'; 6 | import { simplify } from './simplify.js'; 7 | import { moduloShift } from './helpers.js'; 8 | 9 | export class Polyline { 10 | 11 | // points is an array; each element is an array, or an object with x and y 12 | // properties 13 | constructor(points) { 14 | 15 | // public properties 16 | if (points.length < 2) throw Error('at least two points expected'); 17 | this.xMin = Infinity; 18 | this.xMax = -Infinity; 19 | this.yMin = Infinity; 20 | this.yMax = -Infinity; 21 | this.pts = []; 22 | for (let pt of points) { 23 | pt = Vector[Array.isArray(pt) ? 'fromArray' : 'fromObject'](pt); 24 | this.pts.push(pt); 25 | if (pt.x < this.xMin) this.xMin = pt.x; 26 | if (pt.x > this.xMax) this.xMax = pt.x; 27 | if (pt.y < this.yMin) this.yMin = pt.y; 28 | if (pt.y > this.yMax) this.yMax = pt.y; 29 | } 30 | this.x = (this.xMax + this.xMin) / 2; 31 | this.y = (this.yMax + this.yMin) / 2; 32 | this.segs = []; 33 | this.segLengths = []; 34 | this.segLengthsCumu = [0]; 35 | for (let i = 0; i < this.pts.length - 1; i++) { 36 | const seg = this.pts[i + 1].copy().sub(this.pts[i]); 37 | this.segs.push(seg); 38 | const m = seg.mag(); 39 | this.segLengths.push(m); 40 | this.segLengthsCumu.push(this.segLengthsCumu.at(-1) + m); 41 | } 42 | this.lineLength = this.segLengthsCumu.at(-1); 43 | 44 | // if line has more than 2 segments, this._intervals contains the segment 45 | // index at equal intervals of this_step along the line; the last entry of 46 | // this._intervals corresponds to the point one step before the line's end 47 | if (this.segs.length > 2) { 48 | const n = Math.min(this.segs.length + 1); 49 | this._step = this.lineLength / n; 50 | this._intervals = []; 51 | let segIndex = 0; 52 | for (let i = 0; i < n; i++) { 53 | const t = i * this._step; 54 | while (t >= this.segLengthsCumu[segIndex + 1]) segIndex++; 55 | this._intervals.push(segIndex); 56 | } 57 | } 58 | 59 | } 60 | 61 | // returns true if distance between first point and last point is less than 62 | // 1/1000 the length of the polyline 63 | get isClosed() { 64 | return this.pts[0].distance(this.pts.at(-1)) < this.lineLength / 1000; 65 | } 66 | 67 | // copy polyline: if close truthy and calling polyline not closed, adds end 68 | // point identical to first point 69 | copy(close) { 70 | return new Polyline(close && !this.isClosed 71 | ? [...this.pts, this.pts[0]] 72 | : this.pts 73 | ); 74 | } 75 | 76 | // transform: scale and rotate about firt point, then translate 77 | transform({ scale = 1, translate = [0, 0], rotate = 0 }) { 78 | if (scale === 1 && rotate === 0) { 79 | return new Polyline(this.pts.map(pt => { 80 | return [pt.x + translate[0], pt.y + translate[1]]; 81 | })); 82 | } 83 | const base = this.pts[0]; 84 | const newBase = base.copy().add(Vector.fromArray(translate)); 85 | const newPoints = [newBase]; 86 | for (let i = 1; i < this.pts.length; i++) { 87 | const pt = this.pts[i].copy().sub(base); 88 | if (!pt.isZero()) { 89 | if (rotate) pt.turn(rotate); 90 | if (scale !== 1) pt.mult(scale); 91 | } 92 | newPoints.push(pt.add(newBase)); 93 | }; 94 | return new Polyline(newPoints); 95 | } 96 | 97 | // simplify 98 | simplify(tolerance, highQuality) { 99 | return new Polyline(simplify(this.pts, tolerance, highQuality)); 100 | } 101 | 102 | // get point on line at curve parameter t; returns a vector 103 | pointAt(t, wrap) { 104 | if (wrap) t = moduloShift(t, this.lineLength); 105 | else if (t <= 0) return this.pts[0].copy(); 106 | else if (t >= this.lineLength) return this.pts.at(-1).copy(); 107 | let segIndex = this._intervals 108 | ? this._intervals[Math.floor(t / this._step)] 109 | : 0; 110 | while (t > this.segLengthsCumu[segIndex + 1]) segIndex++; 111 | return this.pts[segIndex].lerp( 112 | this.pts[segIndex + 1], 113 | (t - this.segLengthsCumu[segIndex]) / this.segLengths[segIndex] 114 | ); 115 | } 116 | 117 | // as pointAt, but based on curve parameter that runs from 0 to 1 118 | pointAtFrac(t, wrap) { 119 | return this.pointAt(t * this.lineLength, wrap); 120 | } 121 | 122 | // sample at n equally spaced points along the polyline; returns a polyline 123 | walk(n) { 124 | if (n < 2) throw Error('n cannot be less than 2'); 125 | const first = this.pts[0]; 126 | const last = this.pts.at(-1); 127 | if (n === 2) return new Polyline([first, last]); 128 | let pts = [first]; 129 | const step = this.lineLength / (n - 1); 130 | let segIndex = 0; 131 | for (let i = 1; i < n - 1; i++) { 132 | const t = i * step; 133 | while (t > this.segLengthsCumu[segIndex + 1]) segIndex++; 134 | pts.push(this.pts[segIndex].lerp( 135 | this.pts[segIndex + 1], 136 | (t - this.segLengthsCumu[segIndex]) / this.segLengths[segIndex] 137 | )); 138 | } 139 | pts.push(last); 140 | return new Polyline(pts); 141 | } 142 | 143 | // point on segment nearest to p (an object with x and y properties); returns 144 | // an object with properties: 145 | // - point: vector, nearest point on segment 146 | // - param: number, value of curve parameter corresponding to nearest point 147 | // - scaProjec: number, scalar projection of p onto segment 148 | // - dist: number, distance from p to nearest point 149 | _pointNearestOnSeg(p, segIndex) { 150 | const seg = this.segs[segIndex]; 151 | const pShift = Vector.fromObject(p).sub(this.pts[segIndex]); 152 | let scaProjec = pShift.scaProjec(seg); 153 | let param; 154 | let proj; 155 | if (scaProjec <= 0) { 156 | proj = this.pts[segIndex].copy(); 157 | param = this.segLengthsCumu[segIndex]; 158 | } 159 | else if (scaProjec >= this.segLengths[segIndex]) { 160 | proj = this.pts[segIndex + 1].copy(); 161 | param = this.segLengthsCumu[segIndex + 1]; 162 | } 163 | else { 164 | proj = pShift.vecProjec(seg).add(this.pts[segIndex]); 165 | param = this.segLengthsCumu[segIndex] + scaProjec; 166 | } 167 | const dist = Math.hypot(proj.x - p.x, proj.y - p.y); 168 | return {point: proj, param, scaProjec, dist}; 169 | } 170 | 171 | // as _pointNearestOnSeg, but only returns distance to nearest point on seg 172 | _distanceFromSeg(p, segIndex) { 173 | const seg = this.segs[segIndex]; 174 | const pShift = Vector.fromObject(p).sub(this.pts[segIndex]); 175 | let scaProjec = pShift.scaProjec(seg); 176 | if (scaProjec <= 0) { 177 | const pt = this.pts[segIndex]; 178 | return Math.hypot(p.x - pt.x, p.y - pt.y); 179 | } 180 | if (scaProjec >= this.segLengths[segIndex]) { 181 | const pt = this.pts[segIndex + 1]; 182 | return Math.hypot(p.x - pt.x, p.y - pt.y); 183 | } 184 | return Math.abs(pShift.scaRejec(seg)); 185 | } 186 | 187 | // point on polyline (or only from segments listed in segIndices) nearest to p 188 | // (an object with x and y properties); returns an object with properties: 189 | // - point: vector, nearest point on polyline 190 | // - param: number, value of curve parameter corresponding to nearest point 191 | // - segIndex: number, index of segment of nearest point 192 | // - scaProjec: number, scalar projection of p onto segment of nearest point 193 | // - dist: number, distance from p to nearest point 194 | pointNearest(p, segIndices) { 195 | let minDist = Infinity; 196 | let nearest; 197 | let segIndex; 198 | for (let i of segIndices ?? this.segs.keys()) { 199 | const obj = this._pointNearestOnSeg(p, i); 200 | if (obj.dist < minDist) { 201 | minDist = obj.dist; 202 | nearest = obj; 203 | segIndex = i; 204 | } 205 | } 206 | if (nearest) nearest.segIndex = segIndex; 207 | return nearest; 208 | } 209 | 210 | } -------------------------------------------------------------------------------- /src/regions.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Generate connected regions. area is a simulation, zone or actor; returns 3 | // an array of regions - each region is an xset of squares. Pass options in 4 | // second argument. 5 | //////////////////////////////////////////////////////////////////////////////// 6 | 7 | import { XSet } from './x-set.js'; 8 | import { random } from './random.js'; 9 | import { 10 | assertNonnegativeInteger, assertPositiveInteger, randomElement, shuffle 11 | } from './helpers.js'; 12 | 13 | export function regions(area, { 14 | maxRegions = 1, // max number of regions; use Infinity to get as 15 | // many as possible - but ensure minRegions is not 16 | // Infinity 17 | minRegions = maxRegions, // min number of regions 18 | aimForMaxRegions = true, // aim for maxRegions? If false, aims for randomly 19 | // chosen number between minRegions and 20 | // maxRegions. Always aims for maxRegions if it is 21 | // Infinity 22 | maxSquares = 2, // max squares in region 23 | minSquares = maxSquares, // min squares in region 24 | aimForMaxSquares = true, // aim for maxSquares? If false, aims for randomly 25 | // chosen number between minSquares and maxSquares 26 | shape = 'smooth', // 'smooth', 'smoothish', 'any', 'roughish' or 27 | // 'rough' 28 | exclude = null, // iterable of agents that cannot appear in a 29 | // region 30 | gap = 1, // min distance (in squares) between regions, and 31 | // also between regions and excluded agents 32 | padding = 0, // min distance (in squares) between regions and 33 | // edge of area 34 | retry = 100, // max total retries after generating a region 35 | // with fewer than minSquares 36 | setup = null, // function to call on each square of each region; 37 | // passed the square, region (xset), square index 38 | // (within the region) and region index 39 | grow = null // if truthy, should be 1, 2, 3 or 4: indicates 40 | // that a region only grows from the last added 41 | // square, and the value of grow specifies the 42 | // max number of directions the region can grow in 43 | } = {}) { 44 | 45 | // process and check args 46 | assertNonnegativeInteger(padding, 'padding'); 47 | assertNonnegativeInteger(gap, 'gap'); 48 | assertNonnegativeInteger(minRegions, 'minRegions'); 49 | if (maxRegions !== Infinity) { 50 | assertPositiveInteger(maxRegions, 'maxRegions'); 51 | if (minRegions > maxRegions) { 52 | throw Error('minRegions is greater than maxRegions'); 53 | } 54 | } 55 | assertPositiveInteger(maxSquares, 'maxSquares'); 56 | assertPositiveInteger(minSquares, 'minSquares'); 57 | if (minSquares > maxSquares) { 58 | throw Error('minSquares is greater than maxSquares'); 59 | } 60 | assertNonnegativeInteger(retry, 'retry'); 61 | if (grow) assertPositiveInteger(grow, 'grow'); 62 | 63 | // useful constants 64 | const sim = area.__simulation ? area : area._simulation; 65 | const isAreaActor = area.type === 'actor'; 66 | const squaresWithinArea = isAreaActor 67 | ? new XSet(area.enclosing('square')) 68 | : area.squares; 69 | 70 | // initialize excluded squares 71 | const excludeSquares = new XSet(); 72 | 73 | // exclude padding 74 | if (padding) { 75 | if (isAreaActor) { 76 | const tempRadius = area.radius - sim.gridStep * padding; 77 | if (tempRadius > 0) { 78 | excludeSquares.adds(squaresWithinArea.difference(sim.squaresInCircle({ 79 | x: area.x, 80 | y: area.y, 81 | radius: tempRadius 82 | }, 'within'))); 83 | } 84 | else { 85 | excludeSquares.adds(squaresWithinArea); 86 | } 87 | } 88 | else { // area is a simulation or zone 89 | for (let level = -1; level >= -padding; level--) { 90 | excludeSquares.adds(area.layer(level)); 91 | } 92 | } 93 | } 94 | 95 | // exclude squares in excluded agents - and gap around them 96 | for (let ex of exclude || []) { 97 | if (ex.type === 'actor') { 98 | if (gap) { 99 | excludeSquares.adds(sim.squaresInCircle({ 100 | x: ex.x, 101 | y: ex.y, 102 | radius: ex.radius + sim.gridStep * gap 103 | }, 'overlap')); 104 | } 105 | else { 106 | excludeSquares.adds(ex.squares); 107 | } 108 | } 109 | else { 110 | for (let level = 1; level <= gap; level++) { 111 | excludeSquares.adds(ex.layer(level)); 112 | } 113 | ex.type === 'zone' 114 | ? excludeSquares.adds(ex.squares) 115 | : excludeSquares.add(ex); 116 | } 117 | } 118 | 119 | // initialize free squares 120 | const freeSquares = squaresWithinArea.difference(excludeSquares); 121 | 122 | // initialize candidates for next square 123 | const candidates = new Map(); 124 | 125 | // directions - only used when grow is truthy 126 | const allDirections = ['north', 'east', 'south' ,'west']; 127 | let regDirections; 128 | function sampleDirections() { 129 | if (grow) { 130 | regDirections = grow > 3 131 | ? allDirections 132 | : shuffle(allDirections).slice(0, grow); 133 | } 134 | } 135 | 136 | // add square to region 137 | function addSquareToRegion(sq, reg) { 138 | reg.add(sq); 139 | excludeSquares.add(sq); 140 | freeSquares.delete(sq); 141 | if (grow) { 142 | candidates.clear(); 143 | for (let direction of regDirections) { 144 | const neighbor = sq[direction](); 145 | if (neighbor && freeSquares.has(neighbor)) { 146 | candidates.set( 147 | neighbor, reg.intersection(neighbor.layerMain(1)).size); 148 | } 149 | } 150 | } 151 | else { 152 | candidates.delete(sq); 153 | for (let neighbor of sq.layerMain(1)) { 154 | if (freeSquares.has(neighbor)) { 155 | candidates.set(neighbor, (candidates.get(neighbor) || 0) + 1); 156 | } 157 | } 158 | } 159 | } 160 | 161 | // choose next square from candidates 162 | function chooseCandidate() { 163 | const candidatesArray = [...candidates]; 164 | if (candidatesArray.length === 1) { 165 | return candidatesArray[0][0]; 166 | } 167 | if (shape === 'any') { 168 | return randomElement(candidatesArray)[0]; 169 | } 170 | const isRough = shape.includes('rough'); 171 | if (shape === 'smoothish' || shape === 'roughish') { 172 | let cumulativeSum = 0; 173 | const cumulativeProbs = []; // cumulative probs are not normalized 174 | if (isRough) { 175 | var maxNebsPlusOne = !grow || grow > 3 176 | ? 5 177 | : (grow === 3 ? 3 : 2) 178 | } 179 | for (let [sq, nNebs] of candidatesArray) { 180 | cumulativeSum += isRough ? maxNebsPlusOne - nNebs : nNebs; 181 | cumulativeProbs.push(cumulativeSum); 182 | } 183 | const v = random.uniform_01() * cumulativeSum; 184 | for (var i = 0; i < cumulativeProbs.length - 1; i++) { 185 | if (v < cumulativeProbs[i]) return candidatesArray[i][0]; 186 | } 187 | return candidatesArray[i][0]; 188 | } 189 | let best = -Infinity; 190 | for (let nNebs of candidates.values()) { 191 | if (isRough) nNebs *= -1; 192 | if (nNebs > best) best = nNebs; 193 | } 194 | if (isRough) best *= -1; 195 | return randomElement( 196 | candidatesArray.filter(([sq, nNebs]) => nNebs === best) 197 | )[0]; 198 | } 199 | 200 | // generate regions - each region is an xset of squares 201 | const targetRegions = 202 | maxRegions === Infinity || aimForMaxRegions || minRegions === maxRegions 203 | ? maxRegions 204 | : random.int(minRegions, maxRegions + 1)(); 205 | const regs = []; 206 | while (regs.length < targetRegions) { 207 | 208 | // target number of squares 209 | const targetSquares = aimForMaxSquares || minSquares === maxSquares 210 | ? maxSquares 211 | : random.int(minSquares, maxSquares + 1)(); 212 | 213 | // sample new directions for this region (if grow truthy) 214 | sampleDirections(); 215 | 216 | // create region and randomly choose first square 217 | const reg = new XSet(); 218 | candidates.clear(); 219 | if (freeSquares.size === 0) { 220 | break; 221 | } 222 | addSquareToRegion(randomElement([...freeSquares]), reg); 223 | 224 | // add squares to region 225 | while (reg.size < targetSquares && candidates.size) { 226 | addSquareToRegion(chooseCandidate(), reg); 227 | } 228 | if (reg.size < minSquares) { 229 | if (retry--) { 230 | for (let sq of reg) { 231 | excludeSquares.delete(sq); 232 | freeSquares.add(sq); 233 | } 234 | continue; 235 | } 236 | break; 237 | } 238 | 239 | // exclude gap squares around region 240 | for (let sq of reg) { 241 | for (let level = 1; level <= gap; level++) { 242 | const layer = sq.layer(level); 243 | excludeSquares.adds(layer); 244 | freeSquares.deletes(layer); 245 | } 246 | } 247 | 248 | regs.push(reg); 249 | 250 | } 251 | 252 | // check number of regions and call setup for each square of each region 253 | if (regs.length < minRegions) { 254 | throw Error('number of generated regions is less than minRegions'); 255 | } 256 | if (typeof setup === 'function') { 257 | for (let [iReg, reg] of regs.entries()) { 258 | let iSq = 0; 259 | for (let sq of reg) { 260 | setup(sq, reg, iSq++, iReg); 261 | } 262 | } 263 | } 264 | 265 | return regs; 266 | 267 | } -------------------------------------------------------------------------------- /src/zone.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Zone class. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | import { XSet } from './x-set.js'; 6 | import { random } from './random.js'; 7 | import { assertAgentType, assertInteger, getOverlapping, getIndexLimits, 8 | getLayer, partitionRect, setVisOptions } from './helpers.js'; 9 | import { Agent } from "./agent.js"; 10 | import { insideDistance } from "./inside-distance.js"; 11 | import { regions } from './regions.js'; 12 | import { autotile } from './autotile.js'; 13 | 14 | export class Zone extends Agent { 15 | 16 | static visOptions = new Set([...Agent.visOptions, 17 | 'tile', 18 | 'textPosition', 19 | 'textPadding', 20 | ]); 21 | 22 | static updatableVisOptions = Agent.updatableVisOptions; 23 | 24 | constructor(options = {}) { 25 | super(options); 26 | this.type = 'zone'; 27 | this._shape = 'rect'; 28 | this.zIndex = options.zIndex ?? -Infinity; 29 | this.direction = options.direction ?? 0; 30 | this.squares = null; 31 | this.xMin = null; 32 | this.xMax = null; 33 | this.yMin = null; 34 | this.width = null; 35 | this.height = null; 36 | [this.xMinIndex, this.xMaxIndex, this.yMinIndex, this.yMaxIndex] = 37 | getIndexLimits(options.indexLimits); 38 | this._resetOnRemove = 39 | ['xMin', 'xMax', 'yMin', 'yMax', 'width', 'height', 'squares']; 40 | for (let name of ['xMinIndex', 'xMaxIndex', 'yMinIndex', 'yMaxIndex']) { 41 | const value = this[name]; 42 | if (!Number.isInteger(value) || value < 0) { 43 | throw Error(`invalid ${name}, non-negative integer expected`); 44 | } 45 | } 46 | if (this.xMinIndex > this.xMaxIndex) { 47 | throw Error('xMinIndex cannot be greater than xMaxIndex'); 48 | } 49 | if (this.yMinIndex > this.yMaxIndex) { 50 | throw Error('yMinIndex cannot be greater than yMaxIndex'); 51 | } 52 | this.nx = this.xMaxIndex - this.xMinIndex + 1; 53 | this.ny = this.yMaxIndex - this.yMinIndex + 1; 54 | } 55 | 56 | vis(obj = {}) { 57 | return setVisOptions(this, Zone, obj); 58 | } 59 | 60 | addTo(simulation) { 61 | this._validateSimulation(simulation); 62 | if (simulation.nx <= this.xMaxIndex || 63 | simulation.ny <= this.yMaxIndex) { 64 | throw Error('zone is not inside the simulation grid'); 65 | } 66 | this.squares = new XSet(); 67 | for (let yi = this.yMinIndex; yi <= this.yMaxIndex; yi++) { 68 | for (let xi = this.xMinIndex; xi <= this.xMaxIndex; xi++) { 69 | const sq = simulation._grid.squares[yi][xi]; 70 | this.squares.add(sq); 71 | sq.zones.add(this); 72 | } 73 | } 74 | const topLeftSquare = 75 | simulation._grid.squares[this.yMinIndex][this.xMinIndex]; 76 | const bottomRightSquare = 77 | simulation._grid.squares[this.yMaxIndex][this.xMaxIndex]; 78 | this.xMin = topLeftSquare.xMin; 79 | this.xMax = bottomRightSquare.xMax; 80 | this.yMin = topLeftSquare.yMin; 81 | this.yMax = bottomRightSquare.yMax; 82 | this.x = (this.xMin + this.xMax) / 2; 83 | this.y = (this.yMin + this.yMax) / 2; 84 | this.width = simulation.gridStep * this.nx; 85 | this.height = simulation.gridStep * this.ny; 86 | this._addToSimulation(simulation); 87 | return this; 88 | } 89 | 90 | layer(level = 1) { 91 | this._assertSimulation(); 92 | assertInteger(level); 93 | if (level) { 94 | return getLayer(this._simulation, level, { 95 | xiMin: this.xMinIndex, 96 | xiMax: this.xMaxIndex, 97 | yiMin: this.yMinIndex, 98 | yiMax: this.yMaxIndex 99 | }); 100 | } 101 | else { 102 | return [...this.squares]; 103 | } 104 | } 105 | 106 | partition(options = {}) { 107 | const addToSim = options.addToSim || options.addToSim === undefined; 108 | if (addToSim && !this._simulation) { 109 | throw Error( 110 | 'cannot add zones to simulation - calling zone is not in a simulation' 111 | ); 112 | } 113 | return partitionRect(this, options).map((r, i) => { 114 | const zn = new Zone({indexLimits: r}); 115 | if (addToSim) zn.addTo(this._simulation); 116 | options.setup?.(zn, i); 117 | return zn; 118 | }); 119 | } 120 | 121 | regions(options) { 122 | this._assertSimulation(); 123 | return regions(this, options); 124 | } 125 | 126 | autotile(options) { 127 | return autotile(this.squares, {...options, _forceUseProbs: false }); 128 | } 129 | 130 | randomX(padding = 0) { 131 | this._assertSimulation(); 132 | return random.uniform_01() * (this.width - 2 * padding) + 133 | padding + this.xMin; 134 | } 135 | 136 | randomY(padding = 0) { 137 | this._assertSimulation(); 138 | return random.uniform_01() * (this.height - 2 * padding) + 139 | padding + this.yMin; 140 | } 141 | 142 | randomXIndex() { 143 | return random.int(this.xMinIndex, this.xMaxIndex + 1)(); 144 | } 145 | 146 | randomYIndex() { 147 | return random.int(this.yMinIndex, this.yMaxIndex + 1)(); 148 | } 149 | 150 | randomSquare() { 151 | this._assertSimulation(); 152 | return this._simulation._grid.squares 153 | [this.randomYIndex()] 154 | [this.randomXIndex()]; 155 | } 156 | 157 | 158 | // ========== proximity methods ========== 159 | 160 | _insideNeighbors(maxDistance) { // actor neighbors, no type parameter 161 | this._assertSimulation(); 162 | const {step: gridStep, squares: gridSquares} = this._simulation._grid; 163 | const depth = Math.ceil((maxDistance + 1e-12) / gridStep); 164 | let candidates; 165 | if (depth * 2 >= 166 | Math.min(this.xMaxIndex - this.xMinIndex, 167 | this.yMaxIndex - this.yMinIndex) + 1) { 168 | candidates = this.overlapping('actor'); 169 | } 170 | else { 171 | candidates = new XSet(); 172 | for (let i = this.yMinIndex; i <= this.yMaxIndex; i++) { 173 | if (i < this.yMinIndex + depth || i > this.yMaxIndex - depth) { 174 | for (let j = this.xMinIndex; j <= this.xMaxIndex; j++) { 175 | candidates.adds(gridSquares[i][j].actors); 176 | } 177 | } 178 | else { 179 | for (let j = this.xMinIndex; j < this.xMinIndex + depth; j++) { 180 | candidates.adds(gridSquares[i][j].actors); 181 | } 182 | for (let j = this.xMaxIndex; j > this.xMaxIndex - depth; j--) { 183 | candidates.adds(gridSquares[i][j].actors); 184 | } 185 | } 186 | } 187 | } 188 | const r = []; 189 | for (let a of candidates) { 190 | const d = insideDistance(a, this); 191 | if (d >= -a.radius && d <= maxDistance) { 192 | r.push(a); 193 | } 194 | } 195 | return r; 196 | } 197 | 198 | _overlappingBoundaryCandidateSet() { // actors only, no type parameter 199 | this._assertSimulation(); 200 | if (this.xMinIndex === this.xMaxIndex || 201 | this.yMinIndex === this.yMaxIndex) { 202 | return getOverlapping(this.squares, 'actor'); 203 | } 204 | const gridSquares = this._simulation._grid.squares; 205 | const r = new XSet(); 206 | for (let i = this.xMinIndex; i <= this.xMaxIndex; i++) { 207 | r.adds(gridSquares[this.yMinIndex][i].actors); 208 | r.adds(gridSquares[this.yMaxIndex][i].actors); 209 | } 210 | for (let i = this.yMinIndex + 1; i < this.yMaxIndex; i++) { 211 | r.adds(gridSquares[i][this.xMinIndex].actors); 212 | r.adds(gridSquares[i][this.xMaxIndex].actors); 213 | } 214 | return r; 215 | } 216 | 217 | neighbors(maxDistance, type) { 218 | this._assertSimulation(); 219 | assertAgentType(type); 220 | if (!(maxDistance > 0)) { 221 | return []; 222 | } 223 | let r; 224 | const { gridStep } = this._simulation; 225 | if (type === 'square') { 226 | r = [...this.squares, ...this.layer(1)]; 227 | let i = 1; 228 | while (maxDistance > gridStep * i) { 229 | const candidates = this.layer(i + 1); 230 | if (candidates.length === 0) break; 231 | if (maxDistance > Math.sqrt(2) * gridStep * i) { 232 | r.push(...candidates); 233 | } 234 | else { 235 | for (let c of candidates) { 236 | if (this.distance(c) < maxDistance) r.push(c); 237 | } 238 | } 239 | i++; 240 | } 241 | } 242 | else if (type === 'zone') { 243 | r = new Set(); 244 | for (let sq of this.neighbors(maxDistance, 'square')) { 245 | for (let z of sq.zones) { 246 | r.add(z); 247 | } 248 | } 249 | r.delete(this); 250 | r = [...r]; 251 | } 252 | else { // actor 253 | r = new Set(); 254 | for (let sq of this.neighbors(maxDistance, 'square')) { 255 | for (let a of sq.actors) { 256 | r.add(a); 257 | } 258 | } 259 | for (let a of r) { 260 | if (this.distance(a) >= maxDistance) r.delete(a); 261 | } 262 | r = [...r]; 263 | } 264 | return r; 265 | } 266 | 267 | overlapping(type) { 268 | this._assertSimulation(); 269 | assertAgentType(type); 270 | return type === 'square' 271 | ? [...this.squares] 272 | : [...getOverlapping(this.squares, type, this)]; 273 | } 274 | 275 | within(type) { 276 | this._assertSimulation(); 277 | assertAgentType(type); 278 | if (type === 'square') { 279 | return this.squares.size === 1 ? [...this.squares] : []; 280 | } 281 | else if (type === 'zone') { 282 | let [firstSq, ...otherSquares] = this.squares; 283 | const firstSqZones = firstSq.zones.copy(); 284 | firstSqZones.delete(this); 285 | return [ 286 | ...firstSqZones.intersection(...otherSquares.map(sq => sq.zones)) 287 | ]; 288 | } 289 | else { // actor 290 | const [firstSq, ...otherSquares] = this.squares; 291 | return firstSq.actors.intersection(...otherSquares.map(sq => sq.actors)) 292 | .filter(a => this.isWithin(a), 'array'); 293 | } 294 | } 295 | 296 | centroidWithin(type) { 297 | this._assertSimulation(); 298 | assertAgentType(type); 299 | if (type === 'square') { 300 | const xIndexMean = (this.xMaxIndex + this.xMinIndex) / 2; 301 | const xInds = Number.isInteger(xIndexMean) 302 | ? [xIndexMean] 303 | : [Math.floor(xIndexMean), Math.ceil(xIndexMean)]; 304 | const yIndexMean = (this.yMaxIndex + this.yMinIndex) / 2; 305 | const yInds = Number.isInteger(yIndexMean) 306 | ? [yIndexMean] 307 | : [Math.floor(yIndexMean), Math.ceil(yIndexMean)]; 308 | const r = []; 309 | for (let yi of yInds) { 310 | for (let xi of xInds) { 311 | r.push(this._simulation.squareAt(xi, yi)); 312 | } 313 | } 314 | return r; 315 | } 316 | else { 317 | return getOverlapping(this.squares, type, this).filter( 318 | a => this.isCentroidWithin(a), 'array'); 319 | } 320 | } 321 | 322 | _enclosing(type, testName) { 323 | this._assertSimulation(); 324 | assertAgentType(type); 325 | return type === 'square' 326 | ? [...this.squares] 327 | : getOverlapping(this.squares, type, this).filter( 328 | a => this[testName](a), 'array'); 329 | } 330 | 331 | enclosing(type) { 332 | return this._enclosing(type, 'isEnclosing'); 333 | } 334 | 335 | enclosingCentroid(type) { 336 | return this._enclosing(type, 'isEnclosingCentroid'); 337 | } 338 | 339 | } -------------------------------------------------------------------------------- /src/autotile.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Function that takes an iterable of squares, and generates a valid tiling of 3 | // the squares given the permitted edge matches and prior probabilities of 4 | // tiles. Based on ideas from Wave Function Collapse 5 | // (https://github.com/mxgmn/WaveFunctionCollapse) and simpler alternatives 6 | // (e.g. https://robertheaton.com/2018/12/17/wavefunction-collapse-algorithm/). 7 | // 8 | // Returns a {complete, assignments, attempts, backtracks} object. The attempts 9 | // and backtracks properties indicates the total number of attempts and 10 | // backtracks the algorithm used. If a solution is found, complete is true and 11 | // assignments is a map where each key is a square, and the value is a {name, 12 | // rotationCode} object containing the name of the tile assigned to the square, 13 | // and the tile's rotation: 0-none, 1-90° clockwise, 2-180°, 3-270°. If no 14 | // solution is found, complete is false and the assignments map contains the 15 | // assignments made on the last attempt. Square-tile pairs are added to the 16 | // assignments map in the order they are chosen. 17 | //////////////////////////////////////////////////////////////////////////////// 18 | 19 | 20 | import { XSet } from "./x-set.js"; 21 | import { randomElement, moduloShift } from "./helpers.js"; 22 | import { random } from "./random.js"; 23 | 24 | 25 | // ========== local helpers ========== 26 | 27 | const compassIndices = { north: 0, east: 1, south: 2, west: 3 }; 28 | 29 | function edgeAtDirection(tileEdges, rotationCode, direction) { 30 | return typeof tileEdges === 'string' 31 | ? tileEdges 32 | : tileEdges[moduloShift(direction - rotationCode, 4)]; 33 | } 34 | 35 | 36 | // ========== autotile function ========== 37 | 38 | export function autotile(squares, options) { 39 | 40 | // copy and check squares 41 | squares = new XSet(squares); 42 | if (squares.size === 0) { 43 | return { 44 | complete: true, 45 | assignments: new Map, 46 | attempts: 0, 47 | backtracks: 0, 48 | }; 49 | } 50 | 51 | 52 | // ========== get options ========== 53 | 54 | // tiles: object. Each key is a name; the value is an object with properties: 55 | // - edges: string (the edge name) if same edge on all sides, else a 4-array 56 | // of edge names: top, right, bottom, left 57 | // - rotate = false: boolean, true if the tile can be rotated 58 | // - prob = 1: number/function, prior probability of tile. Value should be 59 | // ≥ 0, but probs need not be normalised - only relative values matter. 60 | // If prob is a function, it is passed the square the tile is being 61 | // considered for and returns the probability 62 | const tiles = {}; 63 | for (let [tileName, tileInfo] of Object.entries(options.tiles)) { 64 | tileInfo = {...tileInfo}; 65 | tileInfo.prob ??= 1; 66 | tiles[tileName] = tileInfo; 67 | } 68 | 69 | // edges: object. Each key is an edge name; the value is the edge name or 70 | // array of edge names that the key-edge can be matched with. If an edge 71 | // name does not appear as a key, it is assumed that the edge can only match 72 | // itself. Omit the edges option entirely if all edges only match themselves. 73 | const edgeNames = new XSet; 74 | for (let { edges } of Object.values(tiles)) { 75 | edgeNames[typeof edges === 'string' ? 'add' : 'adds'](edges); 76 | } 77 | const edges = {}; 78 | for (let edgeName of edgeNames) { 79 | const matchingEdges = options.edges?.[edgeName]; 80 | if (matchingEdges) { 81 | edges[edgeName] = new Set( 82 | typeof matchingEdges === 'string' ? [matchingEdges] : matchingEdges 83 | ); 84 | } 85 | else { 86 | edges[edgeName] = new Set([edgeName]); 87 | } 88 | } 89 | 90 | // startTiles: if used, a map (or array of arrays) where each key is a square 91 | // and the value is a { name, rotationCode = 0 } object - see the format of 92 | // the map returned by autotile for details. The autotile algorithm finds 93 | // tiles for all squares not included in startTiles. 94 | // - The 'edge validity' of start tiles is not checked - i.e. adjacent start 95 | // tiles need not obey the edge relationships in the edges option. 96 | // - Entries for squares not in the squares argument are dropped. 97 | const startTiles = new Map; 98 | for (let [sq, tileInfo] of options.startTiles || []) { 99 | if (!squares.has(sq)) continue; 100 | tileInfo = {...tileInfo}; 101 | tileInfo.rotationCode ??= 0; 102 | startTiles.set(sq, tileInfo); 103 | } 104 | 105 | // retry: number, max allowed attempts to find a solution. 106 | const retry = options.retry ?? 100; 107 | 108 | // backtrack: boolean. An attempt fails if there is a square with no valid 109 | // tile candidates. If backtrack is true, the algorithm backtracks to the 110 | // last assigned-to neighbor of the no-candidates-square and chooses a 111 | // different tile for the neighbor so that the edge facing the 112 | // no-candidates-square is different. 113 | const backtrack = options.backtrack ?? true; 114 | 115 | // _forceUseProbs = true, boolean. If true, forces algorithm to use 116 | // probabilities. This is a hack which ensures that if not autotiliing a 117 | // simulation, zone or actor, we use probabilities and hence naively 118 | // initialise the candidates of all non-start-squares - which is required 119 | // since may have unconnected components so a single random start square 120 | // may be insufficient. 121 | let useProbs = options._forceUseProbs ?? true; 122 | 123 | 124 | // ========== tile names and tiles map ========== 125 | 126 | const tileNames = []; 127 | const tilesMap = new Map; 128 | for (let [tileName, tileInfo] of Object.entries(tiles)) { 129 | tileNames.push(tileName); 130 | tilesMap.set(tileName, tileInfo); 131 | if (tileInfo.prob !== 1) useProbs = true; 132 | } 133 | for (let { name: tileName } of startTiles.values()) { 134 | if (!tilesMap.has(tileName)) { 135 | throw Error( 136 | `tile '${tileName}' in startTiles is not listed in the tiles option`); 137 | } 138 | } 139 | 140 | 141 | // ========== tile names, probabilities and entropy of candidates ========== 142 | 143 | function updateProbInfo(sq, candidates) { 144 | if (candidates.size === 0) { 145 | candidates.__probInfo = null; 146 | return; 147 | } 148 | if (useProbs) { 149 | const tileNames = []; 150 | const probs = []; 151 | let total = 0; 152 | for (let tileName of candidates.keys()) { 153 | let prob = tiles[tileName].prob; 154 | if (typeof prob === 'function') prob = prob(sq); 155 | if (!prob) { 156 | candidates.delete(tileName); 157 | } 158 | else { 159 | tileNames.push(tileName); 160 | probs.push(prob); 161 | total += prob; 162 | } 163 | } 164 | if (candidates.size === 0) { 165 | candidates.__probInfo = null; 166 | return; 167 | } 168 | let e = 0; 169 | for (let prob of probs) { 170 | prob /= total; 171 | e += prob * Math.log(prob); 172 | } 173 | candidates.__probInfo = { tileNames, probs, entropy: -e }; 174 | } 175 | else { // uniform probs 176 | candidates.__probInfo = { 177 | tileNames: [...candidates.keys()], 178 | entropy: -Math.log(1 / candidates.size), 179 | }; 180 | } 181 | } 182 | 183 | 184 | // ========== valid matches ========== 185 | 186 | const validMatches = new Map; 187 | 188 | // for every edge-direction combination, find all tile-rotation pairs that 189 | // give a valid neighbor edge 190 | for (let edgeName of edgeNames) { 191 | const validNeighbors = edges[edgeName]; 192 | const edgeMatches = [new Map, new Map, new Map, new Map]; 193 | for (let [tileName, {edges: tileEdgeNames, rotate, prob}] of tilesMap) { 194 | if (!prob) continue; 195 | if (typeof tileEdgeNames === 'string') { 196 | if (validNeighbors.has(tileEdgeNames)) { 197 | const turns = new Set(rotate ? [0, 1, 2, 3] : [0]); 198 | edgeMatches[0].set(tileName, turns); 199 | edgeMatches[1].set(tileName, turns); 200 | edgeMatches[2].set(tileName, turns); 201 | edgeMatches[3].set(tileName, turns); 202 | } 203 | } 204 | else if (!rotate) { 205 | for (let direction = 0; direction < 4; direction++) { 206 | if (validNeighbors.has(tileEdgeNames[(direction + 2) % 4])) { 207 | edgeMatches[direction].set(tileName, new Set([0])); 208 | } 209 | } 210 | } 211 | else { // rotate true: include all rotations that give valid neighbor 212 | const turns = []; 213 | for (let turn = 0; turn < 4; turn++) { 214 | if (validNeighbors.has(tileEdgeNames[moduloShift(2 - turn, 4)])) { 215 | turns.push(turn); 216 | } 217 | if (turns.length) { 218 | edgeMatches[0].set(tileName, new Set(turns)); 219 | edgeMatches[1].set(tileName, new Set(turns.map(t => (t + 1) % 4))); 220 | edgeMatches[2].set(tileName, new Set(turns.map(t => (t + 2) % 4))); 221 | edgeMatches[3].set(tileName, new Set(turns.map(t => (t + 3) % 4))); 222 | } 223 | } 224 | } 225 | } 226 | validMatches.set(edgeName, edgeMatches); 227 | } 228 | 229 | 230 | // ========== compute/update candidates ========== 231 | 232 | // new candidates map (tile name => set of valid rotation codes) for square 233 | // that currently has no edge constraints from neighbors 234 | let initCandidates; 235 | { 236 | if (useProbs && 237 | [...tilesMap.values()].some(({prob}) => typeof prob === 'function')) { 238 | initCandidates = function(sq) { 239 | const candidates = new Map; 240 | for (let [tileName, { rotate, prob }] of tilesMap) { 241 | if (typeof prob === 'function') prob = prob(sq); 242 | if (prob) { 243 | candidates.set(tileName, new Set(rotate ? [0, 1, 2, 3] : [0])); 244 | } 245 | } 246 | updateProbInfo(sq, candidates); 247 | return candidates; 248 | }; 249 | } 250 | else { // same for all squares 251 | const baseCandidates = new Map; 252 | for (let [tileName, { rotate, prob }] of tilesMap) { 253 | if (prob) { 254 | baseCandidates.set(tileName, new Set(rotate ? [0, 1, 2, 3] : [0])); 255 | } 256 | } 257 | updateProbInfo(null, baseCandidates); 258 | initCandidates = function() { 259 | const candidates = structuredClone(baseCandidates); 260 | candidates.__probInfo = // clone of map does not include added properties 261 | baseCandidates.__probInfo; 262 | return candidates; 263 | }; 264 | } 265 | } 266 | 267 | // Compute/update a square's tile candidates given a new edge constraint from 268 | // a neighbor 269 | // - sq: square, the square the tiles are candidates for 270 | // - neighborEdgeName: string, edge name of neighbor 271 | // - direction: 0, 1, 2 or 3; the square's edge that is being matched to 272 | // neighborEdgeName, e.g. 1 means matching square's right side - so its 273 | // neighbor on the right has edge neighborEdgeName on its left 274 | // - candidates: omit if no existing candidates; else a map (tile name => set 275 | // of valid rotation codes); updateCandidates mutates this map 276 | function updateCandidates(sq, neighborEdgeName, direction, candidates) { 277 | 278 | // new candidate tiles, a map: tile name => set of valid turns 279 | const newCandidates = 280 | validMatches.get(neighborEdgeName)[(direction + 2) % 4]; 281 | 282 | // no existing candidates 283 | if (!candidates) { 284 | candidates = structuredClone(newCandidates); 285 | } 286 | 287 | // there are existing candidates; eliminate those not in new list 288 | else { 289 | for (let [tileName, turns] of candidates) { 290 | if (!newCandidates.has(tileName)) { 291 | candidates.delete(tileName); 292 | } 293 | else { 294 | const newTurns = newCandidates.get(tileName); 295 | for (let t of turns) { 296 | if (!newTurns.has(t)) { 297 | turns.delete(t); 298 | if (turns.size === 0) { 299 | candidates.delete(tileName); 300 | break; 301 | } 302 | } 303 | } 304 | } 305 | } 306 | } 307 | 308 | updateProbInfo(sq, candidates); 309 | return candidates; 310 | 311 | } 312 | 313 | 314 | // ========== choose a tile from candidates ========== 315 | 316 | function chooseTile(candidates) { 317 | const { tileNames, probs } = candidates.__probInfo; 318 | const chosenTileName = tileNames.length === 1 319 | ? tileNames[0] 320 | : useProbs 321 | ? tileNames[random.categorical(probs)()] 322 | : tileNames[random.int(tileNames.length)()]; 323 | const turns = [...candidates.get(chosenTileName)]; 324 | return { 325 | name: chosenTileName, 326 | rotationCode: turns.length === 1 ? turns[0] : randomElement(turns), 327 | }; 328 | } 329 | 330 | 331 | // ========== try loop ========== 332 | 333 | let assignments; 334 | let attempts = 0; 335 | let backtracks = 0; 336 | let backtrackInfo; 337 | let backtrackSquares = new Set; 338 | attemptsLoop: while (attempts < retry) { 339 | 340 | // initial assignments 341 | if (!backtrackInfo) { 342 | attempts++; 343 | backtrackSquares = new Set; 344 | assignments = new Map(startTiles); 345 | } 346 | else { 347 | backtracks++; 348 | } 349 | 350 | // candidates and free squares 351 | const allCandidates = new Map; 352 | const freeSquares = new XSet(squares); 353 | freeSquares.deletes(assignments.keys()); 354 | 355 | // given an assigned-to square, update candidates of neighboring squares 356 | function updateCandidatesOfNeighbors(sq, tileName, rotationCode) { 357 | for (let [compassDirection, neighborSq] 358 | of Object.entries(sq.compassMain())) { 359 | if (neighborSq && freeSquares.has(neighborSq)) { 360 | const direction = compassIndices[compassDirection]; 361 | const edgeAtDir = 362 | edgeAtDirection(tiles[tileName].edges, rotationCode, direction); 363 | const filteredCandidates = updateCandidates( 364 | neighborSq, 365 | edgeAtDir, 366 | (direction + 2) % 4, 367 | allCandidates.get(neighborSq) 368 | ); 369 | if (!filteredCandidates.__probInfo) { 370 | return { 371 | square: sq, 372 | success: false, 373 | direction, 374 | edgeName: edgeAtDir 375 | }; 376 | }; 377 | allCandidates.set(neighborSq, filteredCandidates); 378 | } 379 | } 380 | return { success: true }; 381 | } 382 | 383 | // if not using probs and no start tiles, assign to a random square - or 384 | // there will be no assignments or candidates to kick off assignment loop 385 | if (!useProbs && !assignments.size) { 386 | const sq = randomElement([...squares]); 387 | const tileName = randomElement(tileNames); 388 | assignments.set(sq, { 389 | name: tileName, 390 | rotationCode: tiles[tileName].rotate ? random.int(4)() : 0, 391 | }); 392 | freeSquares.delete(sq); 393 | } 394 | 395 | // compute/update candidates for neighbors of start tiles 396 | for (let [sq, {name, rotationCode}] of assignments) { 397 | const { success } = updateCandidatesOfNeighbors(sq, name, rotationCode); 398 | if (!success) break attemptsLoop; 399 | } 400 | 401 | // if using probs, compute initial candidates for squares that are neither 402 | // start squares nor their neighbors - since these squares may still have 403 | // the lowest entropy 404 | if (useProbs) { 405 | for (let sq of squares) { 406 | if (!assignments.has(sq) && !allCandidates.has(sq)) { 407 | const candidates = initCandidates(sq); 408 | if (!candidates.__probInfo) break attemptsLoop; 409 | allCandidates.set(sq, candidates); 410 | } 411 | } 412 | } 413 | 414 | // assign to free squares until none left or fail to find solution 415 | while (freeSquares.size) { 416 | 417 | // choose next square and assign to it 418 | let chosenSq; 419 | if (backtrackInfo) { 420 | chosenSq = backtrackInfo.square; 421 | const candidates = allCandidates.get(chosenSq); 422 | for (let [tileName, turns] of candidates) { 423 | const edges = tiles[tileName].edges; 424 | for (let rotCode of turns) { 425 | if (edgeAtDirection(edges, rotCode, backtrackInfo.direction) === 426 | backtrackInfo.edgeName) { 427 | turns.delete(rotCode); 428 | if (turns.size === 0) { 429 | candidates.delete(tileName); 430 | if (candidates.size === 0) { 431 | backtrackInfo = null; 432 | continue attemptsLoop; 433 | } 434 | } 435 | } 436 | } 437 | } 438 | updateProbInfo(chosenSq, candidates); 439 | backtrackInfo = null; 440 | } 441 | else { 442 | let minEntropy = Infinity; 443 | let possNextSquares = []; 444 | for (let [sq, { __probInfo }] of allCandidates) { 445 | const { entropy } = __probInfo; 446 | if (entropy < minEntropy) { 447 | minEntropy = entropy; 448 | possNextSquares = [sq]; 449 | } 450 | else if (entropy === minEntropy) { 451 | possNextSquares.push(sq); 452 | } 453 | } 454 | chosenSq = possNextSquares.length > 1 455 | ? randomElement(possNextSquares) 456 | : possNextSquares[0]; 457 | } 458 | const chosenTile = chooseTile(allCandidates.get(chosenSq)); 459 | assignments.set(chosenSq, chosenTile); 460 | allCandidates.delete(chosenSq); 461 | freeSquares.delete(chosenSq); 462 | 463 | // update candidates of neighbors of chosen square 464 | const updateResult = updateCandidatesOfNeighbors( 465 | chosenSq, chosenTile.name, chosenTile.rotationCode); 466 | if (!updateResult.success) { 467 | if (backtrack) { 468 | if (backtrackSquares.has(chosenSq)) { 469 | backtrackInfo = null; 470 | continue attemptsLoop; 471 | } 472 | backtrackSquares.add(chosenSq); 473 | const newAssignments = new Map; 474 | for (let [sq, tileInfo] of assignments) { 475 | if (sq === chosenSq) { 476 | assignments = newAssignments; 477 | backtrackInfo = updateResult; 478 | break; 479 | } 480 | else { 481 | newAssignments.set(sq, tileInfo); 482 | } 483 | } 484 | } 485 | continue attemptsLoop; 486 | } 487 | 488 | } 489 | 490 | break attemptsLoop; 491 | 492 | } 493 | 494 | return { 495 | complete: assignments.size === squares.size, 496 | assignments: assignments, 497 | attempts, 498 | backtracks, 499 | }; 500 | 501 | } -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Helper functions. Some are only used internally, whereas others are also 3 | // exported from Atomic Agents. 4 | //////////////////////////////////////////////////////////////////////////////// 5 | 6 | import { random } from './random.js'; 7 | import { Vector } from './vector.js'; 8 | import { XSet } from './x-set.js'; 9 | 10 | // Shuffle array in place using Fisher–Yates algorithm. 11 | export function shuffle(x) { 12 | for (let i = x.length - 1; i > 0; i--) { 13 | const j = Math.floor(random.uniform_01() * (i + 1)); 14 | if (i !== j) { 15 | const t = x[i]; 16 | x[i] = x[j]; 17 | x[j] = t; 18 | } 19 | } 20 | return x; 21 | } 22 | 23 | // Loop n times; pass f the index each step. 24 | export function loop(n, f) { 25 | for (let i = 0; i < n; i++) { 26 | f(i); 27 | } 28 | } 29 | 30 | // Bound value to given min and max. 31 | export function bound(val, min, max) { 32 | return Math.max(Math.min(val, max), min); 33 | } 34 | 35 | // Is positive integer? 36 | export function isPositiveInteger(k) { 37 | return Number.isInteger(k) && k > 0; 38 | } 39 | 40 | // Is non-negative integer? 41 | export function isNonnegativeInteger(k) { 42 | return Number.isInteger(k) && k > -1; 43 | } 44 | 45 | // Assert integer. 46 | export function assertInteger(k, name) { 47 | if (!Number.isInteger(k)) { 48 | throw Error(`${name} must be an integer`); 49 | } 50 | } 51 | 52 | // Assert positive integer. 53 | export function assertPositiveInteger(k, name) { 54 | if (!isPositiveInteger(k)) { 55 | throw Error(`${name} must be a positive integer`); 56 | } 57 | } 58 | 59 | // Assert non-negative integer. 60 | export function assertNonnegativeInteger(k, name) { 61 | if (!isNonnegativeInteger(k)) { 62 | throw Error(`${name} must be a non-negative integer`); 63 | } 64 | } 65 | 66 | // Roughly equal to? 67 | export function roughlyEqual(u, v) { 68 | return Math.abs(u - v) < 1e-10; 69 | } 70 | 71 | // Throw if t is not a valid agent type. 72 | export function assertAgentType(t) { 73 | if (t !== 'actor' && t !== 'square' && t !== 'zone') { 74 | throw Error('invalid agent type'); 75 | } 76 | } 77 | 78 | // Is iterable? 79 | export function isIterable(a) { 80 | return typeof a?.[Symbol.iterator] === 'function'; 81 | } 82 | 83 | // Random element of an array. 84 | export function randomElement(a) { 85 | return a[random.int(a.length)()]; 86 | } 87 | 88 | // Random point in circle. 89 | export function randomPointInCircle({x = 0, y = 0, radius = 1}) { 90 | const r = radius * Math.sqrt(random.uniform_01()); 91 | const theta = random.uniform_01() * 2 * Math.PI; 92 | return { 93 | x: x + r * Math.cos(theta), 94 | y: y + r * Math.sin(theta) 95 | }; 96 | } 97 | 98 | // Iterable of index limits. No checking: either returns passed iterable, or 99 | // assumes passed an object and puts the relevant property values in an array. 100 | export function getIndexLimits(a) { 101 | return isIterable(a) 102 | ? a 103 | : [a.xMinIndex, a.xMaxIndex, a.yMinIndex, a.yMaxIndex]; 104 | } 105 | 106 | // XSet of actors or zones that overlap any square in an iterable of squares. 107 | // - type should be 'actor' or 'zone' 108 | // - omitThis should be an actor or zone 109 | export function getOverlapping(squares, type, omitThis) { 110 | const r = new XSet(); 111 | const prop = type + 's'; 112 | for (let sq of squares) { 113 | for (let a of sq[prop]) { 114 | if (a !== omitThis) r.add(a); 115 | } 116 | } 117 | return r; 118 | } 119 | 120 | // Set vis options for simulation or agent. 121 | const interactionEvents = new Set([ 122 | 'click', 123 | 'pointercancel', 124 | 'pointerdown', 125 | 'pointerout', 126 | 'pointerover', 127 | 'pointertap', 128 | 'pointerup', 129 | 'pointerupoutside' 130 | ]); 131 | export function setVisOptions(a, cls, ops) { 132 | if (a._vis || a._visUpdates || a._interaction) { 133 | throw Error('can only set vis options once'); 134 | } 135 | for (let [key, value] of Object.entries(ops)) { 136 | if (interactionEvents.has(key.toLowerCase())) { 137 | if (typeof value !== 'function') { 138 | throw Error(`interaction option "${key}": function expected`); 139 | } 140 | (a._interaction ??= new Map()).set(key.toLowerCase(), value.bind(a)); 141 | } 142 | else if (typeof value === 'function') { 143 | if (!cls.updatableVisOptions.has(key)) { 144 | throw Error(`"${key}" is not an updatable vis option`); 145 | } 146 | (a._visUpdates ??= new Map()).set(key, value.bind(a)); 147 | } 148 | else { 149 | if (!cls.visOptions.has(key)) { 150 | throw Error(`"${key}" is not a vis option`); 151 | } 152 | (a._vis ??= new Map()).set(key, value); 153 | } 154 | } 155 | return a; 156 | } 157 | 158 | // Set 3d vis options for simulation or agent. 159 | const interactionEvents3d = new Set([ 160 | ]); 161 | export function setVis3dOptions(a, cls, ops) { 162 | if (a._vis3d || a._vis3dUpdates || a._interaction3d) { 163 | throw Error('can only set 3d vis options once'); 164 | } 165 | for (let [key, value] of Object.entries(ops)) { 166 | if (interactionEvents3d.has(key.toLowerCase())) { 167 | if (typeof value !== 'function') { 168 | throw Error(`3d interaction option "${key}": function expected`); 169 | } 170 | (a._interaction3d ??= new Map()).set(key.toLowerCase(), value.bind(a)); 171 | } 172 | else if (typeof value === 'function') { 173 | if (!cls.updatableVis3dOptions.has(key)) { 174 | throw Error(`"${key}" is not an updatable 3d vis option`); 175 | } 176 | (a._vis3dUpdates ??= new Map()).set(key, value.bind(a)); 177 | } 178 | else { 179 | if (!cls.vis3dOptions.has(key)) { 180 | throw Error(`"${key}" is not a 3d vis option`); 181 | } 182 | (a._vis3d ??= new Map()).set(key, value); 183 | } 184 | } 185 | return a; 186 | } 187 | 188 | // Returns r = p + mq, such that: 0 <= r < q (so assumes q > 0). 189 | export function moduloShift(p, q) { 190 | return p < 0 || p >= q 191 | ? p - Math.floor(p / q) * q 192 | : p; 193 | } 194 | 195 | // Convert angle to a value in [0, 2 * pi). 196 | export function normalizeAngle(a) { 197 | return moduloShift(a, 2 * Math.PI); 198 | } 199 | 200 | // Get layer - an array of squares. 201 | // sim: simulation object 202 | // level: non-zero integer (can be negative) 203 | // limits: object with props `xiMin`, `xiMax`, `yiMin`, `yiMax' 204 | // (valid x and y grid indices with min <= max on each dimension) 205 | export function getLayer(sim, level, limits) { 206 | 207 | // process limits and bound them if necessary 208 | const { nx, ny, squares: gridSquares } = sim._grid; 209 | if (level < 0) level++; 210 | const xStart = limits.xiMin - level; 211 | const xEnd = limits.xiMax + level; 212 | const yStart = limits.yiMin - level; 213 | const yEnd = limits.yiMax + level; 214 | if (xStart > xEnd || yStart > yEnd) { 215 | return []; 216 | } 217 | const xStartBounded = Math.max(xStart, 0); 218 | const xEndBounded = Math.min(xEnd, nx - 1); 219 | const yStartBounded = Math.max(yStart, 0); 220 | const yEndBounded = Math.min(yEnd, ny - 1); 221 | const s = []; 222 | 223 | // layer has length 1 on one or both dimensions 224 | if (xStartBounded === xEndBounded) { 225 | if (yStartBounded === yEndBounded) { 226 | s.push(gridSquares[yStartBounded][xStartBounded]); 227 | } 228 | else { 229 | for (let yi = yStartBounded; yi <= yEndBounded; yi++) { 230 | s.push(gridSquares[yi][xStartBounded]); 231 | } 232 | } 233 | } 234 | else if (yStartBounded === yEndBounded) { 235 | for (let xi = xStartBounded; xi <= xEndBounded; xi++) { 236 | s.push(gridSquares[yStartBounded][xi]); 237 | } 238 | } 239 | 240 | // layer greater than langth 1 on both dimensions 241 | else { 242 | if (yStart >= 0) { // top 243 | const xn = xEndBounded < xEnd ? xEndBounded : xEndBounded - 1; 244 | for (let xi = xStartBounded; xi <= xn; xi++) { 245 | s.push(gridSquares[yStart][xi]); 246 | } 247 | } 248 | if (xEnd < nx) { // right 249 | const yn = yEndBounded < yEnd ? yEndBounded : yEndBounded - 1; 250 | for (let yi = yStartBounded; yi <= yn; yi++) { 251 | s.push(gridSquares[yi][xEnd]); 252 | } 253 | } 254 | if (yEnd < ny) { // bottom 255 | const xn = xStartBounded > xStart ? xStartBounded : xStartBounded + 1; 256 | for (let xi = xEndBounded; xi >= xn; xi--) { 257 | s.push(gridSquares[yEnd][xi]); 258 | } 259 | } 260 | if (xStart >= 0) { // left 261 | const yn = yStartBounded > yStart ? yStartBounded : yStartBounded + 1; 262 | for (let yi = yEndBounded; yi >= yn; yi--) { 263 | s.push(gridSquares[yi][xStart]); 264 | } 265 | } 266 | } 267 | 268 | return s; 269 | 270 | } 271 | 272 | // The value at the given time of a repeating sequence with the given period 273 | // and steps 0, 1, ..., steps - 1. 274 | // - if time < 0, it is rounded up to 0 275 | // - period should be divisible by steps 276 | export function frame(period, steps, time) { 277 | return Math.floor(Math.max(time, 0) % period / (period / steps)); 278 | } 279 | 280 | // Grid points in rectangle - any object with properties xMin, xMax, yMin, yMax. 281 | // Pass options in second argument: 282 | export function gridInRect(rect, { 283 | n: nPoints, // min number of points to generate - see crop option 284 | pairs = true, // if true, return array of points (vectors); if false, 285 | // return an array; the first element is an array of 286 | // x-values, the second is an array of y-values 287 | padding = 0, // distance between rectangle boundary and closest grid 288 | // point 289 | descX, // true for descending x, ascending by default, 290 | descY, // true for descending y, ascending by default, 291 | // ----- following options ignored if pairs is false ----- 292 | crop = true, // false to generate all grid points (so possibly more than 293 | // n); true to return only n points 294 | columnsFirst // true to fill columns first; rows first by default 295 | } = {}) { 296 | 297 | // check and process arguments 298 | if (!Number.isInteger(nPoints) || nPoints <= 0) { 299 | throw Error('n must be a positive integer'); 300 | } 301 | const width = rect.xMax - rect.xMin - 2 * padding; 302 | const height = rect.yMax - rect.yMin - 2 * padding; 303 | if (width <= 0 || height <= 0) { 304 | throw Error( 305 | 'width and height of rectangle must be more than double the padding'); 306 | } 307 | const aspect = width / height; 308 | 309 | // 1 point is a special case 310 | if (nPoints === 1) { 311 | const x = padding + rect.xMin + width / 2; 312 | const y = padding + rect.yMin + height / 2; 313 | return pairs ? [new Vector(x, y)] : [[x], [y]]; 314 | } 315 | 316 | // compute number of columns and rows: consider 2 possibilities and take 317 | // closest to aspect ratio of the rectangle 318 | // - initially compute possible values for the number of x and y steps, then 319 | // add 1 to get numbers of columns and rows 320 | let nx, ny, nx1, ny1, nx2, ny2; 321 | if (width > height) { // smaller height: start from 2 height candidates 322 | const nyRaw = Math.sqrt(nPoints / aspect) - 1; 323 | ny1 = Math.max(0, Math.floor(nyRaw)); 324 | ny2 = ny1 + 1; 325 | nx2 = Math.floor((nyRaw + 1) * aspect - 1); 326 | nx1 = ny1 === 0 ? nPoints - 1 : nx2; 327 | nx1++; ny1++; nx2++; ny2++; 328 | while (nx1 * ny1 < nPoints) nx1++; 329 | while ((nx1 - 1) * ny1 >= nPoints) nx1--; 330 | while (nx2 * ny2 < nPoints) nx2++; 331 | while ((nx2 - 1) * ny2 >= nPoints) nx2--; 332 | [nx, ny] = // use logs to compare ratios - so e.g. 2.5 and 10 are same distance from 5 333 | (Math.log((nx1 - 1) / (ny1 - 1 || 1)) - Math.log(aspect)) ** 2 < 334 | (Math.log((nx2 - 1) / (ny2 - 1 || 1)) - Math.log(aspect)) ** 2 335 | ? [nx1, ny1] 336 | : [nx2, ny2]; 337 | } 338 | else { // smaller width: start from 2 width candidates 339 | const nxRaw = Math.sqrt(nPoints * aspect) - 1; 340 | nx1 = Math.max(0, Math.floor(nxRaw)); 341 | nx2 = nx1 + 1; 342 | ny2 = Math.floor((nxRaw + 1) / aspect - 1); 343 | ny1 = nx1 === 0 ? nPoints - 1 : ny2; 344 | nx1++; ny1++; nx2++; ny2++; 345 | while (nx1 * ny1 < nPoints) ny1++; 346 | while (nx1 * (ny1 - 1) >= nPoints) ny1--; 347 | while (nx2 * ny2 < nPoints) ny2++; 348 | while (nx2 * (ny2 - 1) >= nPoints) ny2--; 349 | [nx, ny] = 350 | (Math.log((ny1 - 1) / (nx1 - 1 || 1)) - Math.log(1 / aspect)) ** 2 < 351 | (Math.log((ny2 - 1) / (nx2 - 1 || 1)) - Math.log(1 / aspect)) ** 2 352 | ? [nx1, ny1] 353 | : [nx2, ny2]; 354 | } 355 | 356 | // generate grid points 357 | const xStep = nx > 1 ? width / (nx - 1) : null; 358 | const yStep = ny > 1 ? height / (ny - 1) : null; 359 | let step = xStep || yStep; 360 | let xPadding = padding; 361 | let yPadding = padding; 362 | if (xStep && yStep) { 363 | if (xStep < yStep) { 364 | yPadding += (yStep - xStep) * (ny - 1) / 2; 365 | step = xStep; 366 | } 367 | else if (yStep < xStep) { 368 | xPadding += (xStep - yStep) * (nx - 1) / 2; 369 | step = yStep; 370 | } 371 | } 372 | else if (xStep) { 373 | yPadding += height / 2; 374 | } 375 | else { // yStep is truthy 376 | xPadding += width / 2; 377 | } 378 | const xStart = rect.xMin + xPadding; 379 | const yStart = rect.yMin + yPadding; 380 | const xStop = rect.xMax - xPadding + 1e-10; 381 | const yStop = rect.yMax - yPadding + 1e-10; 382 | const rx = []; 383 | const ry = []; 384 | for (let x = xStart; x < xStop; x += step) rx.push(x); 385 | for (let y = yStart; y < yStop; y += step) ry.push(y); 386 | if (descX) rx.reverse(); 387 | if (descY) ry.reverse(); 388 | if (!pairs) return [rx, ry]; 389 | const r = []; 390 | if (columnsFirst) { 391 | for (let x of rx) { 392 | for (let y of ry) { 393 | r.push(new Vector(x, y)); 394 | if (crop && r.length === nPoints) return r; 395 | } 396 | } 397 | } 398 | else { 399 | for (let y of ry) { 400 | for (let x of rx) { 401 | r.push(new Vector(x, y)); 402 | if (crop && r.length === nPoints) return r; 403 | } 404 | } 405 | } 406 | return r; 407 | 408 | } 409 | 410 | // Triangular grid points in hexagon - any object with properties x, y, radius. 411 | // Returns an array of vectors. Pass options in second argument: 412 | export function gridInHex(hex, { 413 | n: nPoints, // min number of points to generate - see crop option 414 | padding = 0, // distance between hexagon boundary and closest grid 415 | // point 416 | clockwise = true, // true for clockwise points in each hexagonal layer, 417 | // false for counterclockwise 418 | start = 'top', // 'top' 'right', 'bottom' or 'left'; position of first 419 | // point in each hexagonal layer 420 | crop = true // false to generate all grid points (so possibly more 421 | // than n); true to return only n points 422 | } = {}) { 423 | 424 | // check and process arguments 425 | if (!Number.isInteger(nPoints) || nPoints <= 0) { 426 | throw Error('n must be a positive integer'); 427 | } 428 | const radius = hex.radius - padding; 429 | if (radius <= 0) { 430 | throw Error('hexagon radius must be greater than padding'); 431 | } 432 | const {x: cx, y: cy} = hex; 433 | 434 | // 1 point is a special case 435 | if (nPoints === 1) { 436 | return [new Vector(cx, cy)]; 437 | } 438 | 439 | // number of hexagonal layers (not including middle point) 440 | // - see https://planetmath.org/CenteredHexagonalNumber 441 | const nLayers = Math.ceil(-0.5 + Math.sqrt((nPoints - 1) / 3 + 0.25)); 442 | 443 | // generate points 444 | const center = new Vector(cx, cy); 445 | const mainPoints = []; 446 | const startAngle = { 447 | top: -Math.PI / 2, 448 | right: 0, 449 | bottom: Math.PI / 2, 450 | left: Math.PI 451 | }[start]; 452 | const magStep = radius / nLayers; 453 | const angleStep = Math.PI / 3 * (clockwise ? 1 : -1); 454 | for (let i = 0; i < 6; i++) { 455 | mainPoints.push(Vector.fromPolar(1, startAngle + angleStep * i)); 456 | } 457 | const r = [center]; 458 | for (let layer = 1; layer <= nLayers; layer++) { 459 | const layerMainPoints = 460 | mainPoints.map(mp => mp.copy().setMag(layer * magStep).add(center)); 461 | for (let i = 0; i < 6; i++) { // loop over layer main points 462 | const mp = layerMainPoints[i]; 463 | const mpNext = layerMainPoints[(i + 1) % 6]; 464 | r.push(mp); 465 | if (crop && r.length === nPoints) return r; 466 | for (let j = 1; j < layer; j++) { // interpolate between main points 467 | r.push(mp.lerp(mpNext, j / layer)); 468 | if (crop && r.length === nPoints) return r; 469 | } 470 | } 471 | } 472 | return r; 473 | 474 | } 475 | 476 | // Partition a rectangle (at integer indices) into smaller rectangles. 477 | // indexLimits describes the passed rectangle: an iterable with elements 478 | // `xMinIndex`, `xMaxIndex`, `yMinIndex`, `yMaxIndex`, or an object with these 479 | // properties. Returns an array of rectangles; each rectangle is an array of 480 | // index limits. Pass options in second argument. 481 | export function partitionRect(indexLimits, { 482 | n: nRect = 2, // number of rectangles in partition 483 | minWidth = 1, // min width of rectangles in the partition 484 | minHeight = 1, // min height of rectangles in the partition 485 | gap = 0, // space between rectangles in the partition 486 | padding = 0, // space between boundary of original rectangle and 487 | // rectangles in the partition 488 | randomSplit = true, // choose next rectangle to split: 489 | // - false: split largest rectangle with a valid split 490 | // - true: random choice - greater area, greater prob 491 | dim = 'xy', // split on either dimension ('xy') or only x' or only 492 | // 'y' 493 | randomDim = true, // if dim is 'xy': 494 | // - false: split rectangle on longest side (if can) 495 | // - true: random choice - longer side, greater prob 496 | randomSite = true // where to split the side: 497 | // - false: half way (rounded up) 498 | // - true: random 499 | } = {}) { 500 | 501 | // process and check args 502 | let [xiMin, xiMax, yiMin, yiMax] = getIndexLimits(indexLimits); 503 | assertNonnegativeInteger(padding, 'padding'); 504 | if (padding) { 505 | xiMin += padding; 506 | xiMax -= padding; 507 | yiMin += padding; 508 | yiMax -= padding; 509 | } 510 | for (let [name, value, assertFunc] of [ 511 | ['xiMin', xiMin, assertNonnegativeInteger], 512 | ['xiMax', xiMax, assertNonnegativeInteger], 513 | ['yiMin', yiMin, assertNonnegativeInteger], 514 | ['yiMax', yiMax, assertNonnegativeInteger], 515 | ['gap', gap, assertNonnegativeInteger], 516 | ['n', nRect, assertPositiveInteger ], 517 | ['minWidth', minWidth, assertPositiveInteger ], 518 | ['minHeight', minHeight, assertPositiveInteger ], 519 | ]) { 520 | assertFunc(value, name); 521 | } 522 | if (xiMin > xiMax) throw Error('xiMin is greater than xiMax'); 523 | if (yiMin > yiMax) throw Error('yiMin is greater than yiMax'); 524 | if (xiMax - xiMin + 1 < minWidth) { 525 | throw Error('minWidth is greater than the width of the rectangle'); 526 | } 527 | if (yiMax - yiMin + 1 < minHeight) { 528 | throw Error('minHeight is greater than the height of the rectangle'); 529 | } 530 | if (nRect === 1) { 531 | return [[xiMin, xiMax, yiMin, yiMax]]; 532 | } 533 | 534 | // rectangle class 535 | const xSplitAllowed = dim.includes('x'); 536 | const ySplitAllowed = dim.includes('y'); 537 | class Rectangle { 538 | constructor(indexLimits) { 539 | [this.xiMin, this.xiMax, this.yiMin, this.yiMax] = indexLimits; 540 | this.width = this.xiMax - this.xiMin + 1; 541 | this.height = this.yiMax - this.yiMin + 1; 542 | this.area = this.width * this.height; 543 | this.xSplitValid = xSplitAllowed && this.width >= 2 * minWidth + gap; 544 | this.ySplitValid = ySplitAllowed && this.height >= 2 * minHeight + gap; 545 | } 546 | } 547 | 548 | // get the x or y index of the split site 549 | // - the split is before the square at the split index 550 | function getSplitSite(len, min) { 551 | return randomSite 552 | ? Math.floor(random.uniform_01() * (len - 2 * min - gap + 1)) + min 553 | : Math.ceil((len - gap) / 2); 554 | } 555 | 556 | // partition, initialise with the original rectangle 557 | const partition = [ 558 | new Rectangle([xiMin, xiMax, yiMin, yiMax]) 559 | ]; 560 | 561 | // select rectangle to split - return an index of partition 562 | function getRectToSplit() { 563 | let probsSum = 0; 564 | const probs = []; // probs or cumulative probs (unnormalised) 565 | for (let r of partition) { 566 | const p = r.xSplitValid || r.ySplitValid ? r.area : 0; 567 | probsSum += p; 568 | probs.push(randomSplit ? probsSum : p); 569 | } 570 | if (probsSum === 0) { 571 | throw Error('no valid rectangles to split'); 572 | } 573 | if (randomSplit) { 574 | const v = random.uniform_01() * probsSum; 575 | for (var i = 0; i < probs.length - 1; i++) { 576 | if (v < probs[i]) return i; 577 | } 578 | return i; 579 | } 580 | else { 581 | let [i, pMax] = [0, 0]; 582 | for (let [j, p] of probs.entries()) { 583 | if (p > pMax) [i, pMax] = [j, p]; 584 | } 585 | return i; 586 | } 587 | } 588 | 589 | // split partition[i] into 2 smaller rectangles 590 | // - can assume partition[i] has a valid x-split or y-split 591 | // - replace partition[i] with the two new rectangles 592 | function splitRect(i) { 593 | const r = partition[i]; 594 | let r1, r2; 595 | let xSplit = false; 596 | if (r.xSplitValid && r.ySplitValid) { 597 | if (randomDim) { 598 | if (random.uniform_01() <= r.width / (r.width + r.height)) { 599 | xSplit = true; 600 | } 601 | } 602 | else if (r.width >= r.height) { 603 | xSplit = true; 604 | } 605 | } 606 | else if (r.xSplitValid) { 607 | xSplit = true; 608 | } 609 | if (xSplit) { 610 | const splitAt = getSplitSite(r.width, minWidth) + r.xiMin; 611 | r1 = new Rectangle([r.xiMin, splitAt - 1, r.yiMin, r.yiMax]); 612 | r2 = new Rectangle([splitAt + gap, r.xiMax, r.yiMin, r.yiMax]); 613 | } 614 | else { // y-split 615 | const splitAt = getSplitSite(r.height, minHeight) + r.yiMin; 616 | r1 = new Rectangle([r.xiMin, r.xiMax, r.yiMin, splitAt - 1]); 617 | r2 = new Rectangle([r.xiMin, r.xiMax, splitAt + gap, r.yiMax]); 618 | } 619 | partition.splice(i, 1, r1, r2); 620 | } 621 | 622 | // split rectangles until reach required number 623 | while (partition.length < nRect) { 624 | splitRect(getRectToSplit()); 625 | } 626 | 627 | // return rectangles as 4-arrays 628 | return partition.map(r => [r.xiMin, r.xiMax, r.yiMin, r.yiMax]); 629 | 630 | } -------------------------------------------------------------------------------- /src/actor.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Actor class. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | import { random } from './random.js'; 6 | import { XSet } from './x-set.js'; 7 | import { Vector } from './vector.js'; 8 | import { assertAgentType, assertInteger, assertPositiveInteger, getOverlapping, 9 | roughlyEqual, moduloShift, getLayer, setVisOptions } from './helpers.js'; 10 | import { Agent } from "./agent.js"; 11 | import { overlap } from './overlap.js'; 12 | import { nearestFrom } from './from-functions.js'; 13 | import { within } from './within.js'; 14 | import { insideDistance } from "./inside-distance.js"; 15 | import { regions } from './regions.js'; 16 | import { autotile } from './autotile.js'; 17 | 18 | export class Actor extends Agent { 19 | 20 | static visOptions = new Set([...Agent.visOptions, 21 | 'textRotate', 22 | 'textMaxWidth' 23 | ]); 24 | 25 | static updatableVisOptions = Agent.updatableVisOptions; 26 | 27 | constructor(options = {}) { 28 | super(options); 29 | this.type = 'actor'; 30 | this._shape = 'circle'; 31 | this.zIndex = options.zIndex ?? -Infinity; 32 | this.radius = options.radius ?? 5; 33 | this.mass = options.mass === 'area' 34 | ? Math.PI * this.radius ** 2 35 | : (options.mass ?? 1); 36 | this.vel = options.vel ?? new Vector(); 37 | this.pointing = options.pointing ?? null; 38 | this.maxSpeed = options.maxSpeed ?? 4; 39 | this.maxForce = options.maxForce ?? Infinity; 40 | this.steerMaxForce = options.steerMaxForce ?? Infinity; 41 | this.still = options.still ?? false; 42 | this.wrap = options.wrap ?? false; 43 | this.wrapX = options.wrapX ?? false; 44 | this.wrapY = options.wrapY ?? false; 45 | this.contains = options.contains ?? null; 46 | if (options.updateMass) this.updateMass = options.updateMass; 47 | if (options.updateRadius) this.updateRadius = options.updateRadius; 48 | if (options.updatePointing) this.updatePointing = options.updatePointing; 49 | this.squares = null; 50 | this.overlappingGrid = false; 51 | this._resetOnRemove = ['squares', 'containsCurrent']; 52 | this._assertPositiveProp('radius'); 53 | this._assertPositiveProp('mass'); 54 | this.steer = new Map(); 55 | this._force = new Vector(); 56 | this._wanderAngle = 0; 57 | this._xChange = 0; 58 | this._yChange = 0; 59 | this.containsCurrent = null; 60 | // also: this._contains, set by this.contains setter 61 | } 62 | 63 | vis(obj = {}) { 64 | return setVisOptions(this, Actor, obj); 65 | } 66 | 67 | _assertPositiveProp(prop) { 68 | if (typeof this[prop] !== 'number' || !(this[prop] > 0)) { 69 | throw Error(`actor ${prop} must be a positive number`); 70 | } 71 | } 72 | 73 | addTo(simulation) { 74 | this._validateSimulation(simulation); 75 | this.squares = new XSet(); 76 | this._addToSimulation(simulation); 77 | this._updateOverlappingSquares(); 78 | return this; 79 | } 80 | 81 | _updateOverlappingSquares() { 82 | if (overlap(this, this._simulation)) { 83 | this.overlappingGrid = true; 84 | const { squares: gridSquares } = this._simulation._grid; 85 | const { xiMin, xiMax, yiMin, yiMax } = 86 | this._simulation._bBoxIndices(this, this.radius); 87 | if (xiMin === xiMax && 88 | yiMin === yiMax && 89 | this.squares.size === 1 && 90 | this.squares.has(gridSquares[yiMin][xiMin]) 91 | ) { 92 | return; 93 | } 94 | for (let sq of this.squares) { 95 | if (sq.xIndex < xiMin || sq.xIndex > xiMax || 96 | sq.yIndex < yiMin || sq.yIndex > yiMax) { 97 | this.squares.delete(sq); 98 | sq.actors.delete(this); 99 | } 100 | } 101 | for (let yi = yiMin; yi <= yiMax; yi++) { 102 | for (let xi = xiMin; xi <= xiMax; xi++) { 103 | const sq = gridSquares[yi][xi]; 104 | const hadOverlap = this.squares.has(sq); 105 | const hasOverlap = overlap(this, sq); 106 | if (hadOverlap && !hasOverlap) { 107 | this.squares.delete(sq); 108 | sq.actors.delete(this); 109 | } 110 | else if (!hadOverlap && hasOverlap) { 111 | this.squares.add(sq); 112 | sq.actors.add(this); 113 | } 114 | } 115 | } 116 | } 117 | else { 118 | this.overlappingGrid = false; 119 | for (let sq of this.squares) { 120 | this.squares.delete(sq); 121 | sq.actors.delete(this); 122 | } 123 | } 124 | } 125 | 126 | heading() { 127 | return Math.atan2(this.vel.y, this.vel.x); 128 | } 129 | 130 | _updateXY(x, y) { 131 | const sim = this._simulation; 132 | if (sim) { 133 | const xOld = this.x; 134 | const yOld = this.y; 135 | this.x = this.wrap || this.wrapX 136 | ? moduloShift(x, sim.width) 137 | : x; 138 | this.y = this.wrap || this.wrapY 139 | ? moduloShift(y, sim.height) 140 | : y; 141 | if (!roughlyEqual(this.x, xOld) || !roughlyEqual(this.y, yOld)) { 142 | this._updateOverlappingSquares(); 143 | } 144 | if (sim.applyContainers) { 145 | this._xChange += this.x - xOld; 146 | this._yChange += this.y - yOld; 147 | } 148 | } 149 | else { 150 | this.x = x; 151 | this.y = y; 152 | } 153 | } 154 | 155 | setXY(x, y) { 156 | this._updateXY(x, y); 157 | return this; 158 | } 159 | 160 | useXY(v) { 161 | this._updateXY(v.x, v.y); 162 | return this; 163 | } 164 | 165 | get contains() { 166 | return this._contains; 167 | } 168 | 169 | set contains(p) { 170 | this._contains = p; 171 | this.label('__container', !!p); 172 | return p; 173 | } 174 | 175 | _updateContainsCurrent() { 176 | const c = this.contains; 177 | if (c) { 178 | if (typeof c === 'function') { 179 | this.containsCurrent = this.contains(this._simulation); // call using 'this.' not c 180 | } 181 | else if (c === 'within') { 182 | this.containsCurrent = this.enclosing('actor'); 183 | } 184 | else if (c === 'centroid') { 185 | this.containsCurrent = this.enclosingCentroid('actor'); 186 | } 187 | else if (c === 'overlap') { 188 | this.containsCurrent = this.overlapping('actor'); 189 | } 190 | else { 191 | this.containsCurrent = c; 192 | } 193 | } 194 | else { 195 | this.containsCurrent = null; 196 | this.label('__container', null); 197 | } 198 | } 199 | 200 | regions(options) { 201 | this._assertSimulation(); 202 | return regions(this, options); 203 | } 204 | 205 | autotile(options) { 206 | return autotile( 207 | this.enclosing('square'), {...options, _forceUseProbs: false }); 208 | } 209 | 210 | 211 | // ========== proximity methods ========== 212 | 213 | squareOfCentroid() { 214 | this._assertSimulation(); 215 | return this._simulation.squareOf(this.x, this.y); 216 | } 217 | 218 | layer(level = 1) { 219 | this._assertSimulation(); 220 | assertInteger(level); 221 | if (!this.overlappingGrid) { 222 | return null; 223 | } 224 | const limits = this._simulation._bBoxIndices(this, this.radius); 225 | if (level) { 226 | return getLayer(this._simulation, level, limits); 227 | } 228 | else { 229 | const { xiMin, xiMax, yiMin, yiMax } = limits; 230 | const s = []; 231 | const gridSquares = this._simulation._grid.squares; 232 | for (let yi = yiMin; yi <= yiMax; yi++) { 233 | for (let xi = xiMin; xi <= xiMax; xi++) { 234 | s.push(gridSquares[yi][xi]); 235 | } 236 | } 237 | return s; 238 | } 239 | } 240 | 241 | insideDistance(otherAgent) { 242 | return insideDistance(this, otherAgent); 243 | } 244 | 245 | _insideNeighbors(maxDistance) { // actor neighbors, no type parameter 246 | this._assertSimulation(); 247 | if (!this.overlappingGrid) { 248 | return null; 249 | } 250 | let candidates; 251 | const innerRadius = this.radius - maxDistance - 1e-12; 252 | if (this.squares.size < 9 || innerRadius <= 0) { 253 | candidates = getOverlapping(this.squares, 'actor', this); 254 | } 255 | else { 256 | candidates = new XSet(); 257 | const innerCircle = { 258 | _shape: 'circle', 259 | x: this.x, 260 | y: this.y, 261 | radius: innerRadius 262 | }; 263 | for (let sq of this.squares) { 264 | if (!within(sq, innerCircle)) { 265 | candidates.adds(sq.actors); 266 | } 267 | } 268 | candidates.delete(this); 269 | } 270 | const r = []; 271 | for (let a of candidates) { 272 | const d = insideDistance(a, this); 273 | if (d >= -a.radius && d <= maxDistance) { 274 | r.push(a); 275 | } 276 | } 277 | return r; 278 | } 279 | 280 | _overlappingBoundaryCandidateSet() { // actors only, no type parameter 281 | this._assertSimulation(); 282 | if (this.radius <= 4 || this.squares.size < 9) { 283 | return getOverlapping(this.squares, 'actor', this); 284 | } 285 | const r = new XSet(); 286 | const innerCircle = { 287 | _shape: 'circle', 288 | x: this.x, 289 | y: this.y, 290 | radius: this.radius - 2 291 | }; 292 | for (let sq of this.squares) { 293 | if (!within(sq, innerCircle)) { 294 | r.adds(sq.actors); 295 | } 296 | } 297 | r.delete(this); 298 | return r; 299 | } 300 | 301 | neighbors(maxDistance, type) { 302 | this._assertSimulation(); 303 | assertAgentType(type); 304 | if (!this.overlappingGrid) { 305 | return null; 306 | } 307 | if (!(maxDistance > 0)) { 308 | return []; 309 | } 310 | const { squares: gridSquares } = this._simulation._grid; 311 | const { xiMin, xiMax, yiMin, yiMax } = 312 | this._simulation._bBoxIndices(this, this.radius + maxDistance); 313 | let r; 314 | if (type === 'square') { 315 | r = []; 316 | for (let yi = yiMin; yi <= yiMax; yi++) { 317 | for (let xi = xiMin; xi <= xiMax; xi++) { 318 | const sq = gridSquares[yi][xi]; 319 | if (this.squares.has(sq) || this.distance(sq) < maxDistance) { 320 | r.push(sq); 321 | } 322 | } 323 | } 324 | } 325 | else { 326 | r = new XSet(); 327 | const prop = type + 's'; 328 | for (let yi = yiMin; yi <= yiMax; yi++) { 329 | for (let xi = xiMin; xi <= xiMax; xi++) { 330 | r.adds(gridSquares[yi][xi][prop]); 331 | } 332 | } 333 | r.delete(this); 334 | r = r.filter(a => this.distance(a) < maxDistance, 'array'); 335 | } 336 | return r; 337 | } 338 | 339 | overlapping(type) { 340 | this._assertSimulation(); 341 | assertAgentType(type); 342 | if (!this.overlappingGrid) { 343 | return null; 344 | } 345 | if (type === 'square') { 346 | return [...this.squares]; 347 | } 348 | else { 349 | let r; 350 | const prop = type + 's'; 351 | if (this.squares.size === 1) { 352 | r = this.squares[Symbol.iterator]().next().value[prop]; 353 | } 354 | else { 355 | r = new XSet(); 356 | for (let sq of this.squares) { 357 | for (let a of sq[prop]) { 358 | if (a !== this) r.add(a); 359 | } 360 | } 361 | } 362 | return type === 'actor' 363 | ? r.filter(a => a !== this && this.isOverlapping(a), 'array') 364 | : [...r]; 365 | } 366 | } 367 | 368 | within(type) { 369 | this._assertSimulation(); 370 | assertAgentType(type); 371 | if (!this.overlappingGrid) { 372 | return null; 373 | } 374 | if (type !== 'actor' && !this.isWithin(this._simulation)) { 375 | return []; 376 | } 377 | if (type === 'square') { 378 | return this.squares.size === 1 ? [...this.squares] : []; 379 | } 380 | else { 381 | const prop = type + 's'; 382 | let [firstSq, ...otherSquares] = this.squares; 383 | const firstSqCandidates = firstSq[prop].copy(); 384 | firstSqCandidates.delete(this); 385 | const candidates = 386 | firstSqCandidates.intersection(...otherSquares.map(sq => sq[prop])); 387 | return type === 'zone' 388 | ? [...candidates] 389 | : candidates.filter(a => this.isWithin(a), 'array'); 390 | } 391 | } 392 | 393 | _centroidIndices(dim) { 394 | const vs = this[dim] / this._simulation._grid.step; 395 | if (Number.isInteger(vs)) { 396 | const ns = this._simulation._grid['n' + dim]; 397 | if (vs === 0) return [0]; 398 | else if (vs === ns) return [ns - 1]; 399 | else return [vs - 1, vs]; 400 | } 401 | return [Math.floor(vs)]; 402 | } 403 | 404 | centroidWithin(type) { 405 | this._assertSimulation(); 406 | assertAgentType(type); 407 | if (!this.overlappingGrid) { 408 | return null; 409 | } 410 | if (!this.isCentroidWithin(this._simulation)) { 411 | return null; 412 | } 413 | const centroidSquares = []; 414 | for (let yi of this._centroidIndices('y')) { 415 | for (let xi of this._centroidIndices('x')) { 416 | centroidSquares.push(this._simulation.squareAt(xi, yi)); 417 | } 418 | } 419 | if (type === 'square') { 420 | return centroidSquares; 421 | } 422 | else { 423 | const r = getOverlapping(centroidSquares, type, this); 424 | return type === 'zone' 425 | ? [...r] 426 | : r.filter(a => this.isCentroidWithin(a), 'array'); 427 | } 428 | } 429 | 430 | _enclosing(type, testName) { 431 | this._assertSimulation(); 432 | assertAgentType(type); 433 | if (!this.overlappingGrid) { 434 | return null; 435 | } 436 | return type === 'square' 437 | ? this.squares.filter(sq => this[testName](sq), 'array') 438 | : getOverlapping(this.squares, type, this).filter( 439 | a => this[testName](a), 'array'); 440 | } 441 | 442 | enclosing(type) { 443 | return this._enclosing(type, 'isEnclosing'); 444 | } 445 | 446 | enclosingCentroid(type) { 447 | return this._enclosing(type, 'isEnclosingCentroid'); 448 | } 449 | 450 | nearest(k, filterFunction, type) { 451 | 452 | this._assertSimulation(); 453 | assertPositiveInteger(k, 'k'); 454 | assertAgentType(type); 455 | if (!this.overlappingGrid) { 456 | return null; 457 | } 458 | filterFunction ||= () => true; 459 | const gridStep = this._simulation.gridStep; 460 | 461 | // squares 462 | if (type === 'square') { 463 | const candidates = []; 464 | const dists = new Map(); 465 | const neighbors = []; 466 | for (let sq of this.squares) { 467 | if (filterFunction(sq, this)) neighbors.push(sq); 468 | if (neighbors.length === k) return neighbors; 469 | } 470 | let level = 0; 471 | let layerSquares = this.layer(level); 472 | if (layerSquares.length > this.squares.size) { 473 | for (let sq of layerSquares) { 474 | if (!this.squares.has(sq) && filterFunction(sq, this)) { 475 | candidates.push(sq); 476 | dists.set(sq, this.distance(sq)); 477 | } 478 | } 479 | candidates.sort((a, b) => dists.get(a) - dists.get(b)); 480 | } 481 | sqOuterLoop: while (true) { 482 | layerSquares = this.layer(++level); 483 | if (layerSquares.length === 0) { 484 | while (candidates.length && neighbors.length < k) { 485 | neighbors.push(candidates.shift()); 486 | } 487 | return neighbors; 488 | } 489 | for (let sq of layerSquares) { 490 | if (filterFunction(sq, this)) { 491 | candidates.push(sq); 492 | dists.set(sq, this.distance(sq)); 493 | } 494 | } 495 | candidates.sort((a, b) => dists.get(a) - dists.get(b)); 496 | const guaranteedDistance = level * gridStep; 497 | while (candidates.length) { 498 | const sq = candidates[0]; 499 | if (dists.get(sq) <= guaranteedDistance) { 500 | neighbors.push(sq); 501 | candidates.shift(); 502 | if (neighbors.length === k) return neighbors; 503 | } 504 | else { 505 | continue sqOuterLoop; 506 | } 507 | } 508 | } 509 | } 510 | 511 | // actors or zones 512 | else { 513 | const candidates = []; 514 | const dists = new Map(); 515 | const neighbors = new Set(); 516 | const rejected = new Set(); 517 | const bBox = this._simulation._bBoxIndices(this, this.radius); 518 | const distanceToEdge = // min dist from actor to edge of its squares bbox 519 | Math.max(Math.min( 520 | (this.x - this.radius) - (bBox.xiMin * gridStep), 521 | (bBox.xiMax + 1) * gridStep - (this.x + this.radius), 522 | (this.y - this.radius) - (bBox.yiMin * gridStep), 523 | (bBox.yiMax + 1) * gridStep - (this.y + this.radius) 524 | ), 0); 525 | let level = -1; 526 | outerLoop: while (true) { 527 | const layerSquares = this.layer(++level); 528 | if (layerSquares.length === 0) { 529 | while (candidates.length && neighbors.size < k) { 530 | neighbors.add(candidates.shift()); 531 | } 532 | break outerLoop; 533 | } 534 | for (let a of getOverlapping(layerSquares, type, this)) { 535 | if (!neighbors.has(a) && 536 | !dists.has(a) && 537 | !rejected.has(a)) { 538 | if (filterFunction(a, this)) { 539 | candidates.push(a); 540 | dists.set(a, this.distance(a)); 541 | } 542 | else { 543 | rejected.add(a); 544 | } 545 | } 546 | } 547 | candidates.sort((a, b) => dists.get(a) - dists.get(b)); 548 | const guaranteedDistance = distanceToEdge + level * gridStep; 549 | while (candidates.length) { 550 | const a = candidates[0]; 551 | if (dists.get(a) <= guaranteedDistance) { 552 | neighbors.add(a); 553 | candidates.shift(); 554 | dists.delete(a); 555 | if (neighbors.size === k) break outerLoop; 556 | } 557 | else { 558 | continue outerLoop; 559 | } 560 | } 561 | } 562 | return [...neighbors]; 563 | } 564 | 565 | } 566 | 567 | nearestFrom(k, candidates) { 568 | assertPositiveInteger(k, 'k'); 569 | return nearestFrom(this, k, candidates); 570 | } 571 | 572 | 573 | // ========== update radius, mass, force, velocity, position ========== 574 | 575 | _updateRadiusAndMass() { 576 | if (this.updateRadius) { 577 | const radiusOld = this.radius; 578 | this.radius = this.updateRadius(this._simulation); 579 | this._assertPositiveProp('radius'); 580 | if (!roughlyEqual(this.radius, radiusOld)) { 581 | this._updateOverlappingSquares(); 582 | if (this.updateMass === 'area') { 583 | this.mass = Math.PI * this.radius ** 2; 584 | } 585 | } 586 | } 587 | if (this.updateMass && this.updateMass !== 'area') { 588 | this.mass = this.updateMass(this._simulation); 589 | this._assertPositiveProp('mass'); 590 | } 591 | } 592 | 593 | _applySteeringForces() { 594 | // assume force has been reset, add all steering accelerations then multiply 595 | // by mass to get force 596 | for (let obj of this.steer.values()) { 597 | if (!(typeof obj.disabled === 'function' 598 | ? obj.disabled.call(this, this._simulation) 599 | : obj.disabled)) { 600 | const steeringAcc = this[`_${obj.behavior}`](obj); 601 | if (steeringAcc) { 602 | this._force.add(steeringAcc); 603 | } 604 | } 605 | } 606 | this._force.mult(this.mass); 607 | if (this.steerMaxForce < Infinity) { 608 | this._force.limit(this.steerMaxForce); 609 | } 610 | } 611 | 612 | _optionValue(obj, optionName, def) { 613 | const option = obj[optionName]; 614 | return typeof option === 'function' 615 | ? option.call(this, this._simulation) ?? def 616 | : option ?? def; 617 | } 618 | 619 | _seek(obj) { 620 | const target = this._optionValue(obj, 'target'); 621 | if (!target) { 622 | return null; 623 | } 624 | const off = this._optionValue(obj, 'off', 0); 625 | let slowMultiplier = 1; 626 | const d = this[obj.proximity === 'centroid' || !target.__agent 627 | ? 'centroidDistance' 628 | : 'distance' 629 | ](target); 630 | if (d <= off) { 631 | return null; 632 | } 633 | const slow = this._optionValue(obj, 'slow', 0); 634 | const maxSpeed = this._optionValue(obj, 'maxSpeed', this.maxSpeed); 635 | if (d <= slow) { 636 | slowMultiplier = (d - off) / (slow - off); 637 | } 638 | return new Vector(target.x, target.y).sub(this) // desired velocity 639 | .setMag(maxSpeed * slowMultiplier) // scaled 640 | .sub(this.vel); // acceleration (i.e. change in velocity) 641 | } 642 | 643 | _flee(obj) { 644 | const target = this._optionValue(obj, 'target'); 645 | if (!target) { 646 | return null; 647 | } 648 | const off = this._optionValue(obj, 'off', Infinity); 649 | let slowMultiplier = 1; 650 | const d = this[obj.proximity === 'centroid' || !target.__agent 651 | ? 'centroidDistance' 652 | : 'distance' 653 | ](target); 654 | if (d >= off) { 655 | return null; 656 | } 657 | const slow = this._optionValue(obj, 'slow', Infinity); 658 | const maxSpeed = this._optionValue(obj, 'maxSpeed', this.maxSpeed); 659 | if (d >= slow) { 660 | slowMultiplier = (off - d) / (off - slow); 661 | } 662 | return new Vector(this.x, this.y).sub(target) // desired velocity 663 | .setMag(maxSpeed * slowMultiplier) // scaled 664 | .sub(this.vel); // acceleration (i.e. change in velocity) 665 | } 666 | 667 | _wander(obj) { 668 | const wanderStrength = this._optionValue(obj, 'wanderStrength', 0.1); 669 | const wanderRate = this._optionValue(obj, 'wanderRate', 0.02); 670 | const maxSpeed = this._optionValue(obj, 'maxSpeed', this.maxSpeed); 671 | const negLimit = Math.max(-wanderRate, -(this._wanderAngle + wanderStrength)); 672 | const posLimit = Math.min(wanderRate, wanderStrength - this._wanderAngle); 673 | this._wanderAngle += random.uniform_01() * (posLimit - negLimit) + negLimit; 674 | const heading = this.vel.x === 0 && this.vel.y === 0 675 | ? random.uniform_01() * Math.PI * 2 676 | : this.heading(); 677 | return Vector.fromPolar(maxSpeed, heading + this._wanderAngle) 678 | .sub(this.vel); 679 | } 680 | 681 | _go(obj) { 682 | const nextPoint = obj.paths.get(this._simulation.squareOf(this.x, this.y)); 683 | if (nextPoint) { 684 | return nextPoint.copy().sub(this) 685 | .setMag(this._optionValue(obj, 'maxSpeed', this.maxSpeed)) 686 | .sub(this.vel); 687 | } 688 | return null; 689 | } 690 | 691 | _updateVelocityAndPosition() { 692 | if (this.maxForce < Infinity) { 693 | this._force.limit(this.maxForce); 694 | } 695 | this.vel.add(this._force.div(this.mass)); 696 | this.vel.limit(this.maxSpeed); 697 | this._updateXY(this.x + this.vel.x, this.y + this.vel.y); 698 | } 699 | 700 | _estimateNextPosition() { 701 | return { 702 | x: this.x + this.vel.x, 703 | y: this.y + this.vel.y, 704 | radius: this.radius, 705 | _shape: 'circle' 706 | }; 707 | } 708 | 709 | } -------------------------------------------------------------------------------- /src/simulation.js: -------------------------------------------------------------------------------- 1 | //////////////////////////////////////////////////////////////////////////////// 2 | // Simulation class. 3 | //////////////////////////////////////////////////////////////////////////////// 4 | 5 | import { random } from './random.js'; 6 | import { XSet } from './x-set.js'; 7 | import { Vector } from './vector.js'; 8 | import { 9 | assertInteger, normalizeAngle, getIndexLimits, getLayer, gridInRect, 10 | partitionRect, frame, setVisOptions, setVis3dOptions, isIterable 11 | } from './helpers.js'; 12 | import { Grid } from './grid.js'; 13 | import { centroidDistanceSqd } from './centroid-distance.js'; 14 | import { insideDistance } from './inside-distance.js'; 15 | import * as d3 from 'd3-ease'; 16 | import { boundaryDistance } from './boundary-distance.js'; 17 | import { Zone } from './zone.js'; 18 | import { Actor } from './actor.js'; 19 | import { regions } from './regions.js'; 20 | import { randomCircles } from './random-circles.js'; 21 | import { autotile } from './autotile.js'; 22 | 23 | export class Simulation { 24 | 25 | static single = false; 26 | static _currentSimulation = null; 27 | 28 | static visOptions = new Set([ 29 | 'baseColor', 30 | 'baseAlpha', 31 | 'background', 32 | 'tint', 33 | 'alpha', 34 | 'image', 35 | 'tile' 36 | ]); 37 | static updatableVisOptions = new Set([ 38 | 'tint', 39 | 'alpha', 40 | 'image' 41 | ]); 42 | 43 | static vis3dOptions = new Set([ 44 | 'backgroundColor', 45 | 'groundType', 46 | 'groundColor', 47 | 'groundTexture', 48 | 'groundCheckerColors', 49 | 'images', 50 | 'meshes' 51 | ]); 52 | 53 | static updatableVis3dOptions = new Set([ 54 | 'backgroundColor', 55 | 'groundColor', 56 | 'groundTexture', 57 | ]); 58 | 59 | constructor(options = {}) { 60 | this.width = options.width ?? 300; 61 | this.height = options.height ?? 300; 62 | this.gridStep = options.gridStep ?? 20; 63 | this.state = options.state ?? {}; 64 | this.history = options.history ?? {}; 65 | this.updateMassesAndRadii = options.updateMassesAndRadii ?? true; 66 | this.updatePointings = options.updatePointings ?? true; 67 | this.updateActorStates = options.updateActorStates ?? true; 68 | this.updateSquareStates = options.updateSquareStates ?? true; 69 | this.updateZoneStates = options.updateZoneStates ?? true; 70 | this.applyInteractionForces = options.applyInteractionForces ?? true; 71 | this.applySteeringForces = options.applySteeringForces ?? true; 72 | this.applyContainers = options.applyContainers ?? true; 73 | if (options.beforeTick) this.beforeTick = options.beforeTick; 74 | if (options.afterTick) this.afterTick = options.afterTick; 75 | if (options.stop) this.stop = options.stop; 76 | this.tickIndex = -1; 77 | this._labels = new Map(); 78 | this.__simulation = true; 79 | this._shape = 'rect'; 80 | this.xMin = 0; 81 | this.xMax = this.width; 82 | this.yMin = 0; 83 | this.yMax = this.height; 84 | this.x = this.width / 2; 85 | this.y = this.height / 2; 86 | this.actors = new XSet(); 87 | this.squares = new XSet(); 88 | this.zones = new XSet(); 89 | this._grid = new Grid(this); 90 | this.nx = this._grid.nx; 91 | this.ny = this._grid.ny; 92 | this.xMinIndex = 0 93 | this.xMaxIndex = this.nx - 1, 94 | this.yMinIndex = 0 95 | this.yMaxIndex = this.ny - 1, 96 | this._actorsAdded = new XSet(); 97 | this._zonesAdded = new XSet(); 98 | this._actorsRemoved = new XSet(); 99 | this._zonesRemoved = new XSet(); 100 | this.interaction = new Map(); 101 | this._pause = false; 102 | this._finished = false; 103 | this._bounceLogged = new XSet(); 104 | if (Simulation.single) { 105 | Simulation._currentSimulation?.end?.(); 106 | Simulation._currentSimulation = this; 107 | } 108 | else { 109 | Simulation._currentSimulation = null; 110 | } 111 | // also _visXXX and _interactionXXX properties set by vis and vis3d methods 112 | } 113 | 114 | vis(obj = {}) { 115 | return setVisOptions(this, Simulation, obj); 116 | } 117 | 118 | vis3d(obj = {}) { 119 | return setVis3dOptions(this, Simulation, obj); 120 | } 121 | 122 | _addAgent(agent) { 123 | this[agent.type + 's'].add(agent); 124 | if (agent.type !== 'square') { 125 | this[`_${agent.type}sAdded`].add(agent); 126 | } 127 | for (let [name, value] of agent._labels) { 128 | this._agentLabelNew(agent, name, value); 129 | } 130 | } 131 | 132 | _removeAgent(agent) { 133 | this[agent.type + 's'].delete(agent); 134 | this[`_${agent.type}sRemoved`].add(agent); 135 | for (let [name, value] of agent._labels) { 136 | const m = this._labels.get(name); 137 | m.get(value).delete(agent); 138 | this._pruneLabels(name, value); 139 | } 140 | } 141 | 142 | _agentLabelNew(agent, name, value) { 143 | if (this._labels.has(name)) { 144 | const m = this._labels.get(name); 145 | m.has(value) 146 | ? m.get(value).add(agent) 147 | : m.set(value, new XSet([agent])); 148 | } 149 | else { 150 | this._labels.set(name, new Map([[value, new XSet([agent])]])); 151 | } 152 | } 153 | 154 | _agentLabelChange(agent, name, oldValue, newValue) { 155 | const m = this._labels.get(name); 156 | m.get(oldValue).delete(agent); 157 | if (m.get(oldValue).size === 0) { 158 | m.delete(oldValue); 159 | } 160 | m.has(newValue) 161 | ? m.get(newValue).add(agent) 162 | : m.set(newValue, new XSet([agent])); 163 | } 164 | 165 | _agentLabelDelete(agent, name, oldValue) { 166 | this._labels.get(name).get(oldValue).delete(agent); 167 | this._pruneLabels(name, oldValue); 168 | } 169 | 170 | _pruneLabels(name, value) { 171 | const m = this._labels.get(name); 172 | if (m.get(value).size === 0) { 173 | m.delete(value); 174 | if (m.size === 0) { 175 | this._labels.delete(name); 176 | } 177 | } 178 | } 179 | 180 | pause(p = true) { 181 | this._pause = !!p; 182 | return this; 183 | } 184 | 185 | end() { 186 | this._finished = true; 187 | return this; 188 | } 189 | 190 | run() { 191 | while(!(this._finished || this._pause)) this.tick(); 192 | return this; 193 | } 194 | 195 | tick() { 196 | 197 | // finished or paused? 198 | if (this._finished || this._pause) return this; 199 | 200 | // increment tickIndex 201 | this.tickIndex++; 202 | 203 | // clear lists of added and removed agents 204 | this._actorsAdded.clear(); 205 | this._zonesAdded.clear(); 206 | this._actorsRemoved.clear(); 207 | this._zonesRemoved.clear(); 208 | 209 | // clear bounce logs 210 | for (let agent of this._bounceLogged) { 211 | agent.bounceLog.clear(); 212 | this._bounceLogged.delete(agent); 213 | } 214 | 215 | // update containers 216 | if (this.applyContainers) { 217 | for (let agent of this.withLabel('__container')) { 218 | agent._updateContainsCurrent(); 219 | agent._xChange = 0; 220 | agent._yChange = 0; 221 | } 222 | } 223 | 224 | // call beforeTick 225 | this.beforeTick?.(); 226 | 227 | // update masses and radii 228 | if (this.updateMassesAndRadii) { 229 | for (let agent of this.actors) { 230 | agent._updateRadiusAndMass(); 231 | } 232 | } 233 | 234 | // update pointings 235 | if (this.updatePointings) { 236 | for (let agent of this.actors) { 237 | if (agent.updatePointing) { 238 | agent.pointing = normalizeAngle(agent.updatePointing(this)); 239 | } 240 | } 241 | } 242 | 243 | // apply steering forces 244 | if (this.applySteeringForces) { 245 | for (let agent of this.actors) { 246 | if (agent.overlappingGrid && !agent.still) { 247 | agent._applySteeringForces(); 248 | } 249 | } 250 | } 251 | 252 | // apply interaction forces 253 | if (this.applyInteractionForces) { 254 | for (let obj of this.interaction.values()) { 255 | if (!(typeof obj.disabled === 'function' 256 | ? obj.disabled(this) 257 | : obj.disabled)) { 258 | this[`_${obj.behavior}`]({...obj}); // shallow copy obj so can mutate it 259 | } 260 | } 261 | } 262 | 263 | // update velocities and positions 264 | if (this.applyInteractionForces || this.applySteeringForces) { 265 | for (let agent of this.actors) { 266 | if (agent.still) { 267 | agent.vel.set(0, 0); 268 | } 269 | else { 270 | agent._updateVelocityAndPosition(); 271 | } 272 | agent._force.set(0, 0); 273 | } 274 | } 275 | 276 | // shift contents of containers 277 | if (this.applyContainers) { 278 | for (let container of this.withLabel('__container')) { 279 | for (let agent of container.containsCurrent || []) { 280 | if (!agent.still) { 281 | agent._updateXY( 282 | agent.x + container._xChange, 283 | agent.y + container._yChange 284 | ); 285 | } 286 | } 287 | } 288 | } 289 | 290 | // update states 291 | if (this.updateActorStates) { 292 | for (let agent of this.actors) agent.updateState?.(this); 293 | } 294 | if (this.updateSquareStates) { 295 | for (let agent of this.squares) agent.updateState?.(this); 296 | } 297 | if (this.updateZoneStates) { 298 | for (let agent of this.zones) agent.updateState?.(this); 299 | } 300 | 301 | // call afterTick 302 | this.afterTick?.(); 303 | 304 | // call stop 305 | if (this.stop?.()) this._finished = true; 306 | 307 | return this; 308 | 309 | } 310 | 311 | withLabel(name, value = true) { 312 | return this._labels.get(name)?.get(value) || new XSet(); 313 | } 314 | 315 | filter(f, returnArray) { 316 | let r, methodName; 317 | if (returnArray) { 318 | r = []; 319 | methodName = 'push'; 320 | } 321 | else { 322 | r = new XSet(); 323 | methodName = 'add'; 324 | } 325 | for (let a of this.actors) if (f(a)) r[methodName](a); 326 | for (let a of this.squares) if (f(a)) r[methodName](a); 327 | for (let a of this.zones) if (f(a)) r[methodName](a); 328 | return r; 329 | } 330 | 331 | squareAt(xIndex, yIndex) { 332 | return this._grid.squares[yIndex]?.[xIndex]; 333 | } 334 | 335 | squareAtIndex(index) { 336 | return this._grid.squares[Math.floor(index / this.nx)]?.[index % this.nx]; 337 | } 338 | 339 | squareOf(x, y) { 340 | return this._grid.squares[ 341 | y === this.height ? this.ny - 1 : Math.floor(y / this.gridStep) 342 | ]?.[ 343 | x === this.width ? this.nx - 1 : Math.floor(x / this.gridStep) 344 | ]; 345 | } 346 | 347 | squaresInRect(rect) { 348 | let [xiMin, xiMax, yiMin, yiMax] = getIndexLimits(rect); 349 | if (xiMin > xiMax || 350 | yiMin > yiMax || 351 | xiMin > this.xMaxIndex || 352 | xiMax < 0 || 353 | yiMin > this.yMaxIndex || 354 | yiMax < 0 355 | ) return []; 356 | xiMin = Math.max(0, xiMin); 357 | xiMax = Math.min(this.xMaxIndex, xiMax); 358 | yiMin = Math.max(0, yiMin); 359 | yiMax = Math.min(this.yMaxIndex, yiMax); 360 | const s = []; 361 | const gridSquares = this._grid.squares; 362 | for (let yi = yiMin; yi <= yiMax; yi++) { 363 | for (let xi = xiMin; xi <= xiMax; xi++) { 364 | s.push(gridSquares[yi][xi]); 365 | } 366 | } 367 | return s; 368 | } 369 | 370 | squaresInCircle(circle, contain = 'within') { 371 | const ac = new Actor(circle).addTo(this); 372 | const methodName = contain === 'overlap' 373 | ? 'overlapping' 374 | : contain === 'centroid' 375 | ? 'enclosingCentroid' 376 | : 'enclosing'; 377 | const squares = ac[methodName]('square') || []; 378 | ac.remove(); 379 | return squares; 380 | } 381 | 382 | layer(level = -1) { 383 | assertInteger(level); 384 | if (level < 0) { 385 | return getLayer(this, level, { 386 | xiMin: this.xMinIndex, 387 | xiMax: this.xMaxIndex, 388 | yiMin: this.yMinIndex, 389 | yiMax: this.yMaxIndex 390 | }); 391 | } 392 | else if (level === 0) { 393 | return [...this.squares]; 394 | } 395 | return []; 396 | } 397 | 398 | randomX(padding = 0) { 399 | return random.uniform_01() * (this.width - 2 * padding) + padding; 400 | } 401 | 402 | randomY(padding = 0) { 403 | return random.uniform_01() * (this.height - 2 * padding) + padding; 404 | } 405 | 406 | randomXIndex() { 407 | return random.int(this.nx)(); 408 | } 409 | 410 | randomYIndex() { 411 | return random.int(this.ny)(); 412 | } 413 | 414 | randomSquare() { 415 | return this.squareAtIndex(random.int(this.squares.size)()); 416 | } 417 | 418 | fitGrid(options) { 419 | return gridInRect(this, options); 420 | } 421 | 422 | randomCircles(options) { 423 | return randomCircles(this, options); 424 | } 425 | 426 | _populate(area, options) { 427 | const addToSim = options.addToSim || options.addToSim === undefined; 428 | const methodName = options.random ? 'randomCircles' : 'fitGrid'; 429 | return area[methodName](options).map((c, i) => { 430 | if (methodName === 'fitGrid') { // copy vector c to object and add radius 431 | c = { 432 | x: c.x, 433 | y: c.y, 434 | radius: typeof options.radius === 'function' 435 | ? options.radius(c.x, c.y, i) 436 | : options.radius 437 | }; 438 | } 439 | c.mass = typeof options.mass === 'function' 440 | ? options.mass(c.x, c.y, i) 441 | : options.mass; 442 | const ac = new Actor(c); 443 | if (addToSim) ac.addTo(area.__simulation ? area : area._simulation); 444 | options.setup?.(ac, i); 445 | return ac; 446 | }); 447 | } 448 | 449 | populate(options) { 450 | return this._populate(this, options); 451 | } 452 | 453 | frame(period, steps) { 454 | return frame(period, steps, this.tickIndex); 455 | } 456 | 457 | 458 | // Squares overlapped by the bbox of point p (with props x and y, possibly an 459 | // actor) with 'radius' d. If p is partly outside the grid, this is not 460 | // equivalent to using the bbox of the part of p that is inside the grid. 461 | _bBoxIndices(p, d) { 462 | const { step, nx, ny } = this._grid; 463 | let xiMax = (p.x + d) / step; 464 | xiMax = Math.min( 465 | Number.isInteger(xiMax) ? xiMax - 1 : Math.floor(xiMax), nx - 1); 466 | let yiMax = (p.y + d) / step; 467 | yiMax = Math.min( 468 | Number.isInteger(yiMax) ? yiMax - 1 : Math.floor(yiMax), ny - 1); 469 | return { 470 | xiMin: Math.max(Math.floor((p.x - d) / step), 0), 471 | xiMax, 472 | yiMin: Math.max(Math.floor((p.y - d) / step), 0), 473 | yiMax 474 | }; 475 | } 476 | 477 | partition(options = {}) { 478 | const addToSim = options.addToSim || options.addToSim === undefined; 479 | return partitionRect(this, options).map((r, i) => { 480 | const zn = new Zone({indexLimits: r}); 481 | if (addToSim) zn.addTo(this); 482 | options.setup?.(zn, i); 483 | return zn; 484 | }); 485 | } 486 | 487 | regions(options) { 488 | return regions(this, options); 489 | } 490 | 491 | autotile(options) { 492 | return autotile(this.squares, {...options, _forceUseProbs: false }); 493 | } 494 | 495 | 496 | // ========== proximity methods ========== 497 | 498 | _insideNeighbors(maxDistance) { // actor neighbors, no type parameter 499 | const {step: gridStep, squares: gridSquares, nx, ny} = this._grid; 500 | const depth = Math.ceil((maxDistance + 1e-12) / gridStep); 501 | let candidates; 502 | if (depth * 2 >= Math.min(nx, ny)) { 503 | candidates = this.actors; 504 | } 505 | else { 506 | candidates = new XSet(); 507 | for (let i = 0; i < ny; i++) { 508 | if (i < depth || i >= ny - depth) { 509 | for (let sq of gridSquares[i]) { 510 | candidates.adds(sq.actors); 511 | } 512 | } 513 | else { 514 | for (let j = 0; j < depth; j++) { 515 | candidates.adds(gridSquares[i][j].actors); 516 | } 517 | for (let j = nx - 1; j >= nx - depth; j--) { 518 | candidates.adds(gridSquares[i][j].actors); 519 | } 520 | } 521 | } 522 | } 523 | const r = []; 524 | for (let a of candidates) { 525 | const d = insideDistance(a, this); 526 | if (d >= -a.radius && d <= maxDistance) { 527 | r.push(a); 528 | } 529 | } 530 | return r; 531 | } 532 | 533 | // actor neighbors of agent p; if p is an actor, it is not included in result 534 | _pointNeighbors(p, maxDistance) { 535 | 536 | if (p.type === 'actor' && !p.overlappingGrid) { 537 | return null; 538 | } 539 | 540 | // if maxDistance >= to furthest corner of grid, consider all actors 541 | const maxDistanceSqd = maxDistance ** 2; 542 | if (Math.max(p.x, (this.xMax - p.x)) ** 2 + 543 | Math.max(p.y, (this.yMax - p.y)) ** 2 544 | >= maxDistanceSqd) { 545 | return this.actors.filter(a => { 546 | return a.overlappingGrid && 547 | a !== p && 548 | centroidDistanceSqd(a, p) < maxDistanceSqd 549 | }, 'array'); 550 | } 551 | 552 | // use squares in radius of maxDistance from p 553 | const gridSquares = this._grid.squares; 554 | const {xiMin, xiMax, yiMin, yiMax} = this._bBoxIndices(p, maxDistance); 555 | const candidates = new XSet(); 556 | for (let yi = yiMin; yi <= yiMax; yi++) { 557 | for (let xi = xiMin; xi <= xiMax; xi++) { 558 | candidates.adds(gridSquares[yi][xi].actors); 559 | } 560 | } 561 | candidates.delete(p); 562 | return candidates 563 | .filter(a => centroidDistanceSqd(a, p) < maxDistanceSqd, 'array'); 564 | 565 | } 566 | 567 | _overlappingBoundaryCandidateSet() { // actors only, no type parameter 568 | const {squares: gridSquares, nx, ny} = this._grid; 569 | if (nx === 1 || ny === 1) { 570 | return this.actors.filter(a => a.overlappingGrid); 571 | } 572 | const r = new XSet(); 573 | for (let sq of gridSquares[0]) { 574 | r.adds(sq.actors); 575 | } 576 | for (let i = 1; i < ny - 1; i++) { 577 | r.adds(gridSquares[i][0].actors); 578 | r.adds(gridSquares[i][nx - 1].actors); 579 | } 580 | for (let sq of gridSquares[ny - 1]) { 581 | r.adds(sq.actors); 582 | } 583 | return r; 584 | } 585 | 586 | 587 | // ========== interaction forces ========== 588 | 589 | _getForcesGroups(obj) { 590 | let {group1: g1, group2: g2} = obj; 591 | g1 = typeof g1 === 'function' ? g1(this) : g1; 592 | g2 = g2 593 | ? (typeof g2 === 'function' ? g2(this) : g2) 594 | : g1; 595 | if (g2 !== this.actors) { 596 | for (let a of g2) { 597 | if (!a.__agent || a.type !== 'actor') { 598 | throw Error('group2 must only contain actors'); 599 | } 600 | } 601 | } 602 | if (g1 === g2) { 603 | if (!(g1 instanceof Set)) { 604 | g2 = g1 = new Set(g1); 605 | } 606 | } 607 | else { 608 | if (g1 !== this && !(g1 instanceof Set)) { 609 | g1 = new Set(g1); 610 | } 611 | if (!(g2 instanceof Set)) { 612 | g2 = new Set(g2); 613 | } 614 | } 615 | return [g1, g2]; 616 | } 617 | 618 | _optionValue(obj, optionName, def) { 619 | const option = obj[optionName]; 620 | return typeof option === 'function' 621 | ? option(this) ?? def 622 | : option ?? def; 623 | } 624 | 625 | _bounce(obj) { 626 | 627 | obj.speed = this._optionValue(obj, 'speed'); 628 | let [g1, g2] = this._getForcesGroups(obj); 629 | 630 | // group1 is the grid 631 | if (g1 === this) { 632 | for (let cand of (this._overlappingBoundaryCandidateSet())) { 633 | if (g2.has(cand) && !cand.still) { 634 | const ba = this._bounceAcc(this, cand, obj); 635 | if (ba) { 636 | cand._force.add(ba.mult(cand.mass)); 637 | } 638 | } 639 | } 640 | } 641 | 642 | else { 643 | const usingOverlap = obj.proximity === 'overlap'; 644 | for (let a1 of g1) { 645 | const a1IsActor = a1.type === 'actor'; 646 | const a1AddForce = g1 !== g2 && a1IsActor && !a1.still && !obj.inward; 647 | for (let cand of a1._overlappingBoundaryCandidateSet()) { 648 | if (g2.has(cand)) { 649 | const aCentroidEnclosed = usingOverlap 650 | ? null 651 | : a1.isEnclosingCentroid(cand) || cand.isEnclosingCentroid(a1); 652 | if (usingOverlap 653 | ? a1.isOverlapping(cand) 654 | : obj.inward 655 | ? aCentroidEnclosed 656 | : !aCentroidEnclosed && a1.isOverlapping(cand) 657 | ) { 658 | if (obj.log || obj.logOnly) { 659 | a1.bounceLog.add(cand); 660 | cand.bounceLog.add(a1); 661 | this._bounceLogged.add(cand); 662 | this._bounceLogged.add(a1); 663 | } 664 | if (!obj.logOnly) { 665 | if (!cand.still) { 666 | const ba = this._bounceAcc(a1, cand, obj); 667 | if (ba) { 668 | cand._force.add(ba.mult(cand.mass)); 669 | } 670 | } 671 | if (a1AddForce) { 672 | const ba = this._bounceAcc(cand, a1, obj); 673 | if (ba) { 674 | a1._force.add(ba.mult(a1.mass)); 675 | } 676 | } 677 | } 678 | } 679 | } 680 | } 681 | } 682 | } 683 | 684 | } 685 | 686 | _bounceAcc(region, actor, obj) { 687 | 688 | const { speed } = obj; 689 | const inward = region === this ? 'true' : obj.inward; 690 | 691 | // region is grid, square or zone 692 | if (region._shape === 'rect') { 693 | 694 | const xOverlap = actor.x < region.x 695 | ? actor.x + actor.radius - region.xMin 696 | : region.xMax - (actor.x - actor.radius); 697 | const yOverlap = actor.y < region.y 698 | ? actor.y + actor.radius - region.yMin 699 | : region.yMax - (actor.y - actor.radius); 700 | if (xOverlap >= 2 * actor.radius && yOverlap >= 2 * actor.radius) { 701 | return; 702 | } 703 | 704 | // if outward, want to flip velocity on dim with least overlap; for 705 | // inward, want dim with greatest 'outerlap' (which is least overlap!) 706 | const dim = xOverlap >= yOverlap ? 'y' : 'x'; 707 | 708 | // no new force if inward and bounce would decrease alignment of velocity 709 | // vector with actor->region vector; opposite for outward 710 | if (actor.vel[dim] * (region[dim] - actor[dim]) * (inward ? 1 : -1) > 0) { 711 | return; 712 | } 713 | 714 | const u = actor.vel.copy(); 715 | u[dim] *= -1; 716 | return u.sub(actor.vel); 717 | 718 | } 719 | 720 | // region is actor; inward force 721 | else if (inward) { // region is an actor - i.e. circular 722 | const d = region.centroidDistance(actor); 723 | if (d <= region.radius && d >= region.radius - actor.radius) { 724 | const velHeading = actor.vel.heading(); 725 | const regionActorHeading = Math.atan2(actor.y - region.y, actor.x - region.x); 726 | const theta = velHeading - regionActorHeading; 727 | const diff = Math.abs(theta) % (2 * Math.PI); 728 | if (Math.min(diff, 2 * Math.PI - diff) >= Math.PI / 2) { 729 | return; 730 | } 731 | const u = actor.vel.copy().mult(-1); 732 | return u.setHeading(u.heading() - 2 * theta).sub(actor.vel); 733 | } 734 | } 735 | 736 | // region is actor; outward force 737 | else { 738 | 739 | // unit normal (i.e. region -> actor) 740 | let un_x = actor.x - region.x, 741 | un_y = actor.y - region.y, 742 | unMag = Math.sqrt(un_x ** 2 + un_y ** 2); 743 | un_x /= unMag; 744 | un_y /= unMag; 745 | 746 | // actor 'normal speed' (velocity projection onto normal) 747 | // - region normal speed (rns) computed below if region not still 748 | const ans = un_x * actor.vel.x + un_y * actor.vel.y; 749 | 750 | // actor 'tangent speed' (velocity projection onto tangent) 751 | const ats = -un_y * actor.vel.x + un_x * actor.vel.y; 752 | 753 | // new actor normal speed 754 | let new_ans; 755 | if (region.still) { 756 | new_ans = -ans; 757 | } 758 | else { 759 | const rns = un_x * region.vel.x + un_y * region.vel.y; 760 | new_ans = (ans * (actor.mass - region.mass) + (2 * region.mass * rns)) / 761 | (region.mass + actor.mass); 762 | } 763 | 764 | // desired velocity 765 | const dv = new Vector(un_x, un_y).mult(new_ans); 766 | dv.x += -un_y * ats; 767 | dv.y += un_x * ats; 768 | if (typeof speed === 'number') dv.setMag(speed); 769 | 770 | // if overlap after move then 'amplify the bounce' 771 | const d = region.centroidDistance(actor); 772 | const dNext = Math.sqrt((region.x - (actor.x + dv.x)) ** 2 + 773 | (region.y - (actor.y + dv.y)) ** 2); 774 | if (dNext < d) { 775 | const amp = d / dNext / 2; 776 | dv.x += un_x * amp; 777 | dv.y += un_y * amp; 778 | } 779 | 780 | // change in velocity - i.e. acceleration 781 | return dv.sub(actor.vel); 782 | } 783 | 784 | } 785 | 786 | _attract(obj) { 787 | this._attractRepelAvoid(obj); 788 | } 789 | 790 | _repel(obj) { 791 | this._attractRepelAvoid(obj); 792 | } 793 | 794 | _avoid(obj) { 795 | this._attractRepelAvoid(obj); 796 | } 797 | 798 | _attractRepelAvoid(obj) { 799 | 800 | obj.strength = this._optionValue(obj, 'strength', 1); 801 | obj.off = this._optionValue(obj, 'off', 30); 802 | obj.ease ??= 'easeLinear'; 803 | let [g1, g2] = this._getForcesGroups(obj); 804 | const isAvoid = obj.behavior === 'avoid'; 805 | if (isAvoid) { 806 | obj.inward = false; 807 | obj.proximity = 'overlap'; 808 | } 809 | else { 810 | obj.decay ??= true; 811 | } 812 | 813 | // group1 is the grid 814 | if (g1 === this) { 815 | if (isAvoid) { 816 | throw Error('cannot use avoid force with the simulation grid'); 817 | } 818 | for (let cand of this._insideNeighbors(obj.off)) { 819 | if (g2.has(cand) && !cand.still) { 820 | this._addAttractRepelForce(this, cand, obj); 821 | } 822 | } 823 | } 824 | 825 | else { 826 | const addForceFunc = isAvoid ? '_addAvoidForce' : '_addAttractRepelForce'; 827 | const agentsDone = g1 === g2 && !obj.inward ? new Set() : null; 828 | for (let a1 of g1) { 829 | const a1AddForce = !obj.inward && a1.type === 'actor' && !a1.still; 830 | const a1Next = isAvoid && a1AddForce && !obj.recover 831 | ? a1._estimateNextPosition() 832 | : null; 833 | const candidates = obj.inward 834 | ? a1._insideNeighbors(obj.off) 835 | : (obj.proximity === 'centroid') 836 | ? this._pointNeighbors(a1, obj.off) 837 | : a1.neighbors(obj.off, 'actor'); 838 | for (let cand of candidates || []) { 839 | if (g2.has(cand) && 840 | (a1AddForce || !cand.still) && 841 | (g1 !== g2 || !agentsDone?.has(cand))) { 842 | this[addForceFunc](a1, cand, obj, a1AddForce, a1Next); 843 | } 844 | } 845 | agentsDone?.add(a1); 846 | } 847 | } 848 | 849 | } 850 | 851 | _addAttractRepelForce(a1, a2, obj, a1AddForce) { 852 | const isInward = a1 === this || obj.inward; 853 | const d = isInward 854 | ? insideDistance(a2, a1) 855 | : a1[obj.proximity === 'centroid' ? 'centroidDistance' : 'distance'](a2); 856 | let multiplier = d / obj.off; 857 | if (obj.decay) { 858 | multiplier = 1 - multiplier; 859 | } 860 | multiplier = 861 | d3[obj.ease](multiplier) * (a1.mass ?? a2.mass) * a2.mass * obj.strength; 862 | if (isInward) { 863 | multiplier *= -1; 864 | } 865 | if (obj.behavior === 'repel') { 866 | multiplier *= -1; 867 | } 868 | let u = new Vector(a1.x - a2.x, a1.y - a2.y); 869 | if (u.x === 0 && u.y === 0) { 870 | if (multiplier < 0) { 871 | u = Vector.randomAngle(); 872 | } 873 | } 874 | else { 875 | u.normalize(); 876 | } 877 | if (!a2.still) { 878 | a2._force.add((a1AddForce ? u.copy() : u).mult(multiplier)); 879 | } 880 | if (a1AddForce) { 881 | a1._force.add(u.mult(-multiplier)); 882 | } 883 | } 884 | 885 | _addAvoidForce(a1, a2, obj, a1AddForce, a1Next) { 886 | const d = a1.distance(a2); 887 | if (!obj.recover && 888 | boundaryDistance(a1Next ?? a1, a2._estimateNextPosition()) > d) { 889 | return; 890 | } 891 | const multiplier = d3[obj.ease](1 - d / obj.off) * obj.strength * 892 | (a1.mass ?? a2.mass) * a2.mass; 893 | let u1, u2; 894 | let diffVec = new Vector(a1.x - a2.x, a1.y - a2.y); 895 | if (!a2.still) { 896 | if (diffVec.isZero()) { // cannot project onto zero vector 897 | u2 = Vector.randomAngle(); 898 | } 899 | else if (a2.vel.isZero()) { 900 | u2 = diffVec.getUnitNormal(); 901 | } 902 | else { 903 | u2 = a2.vel.vecRejec(diffVec); 904 | if (u2.isZero()) { // a2 velocity and diffVec aligned 905 | u2 = diffVec.getUnitNormal(); 906 | } 907 | else { 908 | u2.normalize(); 909 | } 910 | } 911 | a2._force.add(u2.mult(multiplier)); 912 | } 913 | if (a1AddForce) { 914 | diffVec.mult(-1); 915 | if (diffVec.isZero()) { // cannot project onto zero vector 916 | u1 = Vector.randomAngle(); 917 | } 918 | else if (a1.vel.isZero()) { 919 | u1 = diffVec.getUnitNormal(); 920 | } 921 | else { 922 | u1 = a1.vel.vecRejec(diffVec); 923 | if (u1.isZero()) { // a1 velocity and diffVec aligned 924 | u1 = diffVec.getUnitNormal(); 925 | } 926 | else { 927 | u1.normalize(); 928 | } 929 | } 930 | if (u2) { // flip u1 if angle between u1 and u2 is less then pi/2 931 | const uDiff = Math.abs(u1.heading() - u2.heading()); 932 | if (uDiff < Math.PI / 2 || uDiff > 3 * Math.Pi / 2) { 933 | u1.mult(-1); 934 | } 935 | } 936 | a1._force.add(u1.mult(multiplier)); 937 | } 938 | } 939 | 940 | _insideForce(obj) { 941 | 942 | let [g1, g2] = this._getForcesGroups(obj); 943 | const { proximity } = obj; 944 | let forceFunc; 945 | if (obj.behavior === 'drag') { 946 | const strength = this._optionValue(obj, 'strength', 1); 947 | forceFunc = (_, actor) => actor.vel.copy().mult(-strength * actor.mass); 948 | } 949 | else if (obj.behavior === 'custom') { 950 | forceFunc = typeof obj.force === 'function' ? obj.force : () => obj.force; 951 | } 952 | 953 | // group1 is the grid 954 | if (g1 === this) { 955 | for (let a2 of g2) { 956 | if (a2.overlappingGrid && !a2.still) { 957 | if (proximity === 'within') { 958 | if (!a2.isWithin(this)) continue; 959 | } 960 | else if (proximity !== 'overlap') { 961 | if (!a2.isCentroidWithin(this)) continue; 962 | } 963 | a2._force.add(forceFunc(null, a2)); 964 | } 965 | } 966 | } 967 | 968 | else { 969 | const methodName = proximity === 'overlap' 970 | ? 'overlapping' 971 | : (proximity === 'within' ? 'enclosing' : 'enclosingCentroid'); 972 | for (let a1 of g1) { 973 | for (let cand of a1[methodName]('actor') || []) { 974 | if (!cand.still && g2.has(cand)) { 975 | cand._force.add(forceFunc(a1, cand)); 976 | } 977 | } 978 | } 979 | } 980 | 981 | } 982 | 983 | _custom(obj) { 984 | this._insideForce(obj); 985 | } 986 | 987 | _drag(obj) { 988 | this._insideForce(obj); 989 | } 990 | 991 | _pointInGrid(p) { 992 | return p.x >= this.xMin && 993 | p.x <= this.xMax && 994 | p.y >= this.yMin && 995 | p.y <= this.yMax; 996 | } 997 | 998 | 999 | // ========== process polylines ========== 1000 | 1001 | // get a function that takes a point (i.e. an object with x and y properties) 1002 | // and returns information about the nearest point on the given polylines (an 1003 | // object with the same structure as returned by polyline.pointNearest, but 1004 | // with a line property added) or null if no polyline is close according to 1005 | // off 1006 | // - polylines argument can be a singe polyline or an iterable of polylines 1007 | // - off is distance above which do not consider point to be close 1008 | // - when using the returned function, the passed point must be in the grid 1009 | // (i.e. its x,y must be in the grid) or it must be an actor in the 1010 | // simulation that overlaps the grid 1011 | registerPolylines(polylines, off = Infinity) { 1012 | 1013 | if (!isIterable(polylines)) polylines = [polylines]; 1014 | 1015 | for (let line of polylines) { 1016 | for (let pt of line.pts) { 1017 | if (!this._pointInGrid(pt)) { 1018 | throw Error('polylines must lie within the simulation grid'); 1019 | } 1020 | } 1021 | } 1022 | 1023 | // squareSegs is an array-of-arrays (same shape as grid): for each square, 1024 | // squareSegs has an array of {line, segIndex, dist} objects (the segments 1025 | // that may contain the closest point to some point in the square) - the 1026 | // array is empty if no segments are close according to the value of off 1027 | const halfDiag = Math.sqrt(2 * this.gridStep ** 2) / 2; 1028 | const squareSegs = this._grid.squares.map(row => { 1029 | return row.map(sq => { 1030 | const candidateSegs = []; 1031 | let minDist = Infinity; 1032 | for (let line of polylines) { 1033 | for (let segIndex of line.segs.keys()) { 1034 | const dist = line._distanceFromSeg(sq, segIndex); 1035 | // if the segment does not overlap the square, dist - halfDiag is a 1036 | // lower bound on the min distance from any point in the square to 1037 | // the segment 1038 | if (dist - halfDiag <= off) { 1039 | minDist = Math.min(dist, minDist); 1040 | candidateSegs.push({line, segIndex, dist}); 1041 | } 1042 | } 1043 | } 1044 | // minDist + halfDiag is an upper bound on the distance from any point 1045 | // in the square to the segment corresponding to minDist 1046 | return candidateSegs.filter(obj => obj.dist <= minDist + halfDiag); 1047 | }); 1048 | }); 1049 | 1050 | // return a function 1051 | return p => { 1052 | let sq; 1053 | if (this._pointInGrid(p)) { 1054 | sq = this.squareOf(p.x, p.y); 1055 | } 1056 | else if (this.actors.has(p) && p.overlappingGrid) { 1057 | let minDistToSq = Infinity; 1058 | for (let s of p.squares) { 1059 | const dist = p.centroidDistance(s); 1060 | if (dist < minDistToSq) { 1061 | minDistToSq = dist; 1062 | sq = s; 1063 | } 1064 | } 1065 | } 1066 | else { 1067 | return null; 1068 | } 1069 | const candidateSegs = squareSegs[sq.yIndex][sq.xIndex]; 1070 | if (candidateSegs.length === 0) return null; 1071 | let minDist = Infinity; 1072 | let best; 1073 | for (let {line, segIndex} of candidateSegs) { 1074 | const pn = line._pointNearestOnSeg(p, segIndex); 1075 | if (pn.dist < minDist) { 1076 | minDist = pn.dist; 1077 | pn.line = line; 1078 | pn.segIndex = segIndex; 1079 | best = pn; 1080 | } 1081 | } 1082 | if (minDist > off) return null; 1083 | return best; 1084 | }; 1085 | 1086 | } 1087 | 1088 | 1089 | // ========== shortest paths ========== 1090 | 1091 | // given square or zone, or iterable of squares/zones ... (nested to any 1092 | // depth), return a unique set of the included squares 1093 | _uniqueSquares(squares) { 1094 | const updateUniqueSquares = (s, result) => { 1095 | if (s.type === 'square') { 1096 | result.add(s); 1097 | } 1098 | else if (s.type === 'zone') { 1099 | if (!this.zones.has(s)) { 1100 | throw Error('zone does not belong to this simulation'); 1101 | } 1102 | for (let sq of s.squares) { 1103 | result.add(sq); 1104 | } 1105 | } 1106 | else { 1107 | for (let t of s) { 1108 | updateUniqueSquares(t, result); 1109 | } 1110 | } 1111 | return result; 1112 | }; 1113 | return updateUniqueSquares(squares, new Set()); 1114 | } 1115 | 1116 | // partition an iterable of squares into an xset of xsets; each inner xset is 1117 | // a rectangle of squares. 1118 | _splitIntoRects(s) { 1119 | 1120 | // convert s to a new array of squares ordered by their index property 1121 | s = [...s].sort((a, b) => a.index - b.index); 1122 | 1123 | // merge squares horizontally - each rect is 1 square high and is an object 1124 | // with properties: xMinIndex, xMaxIndex, yMinIndex, yMaxIndex 1125 | const rects = new Map(); // each key is the yMinIndex of rects in that row 1126 | { 1127 | let rect; 1128 | const newRect = sq => { 1129 | rect = { 1130 | xMinIndex: sq.xIndex, 1131 | xMaxIndex: sq.xIndex, 1132 | yMinIndex: sq.yIndex, 1133 | yMaxIndex: sq.yIndex 1134 | }; 1135 | }; 1136 | const addRect = () => rects.has(rect.yMinIndex) 1137 | ? rects.get(rect.yMinIndex).add(rect) 1138 | : rects.set(rect.yMinIndex, new Set([rect])); 1139 | for (let sq of s) { 1140 | if (rect) { 1141 | if (sq.xIndex === rect.xMaxIndex + 1 && sq.yIndex === rect.yMinIndex) { 1142 | rect.xMaxIndex++; 1143 | } 1144 | else { 1145 | addRect(); 1146 | newRect(sq); 1147 | } 1148 | } 1149 | else { 1150 | newRect(sq); 1151 | } 1152 | } 1153 | if (rect) { 1154 | addRect(); 1155 | } 1156 | } 1157 | 1158 | // merge rectangles vertically 1159 | const ny = this.ny; 1160 | for (let i = 0; i < ny - 1; i++) { // all but last row 1161 | for (let top of rects.get(i) || []) { 1162 | for (let btm of rects.get(i + 1) || []) { 1163 | if (top.xMinIndex === btm.xMinIndex) { 1164 | if (top.xMaxIndex === btm.xMaxIndex) { 1165 | btm.yMinIndex = top.yMinIndex; 1166 | rects.get(i).delete(top); 1167 | } 1168 | break; 1169 | } 1170 | else if (top.xMinIndex < btm.xMinIndex) { 1171 | break; 1172 | } 1173 | } 1174 | } 1175 | } 1176 | 1177 | // set of squares for each rect 1178 | const rectSquares = new XSet(); 1179 | for (let row of rects.values()) { 1180 | for (let rect of row) { 1181 | rectSquares.add(new XSet(this.squaresInRect([ 1182 | rect.xMinIndex, 1183 | rect.xMaxIndex, 1184 | rect.yMinIndex, 1185 | rect.yMaxIndex 1186 | ]))); 1187 | } 1188 | } 1189 | 1190 | return rectSquares; 1191 | 1192 | } 1193 | 1194 | // returns a map with the same keys as destsArg; each value is a map: 1195 | // each key is a square; the value is the next point (vector) to steer to - 1196 | // or undefined for destination squares and squares which cannot reach 1197 | // destination squares. 1198 | paths(destsArg, costsArg = [], taxicab) { 1199 | 1200 | // costs and cost sets 1201 | const costs = new Map(); 1202 | for (let sq of this.squares) { 1203 | costs.set(sq, 0); 1204 | } 1205 | const costSets = new Map(); 1206 | for (let [s, v] of costsArg) { 1207 | const cs = this._uniqueSquares(s); 1208 | for (let sq of cs) { 1209 | costs.set(sq, costs.get(sq) + v); 1210 | } 1211 | if (!taxicab) { 1212 | if (s.type === 'square' || s.type === 'zone') { 1213 | costSets.set(cs, v); 1214 | } 1215 | else { 1216 | for (let r of this._splitIntoRects(cs)) { 1217 | costSets.set(r, v); 1218 | } 1219 | } 1220 | } 1221 | } 1222 | 1223 | // if not using taxicab: 1224 | // - check no square in more than one cost set 1225 | // - add rectangles to cover squares not in any cost set 1226 | if (!taxicab) { 1227 | const allCostSquares = new XSet(); 1228 | let sizeSum = 0; 1229 | for (let cs of costSets.keys()) { 1230 | allCostSquares.adds(cs); 1231 | sizeSum += cs.size; 1232 | } 1233 | if (allCostSquares.size < sizeSum) { 1234 | throw Error( 1235 | 'cost sets can only have common squares if using taxicab option'); 1236 | } 1237 | if (allCostSquares.size < this.squares.size) { 1238 | for (let r of 1239 | this._splitIntoRects(this.squares.difference(allCostSquares))) { 1240 | costSets.set(r, 0); 1241 | } 1242 | } 1243 | } 1244 | 1245 | // Floyd-Warshall - setup 1246 | const dists = new Map(); 1247 | const next = new Map(); 1248 | for (let sq1 of this.squares) { 1249 | const distsRow = new Map(); 1250 | const nextRow = new Map(); 1251 | dists.set(sq1, distsRow); 1252 | next.set(sq1, nextRow); 1253 | for (let sq2 of this.squares) { 1254 | if (sq1 === sq2) { 1255 | distsRow.set(sq2, 0); 1256 | nextRow.set(sq2, sq2); 1257 | } 1258 | else { 1259 | distsRow.set(sq2, Infinity); 1260 | } 1261 | } 1262 | } 1263 | const { nx, ny, squares: gridSquares, step: gridStep } = this._grid; 1264 | function useEdgeWeight(sq1, sq2) { 1265 | dists.get(sq1).set(sq2, gridStep + costs.get(sq2)); 1266 | dists.get(sq2).set(sq1, gridStep + costs.get(sq1)); 1267 | next.get(sq1).set(sq2, sq2); 1268 | next.get(sq2).set(sq1, sq1); 1269 | } 1270 | for (let i = 0; i < ny; i++) { 1271 | for (let j = 0; j < nx; j++) { 1272 | if (i < ny - 1) { 1273 | useEdgeWeight(gridSquares[i][j], gridSquares[i + 1][j]); // square below 1274 | } 1275 | if (j < nx - 1) { 1276 | useEdgeWeight(gridSquares[i][j], gridSquares[i][j + 1]); // square to right 1277 | } 1278 | } 1279 | } 1280 | 1281 | // add diagonal edges 1282 | // (do not add longer horizontal/vertical edges since leads to less natural 1283 | // trajectories for long horiz/vert paths with turn at start/end) 1284 | if (!taxicab) { 1285 | for (let [cs, v] of costSets) { 1286 | const csn = cs.size; 1287 | if (csn === 1) { 1288 | continue; 1289 | } 1290 | cs = [...cs]; 1291 | for (let i = 0; i < csn; i++) { 1292 | const sq1 = cs[i]; 1293 | for (let j = i + 1; j < csn; j++) { 1294 | const sq2 = cs[j]; 1295 | if (sq1.x !== sq2.x && sq1.y !== sq2.y) { 1296 | const w = Math.sqrt((sq1.xIndex - sq2.xIndex) ** 2 + 1297 | (sq1.yIndex - sq2.yIndex) ** 2) 1298 | * (gridStep + v); 1299 | dists.get(sq1).set(sq2, w); 1300 | dists.get(sq2).set(sq1, w); 1301 | next.get(sq1).set(sq2, sq2); 1302 | next.get(sq2).set(sq1, sq1); 1303 | } 1304 | } 1305 | } 1306 | } 1307 | } 1308 | 1309 | // Floyd-Warshall - compute shortest paths 1310 | const finiteCostSquares = 1311 | this.squares.filter(sq => costs.get(sq) < Infinity, 'array'); 1312 | for (let sq3 of finiteCostSquares) { 1313 | const distsRow_sq3 = dists.get(sq3); 1314 | for (let sq1 of this.squares) { 1315 | if (sq1 === sq3) { 1316 | continue; 1317 | } 1318 | const distsRow_sq1 = dists.get(sq1); 1319 | const nextRow_sq1 = next.get(sq1); 1320 | for (let sq2 of finiteCostSquares) { 1321 | if (sq1 === sq2 || sq2 === sq3) { 1322 | continue; 1323 | } 1324 | const dNew = distsRow_sq1.get(sq3) + distsRow_sq3.get(sq2); 1325 | if (dNew < distsRow_sq1.get(sq2)) { 1326 | distsRow_sq1.set(sq2, dNew); 1327 | nextRow_sq1.set(sq2, nextRow_sq1.get(sq3)); 1328 | } 1329 | } 1330 | } 1331 | } 1332 | 1333 | // Floyd-Warshall - next points 1334 | const results = new Map(); 1335 | for (const [destName, destCollection] of destsArg) { 1336 | 1337 | // destination squares 1338 | const destSquares = this._uniqueSquares(destCollection); 1339 | 1340 | // next square of every square (except destination squares and squares 1341 | // with no finite route to a destination square) 1342 | const nextSquares = new Map(); 1343 | for (let sq of this.squares) { 1344 | if (!destSquares.has(sq)) { 1345 | let d = Infinity; 1346 | let targetSq; 1347 | for (let destSq of destSquares) { 1348 | const dNew = dists.get(sq).get(destSq); 1349 | if (dNew < d) { 1350 | d = dNew; 1351 | targetSq = destSq; 1352 | } 1353 | } 1354 | if (targetSq) { 1355 | nextSquares.set(sq, next.get(sq).get(targetSq)); 1356 | } 1357 | } 1358 | } 1359 | 1360 | // next point (vector) to aim at for each square with a next square - 1361 | // uses the next square's next square! 1362 | const nextPoints = new Map(); 1363 | for (let [sq, nextSq] of nextSquares) { 1364 | const nextNextSq = nextSquares.get(nextSq); 1365 | nextPoints.set(sq, 1366 | !nextNextSq 1367 | ? Vector.fromObject(nextSq) 1368 | : taxicab 1369 | ? Vector.fromObject(nextSq).add(nextNextSq).div(2) 1370 | : Vector.fromObject(nextNextSq).sub(nextSq) 1371 | .setMag(gridStep / 2) 1372 | .add(nextSq) 1373 | ); 1374 | } 1375 | results.set(destName, nextPoints); 1376 | 1377 | } 1378 | 1379 | return results; 1380 | 1381 | } 1382 | 1383 | // returns an object of functions: 1384 | // - cost(sq1, sq2): cost of optimal path from sq1 to sq2 1385 | // - next(sq1, sq2): next square on optimal path from sq1 to sq2 1386 | // - route(sq1, sq2): optimal route from sq1 to sq2 (an array of squares) 1387 | // - best(sources, targets): sources and targets are flattened to squares 1388 | // using _uniqueSquares; best returns a map: each key is a source square, 1389 | // the value is an object {target, cost, next} specifying the min cost to a 1390 | // target square, the target square, and the next square of the optimal path 1391 | routes({ 1392 | // square cost (for entering square): number or function (f(sq) -> cost) 1393 | squareCost = 0, 1394 | // edge cost: number or function (f(sq1, sq2) -> cost) 1395 | edgeCost = 0, 1396 | // edges: false, true, 4 (neighbors) or 8 (neighbors); if edges is false 1397 | // and extraEdges is empty, there are no paths! 1398 | edges = false, 1399 | // additional edges: each element should be [sqs1, sqs2, cost]; each of 1400 | // sqs1 and sqs2 is flattened with _uniqueSquares and edges are added for 1401 | // all sqs1->sqs2 pairs of squares; cost should be a number or a function 1402 | // (f(sq1, sq2) -> cost); use [sqs1, sqs2, cost, true] to also add edges 1403 | // for sqs2->sqs1 pairs; if a given edge has already been set from edges, 1404 | // extraEdges overwrites it (as long as the new edge cost is finite) 1405 | extraEdges = [] 1406 | }) { 1407 | 1408 | // cost functions 1409 | const getSquareCost = typeof squareCost === 'function' 1410 | ? squareCost 1411 | : () => squareCost; 1412 | const getEdgeCost = typeof edgeCost === 'function' 1413 | ? edgeCost 1414 | : () => edgeCost; 1415 | 1416 | // Floyd-Warshall - setup 1417 | const squareCosts = new Map(); 1418 | const edgeCosts = new Map(); 1419 | const next = new Map(); 1420 | for (let sq1 of this.squares) { 1421 | squareCosts.set(sq1, getSquareCost(sq1)); 1422 | const edgeCostsRow = new Map(); 1423 | const nextRow = new Map(); 1424 | edgeCosts.set(sq1, edgeCostsRow); 1425 | next.set(sq1, nextRow); 1426 | for (let sq2 of this.squares) { 1427 | if (sq1 === sq2) { 1428 | edgeCostsRow.set(sq2, 0); 1429 | nextRow.set(sq2, sq2); 1430 | } 1431 | else { 1432 | edgeCostsRow.set(sq2, Infinity); 1433 | } 1434 | } 1435 | } 1436 | const { nx, ny, squares: gridSquares } = this._grid; 1437 | function setEdgeCost(sq1, sq2, f = getEdgeCost) { 1438 | const c = f(sq1, sq2) + squareCosts.get(sq2); 1439 | if (Number.isFinite(c)) { 1440 | edgeCosts.get(sq1).set(sq2, c); 1441 | next.get(sq1).set(sq2, sq2); 1442 | } 1443 | } 1444 | function addEdge(sq1, sq2, addReverseEdge) { 1445 | setEdgeCost(sq1, sq2); 1446 | if (addReverseEdge) { 1447 | setEdgeCost(sq2, sq1); 1448 | } 1449 | } 1450 | if (edges === true) { 1451 | for (let sq1 of this.squares) { 1452 | for (let sq2 of this.squares) { 1453 | if (sq1 !== sq2) { 1454 | addEdge(sq1, sq2); 1455 | } 1456 | } 1457 | } 1458 | } 1459 | else if (edges === 4 || edges === 8) { 1460 | for (let i = 0; i < ny; i++) { 1461 | for (let j = 0; j < nx; j++) { 1462 | if (i < ny - 1) { // squares below 1463 | addEdge(gridSquares[i][j], gridSquares[i + 1][j], true); 1464 | if (edges === 8) { 1465 | if (j > 0) { // square below-left 1466 | addEdge(gridSquares[i][j], gridSquares[i + 1][j - 1], true); 1467 | } 1468 | if (j < nx - 1) { // square below-right 1469 | addEdge(gridSquares[i][j], gridSquares[i + 1][j + 1], true); 1470 | } 1471 | } 1472 | } 1473 | if (j < nx - 1) { // square to right 1474 | addEdge(gridSquares[i][j], gridSquares[i][j + 1], true); 1475 | } 1476 | } 1477 | } 1478 | } 1479 | for (let [sqs1, sqs2, cost, addReverseEdge] of extraEdges) { 1480 | sqs1 = this._uniqueSquares(sqs1); 1481 | sqs2 = this._uniqueSquares(sqs2); 1482 | const f = typeof cost === 'function' ? cost: () => cost; 1483 | for (let sq1 of sqs1) { 1484 | for (let sq2 of sqs2) { 1485 | if (sq1 !== sq2) { 1486 | setEdgeCost(sq1, sq2, f); 1487 | if (addReverseEdge) { 1488 | setEdgeCost(sq2, sq1, f); 1489 | } 1490 | } 1491 | } 1492 | } 1493 | } 1494 | 1495 | // Floyd-Warshall - compute shortest paths 1496 | const finiteCostSquares = 1497 | this.squares.filter(sq => squareCosts.get(sq) < Infinity, 'array'); 1498 | for (let sq3 of finiteCostSquares) { 1499 | const edgeCostsRow_sq3 = edgeCosts.get(sq3); 1500 | for (let sq1 of this.squares) { 1501 | if (sq1 === sq3) { 1502 | continue; 1503 | } 1504 | const edgeCostsRow_sq1 = edgeCosts.get(sq1); 1505 | const nextRow_sq1 = next.get(sq1); 1506 | for (let sq2 of finiteCostSquares) { 1507 | if (sq1 === sq2 || sq2 === sq3) { 1508 | continue; 1509 | } 1510 | const eNew = edgeCostsRow_sq1.get(sq3) + edgeCostsRow_sq3.get(sq2); 1511 | if (eNew < edgeCostsRow_sq1.get(sq2)) { 1512 | edgeCostsRow_sq1.set(sq2, eNew); 1513 | nextRow_sq1.set(sq2, nextRow_sq1.get(sq3)); 1514 | } 1515 | } 1516 | } 1517 | } 1518 | 1519 | // return object 1520 | return { 1521 | 1522 | cost(sq1, sq2) { 1523 | return edgeCosts.get(sq1).get(sq2); 1524 | }, 1525 | 1526 | next(sq1, sq2) { 1527 | return next.get(sq1).get(sq2) ?? null; 1528 | }, 1529 | 1530 | route(sq1, sq2) { 1531 | if (next.get(sq1).get(sq2) === undefined) { 1532 | return null; 1533 | } 1534 | let s = sq1; 1535 | const route = [s]; 1536 | while(s !== sq2) { 1537 | s = next.get(s).get(sq2); 1538 | route.push(s); 1539 | } 1540 | return route; 1541 | }, 1542 | 1543 | best: (sources, targets) => { 1544 | sources = this._uniqueSquares(sources); 1545 | targets = this._uniqueSquares(targets); 1546 | const bestMap = new Map(); 1547 | for (let source of sources) { 1548 | const edgeCostsRow = edgeCosts.get(source); 1549 | let minCost = Infinity; 1550 | let bestTarget = null; 1551 | for (let target of targets) { 1552 | if (edgeCostsRow.get(target) < minCost) { 1553 | minCost = edgeCostsRow.get(target); 1554 | bestTarget = target; 1555 | } 1556 | } 1557 | bestMap.set(source, { 1558 | cost: minCost, 1559 | target: bestTarget, 1560 | next: bestTarget ? next.get(source).get(bestTarget) : null 1561 | }); 1562 | } 1563 | return bestMap; 1564 | }, 1565 | 1566 | } 1567 | 1568 | } 1569 | 1570 | } --------------------------------------------------------------------------------