cacatoo
Version:
Building, exploring, and sharing spatially structured models
1,113 lines (946 loc) • 45.4 kB
JavaScript
import Gridpoint from "./gridpoint.js"
import Graph from './graph.js'
import QuadTree from './quadtree.js'
import * as utility from './utility.js'
/**
* Flockmodel is the second modeltype in Cacatoo, which uses Boids that can interact with a @Gridmodel
*/
class Flockmodel {
/**
* The constructor function for a @Flockmodl object. Takes the same config dictionary as used in @Simulation
* @param {string} name The name of your model. This is how it will be listed in @Simulation 's properties
* @param {dictionary} config A dictionary (object) with all the necessary settings to setup a Cacatoo GridModel.
* @param {MersenneTwister} rng A random number generator (MersenneTwister object)
*/
constructor(name, config={}, rng) {
this.name = name
this.config = config
this.time = 0
this.draw = true
this.max_force = config.max_force || 1
this.max_speed = config.max_speed || 1
this.width = config.width || config.ncol ||600
this.height = config.height ||config.nrow || 600
this.scale = config.scale || 1
this.shape = config.shape || 'dot'
this.click = config.click || 'none'
this.follow_mouse = config.follow_mouse
this.init_velocity = config.init_velocity || 0.1
this.rng = rng
this.random = () => { return this.rng.random()}
this.randomInt = (a,b) => { return this.rng.randomInt(a,b)}
this.wrap = config.wrap || [true, true]
this.wrapreflect = 1
if(config.wrapreflect) this.wrapreflect = config.wrapreflect
this.graph_update = config.graph_update || 20
this.graph_interval = config.graph_interval || 2
this.bgcolour = config.bgcolour || undefined
this.physics = (config.physics === false) ? false : true;
this.statecolours = {}
if(config.statecolours) this.statecolours = this.setupColours(config.statecolours,config.num_colours||100) // Makes sure the statecolours in the config dict are parsed (see below)
if(!config.qt_capacity) config.qt_capacity = 3
this.graphs = {} // Object containing all graphs belonging to this model (HTML usage only)
this.canvases = {} // Object containing all Canvases belonging to this model (HTML usage only)
// Flocking stuff
let radius_alignment = this.config.alignment ? this.config.alignment.radius : 0
let radius_cohesion = this.config.cohesion ? this.config.cohesion.radius : 0
let radius_separation = this.config.separation ? this.config.separation.radius : 0
this.neighbourhood_radius = Math.max(radius_alignment,radius_cohesion,radius_separation)
this.friction = this.config.friction
this.mouse_radius = this.config.mouse_radius || 100
this.mousecoords = {x:-1000,y:-1000}
this.boids = []
this.mouseboids = []
this.obstacles = []
this.populateSpot()
this.build_quadtree()
}
build_quadtree(){
let boundary = { x: this.width/2, y: this.height/2, w: this.width, h: this.height }
this.qt = new QuadTree(boundary, this.config.qt_capacity)
for (let boid of this.boids) {
this.qt.insert(boid)
}
}
/**
* Populates the space with individuals in a certain radius from the center
*/
populateSpot(num,put_x,put_y,s){
let n = num || this.config.num_boids
let size = s || this.width/2
let x = put_x || this.width/2
let y = put_y || this.height/2
for (let i = 0; i < n; i++) {
// Random direction
let angle = this.random() * 2 * Math.PI;
// Unit direction vector
let ux = Math.cos(angle);
let uy = Math.sin(angle);
// Exact initial speed (constant for all boids)
let speed = this.init_velocity;
this.boids.push({
position: {
x: x + size - 2 * this.random() * size,
y: y + size - 2 * this.random() * size
},
velocity: {
x: ux * speed,
y: uy * speed
},
acceleration: { x: 0, y: 0 },
size: this.config.size
});
}
}
copyBoid(boid){
return utility.copy(boid)
}
/** TODO
* Saves the current flock a JSON object
* @param {string} filename The name of of the JSON file
*/
save_flock(filename)
{
}
/**
* Reads a JSON file and loads a JSON object onto this flockmodel. Reading a local JSON file will not work in browser.
* Gridmodels 'addCheckpointButton' instead, which may be implemented for flocks at a later stage.
* @param {string} file Path to the json file
*/
load_flock(file)
{
}
/** Initiate a dictionary with colour arrays [R,G,B] used by Graph and Canvas classes
* @param {statecols} object - given object can be in two forms
* | either {state:colour} tuple (e.g. 'alive':'white', see gol.html)
* | or {state:object} where objects are {val:'colour},
* | e.g. {'species':{0:"black", 1:"#DDDDDD", 2:"red"}}, see cheater.html
*/
setupColours(statecols,num_colours=18) {
let return_dict = {}
if (statecols == null){ // If the user did not define statecols (yet)
return return_dict["state"] = utility.default_colours(num_colours)
}
let colours = utility.dict_reverse(statecols) || { 'val': 1 }
for (const [statekey, statedict] of Object.entries(colours)) {
if (statedict == 'default') {
return_dict[statekey] = utility.default_colours(num_colours+1)
}
else if (statedict == 'random') {
return_dict[statekey] = utility.random_colours(num_colours+1,this.rng)
}
else if (statedict == 'viridis') {
let colours = this.colourGradientArray(num_colours, 0,[68, 1, 84], [59, 82, 139], [33, 144, 140], [93, 201, 99], [253, 231, 37])
return_dict[statekey] = colours
}
else if (statedict == 'inferno') {
let colours = this.colourGradientArray(num_colours, 0,[20, 11, 52], [132, 32, 107], [229, 92, 45], [246, 215, 70])
return_dict[statekey] = colours
}
else if (statedict == 'rainbow') {
let colours = this.colourGradientArray(num_colours, 0,[251, 169, 73], [250, 228, 66], [139, 212, 72], [42, 168, 242],
[50,100,255])
return_dict[statekey] = colours
}
else if (statedict == 'pride') {
let colours = this.colourGradientArray(num_colours, 0,[228, 3, 3], [255, 140, 0], [255, 237, 0], [0, 128, 38], [0,76,255],[115,41,130])
return_dict[statekey] = colours
}
else if (statedict == 'inferno_rev') {
let colours = this.colourGradientArray(num_colours, 0, [246, 215, 70], [229, 92, 45], [132, 32, 107])
return_dict[statekey] = colours
}
else if (typeof statedict === 'string' || statedict instanceof String) // For if
{
return_dict[statekey] = utility.stringToRGB(statedict)
}
else {
let c = {}
for (const [key, val] of Object.entries(statedict)) {
if (Array.isArray(val)) c[key] = val
else c[key] = utility.stringToRGB(val)
}
return_dict[statekey] = c
}
}
return return_dict
}
/** Initiate a gradient of colours for a property (return array only)
* @param {string} property The name of the property to which the colour is assigned
* @param {int} n How many colours the gradient consists off
* For example usage, see colourViridis below
*/
colourGradientArray(n,total)
{
let color_dict = {}
//color_dict[0] = [0, 0, 0]
let n_arrays = arguments.length - 2
if (n_arrays <= 1) throw new Error("colourGradient needs at least 2 arrays")
let segment_len = Math.ceil(n / (n_arrays-1))
if(n <= 10 && n_arrays > 3) console.warn("Cacatoo warning: forming a complex gradient with only few colours... hoping for the best.")
let total_added_colours = 0
for (let arr = 0; arr < n_arrays - 1 ; arr++) {
let arr1 = arguments[2 + arr]
let arr2 = arguments[2 + arr + 1]
for (let i = 0; i < segment_len; i++) {
let r, g, b
if (arr2[0] > arr1[0]) r = Math.floor(arr1[0] + (arr2[0] - arr1[0])*( i / (segment_len-1) ))
else r = Math.floor(arr1[0] - (arr1[0] - arr2[0]) * (i / (segment_len-1)))
if (arr2[1] > arr1[1]) g = Math.floor(arr1[1] + (arr2[1] - arr1[1]) * (i / (segment_len - 1)))
else g = Math.floor(arr1[1] - (arr1[1] - arr2[1]) * (i / (segment_len - 1)))
if (arr2[2] > arr1[2]) b = Math.floor(arr1[2] + (arr2[2] - arr1[2]) * (i / (segment_len - 1)))
else b = Math.floor(arr1[2] - (arr1[2] - arr2[2]) * (i / (segment_len - 1)))
color_dict[Math.floor(i + arr * segment_len + total)+1] = [Math.min(r,255), Math.min(g,255), Math.min(b,255)]
total_added_colours++
if(total_added_colours == n) break
}
}
return(color_dict)
}
/** Initiate a gradient of colours for a property.
* @param {string} property The name of the property to which the colour is assigned
* @param {int} n How many colours the gradient consists off
* For example usage, see colourViridis below
*/
colourGradient(property, n) {
let offset = 2
let n_arrays = arguments.length - offset
if (n_arrays <= 1) throw new Error("colourGradient needs at least 2 arrays")
let color_dict = {}
let total = 0
if(this.statecolours !== undefined && this.statecolours[property] !== undefined){
color_dict = this.statecolours[property]
total = Object.keys(this.statecolours[property]).length
}
let all_arrays = []
for (let arr = 0; arr < n_arrays ; arr++) all_arrays.push(arguments[offset + arr])
let new_dict = this.colourGradientArray(n,total,...all_arrays)
this.statecolours[property] = {...color_dict,...new_dict}
}
/** Initiate a gradient of colours for a property, using the Viridis colour scheme (purpleblue-ish to green to yellow) or Inferno (black to orange to yellow)
* @param {string} property The name of the property to which the colour is assigned
* @param {int} n How many colours the gradient consists off
* @param {bool} rev Reverse the viridis colour gradient
*/
colourViridis(property, n, rev = false, option="viridis") {
if(option=="viridis"){
if (!rev) this.colourGradient(property, n, [68, 1, 84], [59, 82, 139], [33, 144, 140], [93, 201, 99], [253, 231, 37]) // Viridis
else this.colourGradient(property, n, [253, 231, 37], [93, 201, 99], [33, 144, 140], [59, 82, 139], [68, 1, 84]) // Viridis
}
else if(option=="inferno"){
if (!rev) this.colourGradient(property, n, [20, 11, 52], [132, 32, 107], [229, 92, 45], [246, 215, 70]) // Inferno
else this.colourGradient(property, n, [246, 215, 70], [229, 92, 45], [132, 32, 107], [20, 11, 52]) // Inferno
}
}
/** Flocking of individuals, based on X, Y, Z (TODO)
* @param {Object} i The individual to be updates
*/
flock(){
if(this.physics) this.applyPhysics()
this.updatePositions()
this.boundariesAndObstacles()
this.build_quadtree()
}
calculateAlignment(boid, neighbours, max_speed) {
let steering = { x: 0, y: 0 }
if (neighbours.length > 0) {
for (let neighbour of neighbours) {
steering.x += neighbour.velocity.x;
steering.y += neighbour.velocity.y;
}
steering.x /= neighbours.length;
steering.y /= neighbours.length;
steering = this.normaliseVector(steering);
steering.x *= max_speed;
steering.y *= max_speed;
steering.x -= boid.velocity.x;
steering.y -= boid.velocity.y;
}
return steering;
}
calculateSeparation(boid, neighbours, max_speed) {
let steering = { x: 0, y: 0 };
if (neighbours.length > 0) {
for (let neighbour of neighbours) {
let dx = boid.position.x - neighbour.position.x;
let dy = boid.position.y - neighbour.position.y;
// Adjust for wrapping in the x direction
if (Math.abs(dx) > this.width / 2) {
dx = dx - Math.sign(dx) * this.width;
}
// Adjust for wrapping in the y direction
if (Math.abs(dy) > this.height / 2) {
dy = dy - Math.sign(dy) * this.height;
}
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.config.separation.radius) {
let difference = { x: dx, y: dy };
difference = this.normaliseVector(difference);
steering.x += difference.x ;
steering.y += difference.y ;
}
}
if (steering.x !== 0 || steering.y !== 0) {
steering.x /= neighbours.length;
steering.y /= neighbours.length;
steering = this.normaliseVector(steering);
steering.x *= max_speed;
steering.y *= max_speed;
steering.x -= boid.velocity.x;
steering.y -= boid.velocity.y;
}
}
return steering;
}
calculateCohesion(boid, neighbours, max_speed) {
let steering = { x: 0, y: 0 };
if (neighbours.length > 0) {
let centerOfMass = { x: 0, y: 0 };
for (let neighbour of neighbours) {
let dx = neighbour.position.x - boid.position.x;
let dy = neighbour.position.y - boid.position.y;
// Adjust for wrapping in the x direction
if (Math.abs(dx) > this.width / 2) {
dx = dx - Math.sign(dx) * this.width;
}
// Adjust for wrapping in the y direction
if (Math.abs(dy) > this.height / 2) {
dy = dy - Math.sign(dy) * this.height;
}
centerOfMass.x += boid.position.x + dx;
centerOfMass.y += boid.position.y + dy;
}
centerOfMass.x /= neighbours.length;
centerOfMass.y /= neighbours.length;
steering.x = centerOfMass.x - boid.position.x;
steering.y = centerOfMass.y - boid.position.y;
steering = this.normaliseVector(steering);
steering.x *= max_speed;
steering.y *= max_speed;
steering.x -= boid.velocity.x;
steering.y -= boid.velocity.y;
}
return steering;
}
calculateCollision(boid, neighbours,max_force) {
let steering = { x: 0, y: 0 };
if (neighbours.length > 0) {
for (let neighbour of neighbours) {
if(neighbour == boid) continue
if(boid.ignore && boid.ignore.includes(neighbour)) continue
let dx = boid.position.x - neighbour.position.x;
let dy = boid.position.y - neighbour.position.y;
// Adjust for wrapping in the x direction
if (Math.abs(dx) > this.width / 2) {
dx = dx - Math.sign(dx) * this.width;
}
// Adjust for wrapping in the y direction
if (Math.abs(dy) > this.height / 2) {
dy = dy - Math.sign(dy) * this.height;
}
let difference = { x: dx, y: dy };
difference = this.normaliseVector(difference);
steering.x += difference.x
steering.y += difference.y
}
if (steering.x !== 0 || steering.y !== 0) {
steering.x /= neighbours.length;
steering.y /= neighbours.length;
steering = this.normaliseVector(steering);
steering.x *= max_force;
steering.y *= max_force;
steering.x -= boid.velocity.x;
steering.y -= boid.velocity.y;
}
}
boid.overlapping = neighbours.length>1
return steering;
}
followMouse(boid){
if(this.mousecoords.x == -1000) return
let dx = boid.position.x - this.mousecoords.x;
let dy = boid.position.y - this.mousecoords.y;
let distance = Math.sqrt(dx*dx + dy*dy);
if (distance > 0) { // Ensure we don't divide by zero
boid.velocity.x += (dx / distance) * this.config.mouseattraction * this.max_force * -1;
boid.velocity.y += (dy / distance) * this.config.mouseattraction * this.max_force * -1;
}
}
steerTowards(boid,x,y,strength){
let dx = boid.position.x - x;
let dy = boid.position.y - y;
let distance = Math.sqrt(dx*dx + dy*dy);
if (distance > 0) { // Ensure we don't divide by zero
boid.velocity.x += (dx / distance) * strength * this.max_force * -1;
boid.velocity.y += (dy / distance) * strength * this.max_force * -1;
}
}
dist(obj1,obj2){
let dx = obj1.x - obj2.x
let dy = obj1.y - obj2.y
return(Math.sqrt(dx*dx + dy*dy))
}
updatePositions(){
for(let i = 0; i<this.boids.length; i++){
let boid = this.boids[i]
let max_speed = this.max_speed
let max_force = this.max_force
let brownian = this.config.brownian ?? 0.0
let friction = this.friction
if(boid.locked) continue
if(boid.max_speed !== undefined) max_speed = boid.max_speed
if(boid.max_force !== undefined) max_force = boid.max_force
// Limit the force applied to the boid
let accLength = Math.sqrt(boid.acceleration.x * boid.acceleration.x + boid.acceleration.y * boid.acceleration.y);
if (accLength > max_force) {
boid.acceleration.x = (boid.acceleration.x / accLength) * max_force
boid.acceleration.y = (boid.acceleration.y / accLength) * max_force
}
// Update velocity
boid.velocity.x += boid.acceleration.x
boid.velocity.y += boid.acceleration.y
// Limit speed
let speed = Math.sqrt(boid.velocity.x * boid.velocity.x + boid.velocity.y * boid.velocity.y)
if (speed > max_speed) {
boid.velocity.x = (boid.velocity.x / speed) * max_speed
boid.velocity.y = (boid.velocity.y / speed) * max_speed
}
speed = Math.sqrt(boid.velocity.x * boid.velocity.x + boid.velocity.y * boid.velocity.y)
// Update position
boid.position.x += boid.velocity.x
boid.position.y += boid.velocity.y
// Apply friction (linear, so no drag)
boid.velocity.x *= (1-friction)
boid.velocity.y *= (1-friction)
// Brownian motion
boid.velocity.x+=brownian*(2*this.rng.random()-1)
boid.velocity.y+=brownian*(2*this.rng.random()-1)
// Reset acceleration
boid.acceleration.x = 0
boid.acceleration.y = 0
}
}
boundariesAndObstacles() {
for (let boid of this.boids) {
// Check obstacles first
for (let obs of this.obstacles) {
this.checkCollisionWithObstacle(boid, obs);
}
const r = boid.size / 2;
const force = this.max_force
// left wall
if (!this.wrap[0] && boid.position.x < r) {
const pen = r - boid.position.x;
boid.acceleration.x += pen*force;
}
// right wall
if (!this.wrap[0] && boid.position.x > this.width - r) {
const pen = boid.position.x - (this.width - r);
boid.acceleration.x -= pen*force;
}
// top wall
if (!this.wrap[1] && boid.position.y < r) {
const pen = r - boid.position.y;
boid.acceleration.y += pen*force;
}
// bottom wall
if (!this.wrap[1] && boid.position.y > this.height - r) {
const pen = boid.position.y - (this.height - r);
boid.acceleration.y -= pen*force;
}
//----------------------------------------------------------------------
// Wrap-around logic unchanged
//----------------------------------------------------------------------
if (this.wrap[0]) {
if (boid.position.x < 0) boid.position.x += this.width;
if (boid.position.x >= this.width) boid.position.x -= this.width;
}
if (this.wrap[1]) {
if (boid.position.y < 0) boid.position.y += this.height;
if (boid.position.y >= this.height) boid.position.y -= this.height;
}
}
}
// Forces like attraction, collisions, and gravity are applied here. Friction is done at the updating of position
applyPhysics() {
for (let i = 0; i < this.boids.length; i++) {
let boid = this.boids[i];
let gravity = this.config.gravity ?? 0
let collision_force = this.config.collision_force ?? 0
let max_speed = this.max_speed
let max_force = this.max_force
if(boid.locked) continue
if(boid.max_force !== undefined) max_force = boid.max_force
if(boid.max_speed !== undefined) max_speed = boid.max_speed
if(boid.gravity !== undefined) gravity = boid.gravity
if(boid.collision_force !== undefined) collision_force = boid.collision_force
let neighbours = this.getIndividualsInRange(boid.position, this.neighbourhood_radius)
let alignment = this.config.alignment ? this.calculateAlignment(boid, neighbours,max_speed) : {x:0,y:0}
let alignmentstrength = this.config.alignment ? this.config.alignment.strength : 0
let separation = this.config.separation ? this.calculateSeparation(boid, neighbours,max_speed) : {x:0,y:0}
let separationstrength = this.config.separation ? this.config.separation.strength : 0
let cohesion = this.config.cohesion ? this.calculateCohesion(boid, neighbours,max_speed) : {x:0,y:0}
let cohesionstrength = this.config.cohesion ? this.config.cohesion.strength : 0
if(boid.alignmentstrength !== undefined) alignmentstrength = boid.alignmentstrength
if(boid.cohesionstrength !== undefined) cohesionstrength = boid.cohesionstrength
if(boid.separationstrength !== undefined) separationstrength = boid.separationstrength
if(boid.brownian !== undefined) brownian = boid.brownian
let collision = {x:0,y:0}
if(collision_force > 0){
let overlapping = this.getIndividualsInRange(boid.position, boid.size)
collision = this.calculateCollision(boid, overlapping, max_force)
}
// Add acceleration to the boid
boid.acceleration.x += alignment.x * alignmentstrength +
separation.x * separationstrength +
cohesion.x * cohesionstrength +
collision.x * collision_force
boid.acceleration.y += alignment.y * alignmentstrength +
separation.y * separationstrength +
cohesion.y * cohesionstrength +
collision.y * collision_force +
gravity
if(this.config.mouseattraction){
this.followMouse(boid)
}
}
}
/* The above code is a multi-line comment in JavaScript. It is not executing any code but is used
for providing explanations or notes within the code. */
inBounds(boid, rect){
if(!rect) rect = {x:0,y:0,w:this.width,h:this.height}
let r = boid.size/2
return(boid.position.x+r > rect.x && boid.position.y+r > rect.y &&
boid.position.x-r < rect.x+rect.w && boid.position.y-r < rect.y+rect.h)
}
checkCollisionWithObstacle(boid, obs) {
if (obs.type === "rectangle") {
const r = boid.size / 2;
const left = boid.position.x - r;
const right = boid.position.x + r;
const top = boid.position.y - r;
const bottom = boid.position.y + r;
if (right > obs.x && left < obs.x + obs.w &&
bottom > obs.y && top < obs.y + obs.h) {
// Compute center of boid and closest point on rectangle
const cx = boid.position.x;
const cy = boid.position.y;
const closestX = Math.max(obs.x, Math.min(cx, obs.x + obs.w));
const closestY = Math.max(obs.y, Math.min(cy, obs.y + obs.h));
// Penetration vector
const dx = cx - closestX;
const dy = cy - closestY;
const dist = Math.hypot(dx, dy);
const minDist = r;
// Only resolve if penetrating
if (dist < minDist) {
// Normal vector
const nx = dx / (dist || 1);
const ny = dy / (dist || 1);
const penetration = minDist - dist;
// 1. Push boid out (position correction)
boid.position.x += nx * penetration;
boid.position.y += ny * penetration;
// 2. Remove velocity along collision normal
const vn = boid.velocity.x * nx + boid.velocity.y * ny;
if (vn < 0) {
// cancel inward motion
boid.velocity.x -= vn * nx * 1.0; // 1 = inelastic; <1 = soft
boid.velocity.y -= vn * ny * 1.0;
}
// 3. Damping so the boid settles
boid.velocity.x *= 0.8; // tweakable
boid.velocity.y *= 0.8;
}
}
}
else if (obs.type === "circle") {
const r = boid.size / 2;
const dx = boid.position.x - obs.x;
const dy = boid.position.y - obs.y;
const dist = Math.hypot(dx, dy);
const minDist = obs.r + r;
if (dist < minDist) {
// Normalized outward direction
const nx = dx / (dist || 1);
const ny = dy / (dist || 1);
// 1. Soft push-out (position correction)
const penetration = minDist - dist;
boid.position.x += nx * penetration * 0.5; // gentle correction
boid.position.y += ny * penetration * 0.5;
// 2. Apply a soft repulsive force (like your original code)
const force = obs.force ?? 1.0;
boid.acceleration.x += nx * force;
boid.acceleration.y += ny * force;
// 3. Light damping only when CLOSE to surface
// (prevents infinite tiny jitters)
if (penetration > 0.1) {
boid.velocity.x *= 0.9;
boid.velocity.y *= 0.9;
}
}
}
}
lengthVector(vector) {
return(Math.sqrt(vector.x * vector.x + vector.y * vector.y))
}
scaleVector(vector,scale){
return {x:vector.x*scale,y:vector.y*scale}
}
normaliseVector(vector) {
let length = this.lengthVector(vector)
if (length > 0) return { x: vector.x / length, y: vector.y / length }
else return { x: 0, y: 0 };
}
limitVector = function (vector,length){
let x = vector.x
let y = vector.y
let magnitude = Math.sqrt(x*x + y*y);
if (magnitude > length) {
// Calculate the scaling factor
const scalingFactor = length / magnitude;
// Scale the vector components
const scaledX = x * scalingFactor;
const scaledY = y * scalingFactor;
// Return the scaled vector as an object
return { x: scaledX, y: scaledY };
}
return { x:x, y:y }
}
// Angle in degrees
rotateVector(vec, ang)
{
ang = -ang * (Math.PI/180);
var cos = Math.cos(ang);
var sin = Math.sin(ang);
return {x: vec.x*cos - vec.y*sin, y: vec.x*sin + vec.y*cos}
}
handleMouseBoids(){}
repelBoids(force=30){
for (let boid of this.mouseboids) {
let dx = boid.position.x - this.mousecoords.x;
let dy = boid.position.y - this.mousecoords.y;
let distance = Math.sqrt(dx*dx + dy*dy);
if (distance > 0) { // Ensure we don't divide by zero
let strength = (this.mouse_radius - distance) / this.mouse_radius;
boid.velocity.x += (dx / distance) * strength * this.max_force * force;
boid.velocity.y += (dy / distance) * strength * this.max_force * force;
}
}
this.mouseboids = []
}
pullBoids(){
this.repelBoids(-30)
}
killBoids(){
let mouseboids = this.mouseboids
this.boids = this.boids.filter( function( el ) {
return mouseboids.indexOf( el ) < 0;
} );
}
/** Apart from flocking itself, any updates for the individuals are done here.
* By default, nextState is empty. It should be defined by the user (see examples)
*/
update() {
}
/** If called for the first time, make an update order (list of ints), otherwise just shuffle it. */
set_update_order() {
if (typeof this.upd_order === 'undefined') // "Static" variable, only create this array once and reuse it
{
this.upd_order = []
for (let n = 0; n < this.individuals.length; n++) {
this.upd_order.push(n)
}
}
utility.shuffle(this.upd_order, this.rng) // Shuffle the update order
}
// Returns the grid point corresponding to the boid's position. Returns 'null' if the boid is out of bounds
getBoidGridpoint(i,gridmodel){
let x = Math.floor(i.position.x)
let y = Math.floor(i.position.y)
if(x >= 0 && x < gridmodel.nc && y >= 0 && y < gridmodel.nr) {
gridmodel.grid[x][y].x = x
gridmodel.grid[x][y].y = y
return(gridmodel.grid[x][y])
}
else{
return undefined
}
}
getGridpoint = this.getBoidGridpoint
// TODO UITLEG
getNearbyGridpoints(boid,gridmodel,radius){
let gps = []
let ix = Math.floor(boid.position.x)
let iy = Math.floor(boid.position.y)
radius = Math.floor(0.5*radius)
for (let x = ix-radius; x < ix+radius; x++)
for (let y = iy-radius; y < iy+radius; y++)
{
if(!this.wrap[0])
if(x < 0 || x > this.width-1) continue
if(!this.wrap[1])
if(y < 0 || y > this.height-1) continue
if ((Math.pow((boid.position.x - x), 2) + Math.pow((boid.position.y - y), 2)) < radius*radius){
gps.push(gridmodel.grid[(x + gridmodel.nc) % gridmodel.nc][(y + gridmodel.nr) % gridmodel.nr])
}
}
return gps
}
getIndividualsInRange(position,radius){
let qt = this.qt
let width = this.width
let height = this.height
let neighbours = [] // Collect all found neighbours here
const offsets = [ // Fetch in 9 possible ways for wrapping around the grid
{ x: 0, y: 0 },
{ x: width, y: 0 },
{ x: -width, y: 0 },
{ x: 0, y: height },
{ x: 0, y: -height },
{ x: width, y: height },
{ x: width, y: -height },
{ x: -width, y: height },
{ x: -width, y: -height }
];
// Fetch all neighbours for each range
for (const offset of offsets) {
let range = { x:position.x+offset.x, y:position.y+offset.y, w:radius*2, h:radius*2 }
neighbours.push(...qt.query(range))
}
// Filter neighbours to only include those within the circular radius (a bit quicker than slicing in for loop, i noticed)
return neighbours.filter(neighbour => {
let dx = neighbour.position.x - position.x
let dy = neighbour.position.y - position.y
// Adjust for wrapping in the x direction
if (Math.abs(dx) > width/2) {
dx = dx - Math.sign(dx) * width;
}
// Adjust for wrapping in the y direction
if (Math.abs(dy) > height/2) {
dy = dy - Math.sign(dy) * height;
}
return (dx*dx + dy*dy) <= (radius*radius);
});
}
/** From a list of individuals, e.g. this.individuals, sample one weighted by a property. This is analogous
* to spinning a "roulette wheel". Also see a hard-coded versino of this in the "cheater" example
* @param {Array} individuals Array of individuals to sample from (e.g. living individuals in neighbourhood)
* @param {string} property The property used to weigh gps (e.g. fitness)
* @param {float} non Scales the probability of not returning any gp.
*/
rouletteWheel(individuals, property, non = 0.0) {
let sum_property = non
for (let i = 0; i < individuals.length; i++) sum_property += individuals[i][property] // Now we have the sum of weight + a constant (non)
let randomnr = this.rng.genrand_real1() * sum_property // Sample a randomnr between 0 and sum_property
let cumsum = 0.0 // This will keep track of the cumulative sum of weights
for (let i = 0; i < individuals.length; i++) {
cumsum += individuals[i][property]
if (randomnr < cumsum) return individuals[i]
}
return
}
placeObstacle(config){
let force = config.force == undefined ? 1 : config.force
if(config.w) this.obstacles.push({type:'rectangle',x:config.x,y:config.y,w:config.w,h:config.h,fill:config.fill,force:force})
if(config.r) this.obstacles.push({type:'circle',x:config.x,y:config.y,r:config.r,fill:config.fill,force:force})
}
/** Assign each individual a new random position in space. This simulated mixing,
* but does not guarantee a "well-mixed" system per se (interactions are still local)
* calculated based on neighbourhoods.
*/
perfectMix(){
for(let boid of this.boids){
boid.position.x = this.rng.genrand_real1() * this.width
boid.position.y = this.rng.genrand_real1() * this.height
}
//return "Perfectly mixed the individuals"
}
shuffleBoids(){
utility.shuffle(this.boids, this.rng)
}
/**
* Adds a dygraph-plot to your DOM (if the DOM is loaded)
* @param {Array} graph_labels Array of strings for the graph legend
* @param {Array} graph_values Array of floats to plot (here plotted over time)
* @param {Array} cols Array of colours to use for plotting
* @param {String} title Title of the plot
* @param {Object} opts dictionary-style list of opts to pass onto dygraphs
*/
plotArray(graph_labels, graph_values, cols, title, opts) {
if (typeof window == 'undefined') return
if (!(title in this.graphs)) {
cols = utility.parseColours(cols)
graph_values.unshift(this.time)
graph_labels.unshift("Time")
this.graphs[title] = new Graph(graph_labels, graph_values, cols, title, opts)
}
else {
if (this.time % this.graph_interval == 0) {
graph_values.unshift(this.time)
graph_labels.unshift("Time")
this.graphs[title].push_data(graph_values)
}
if (this.time % this.graph_update == 0) {
this.graphs[title].update()
}
}
}
/**
* Adds a dygraph-plot to your DOM (if the DOM is loaded)
* @param {Array} graph_values Array of floats to plot (here plotted over time)
* @param {String} title Title of the plot
* @param {Object} opts dictionary-style list of opts to pass onto dygraphs
*/
plotPoints(graph_values, title, opts) {
let graph_labels = Array.from({length: graph_values.length}, (v, i) => 'sample'+(i+1))
let cols = Array.from({length: graph_values.length}, (v, i) => 'black')
let seriesname = 'average'
let sum = 0
let num = 0
// Get average of all defined values
for(let n = 0; n< graph_values.length; n++){
if(graph_values[n] !== undefined) {
sum += graph_values[n]
num++
}
}
let avg = (sum / num) || 0;
graph_values.unshift(avg)
graph_labels.unshift(seriesname)
cols.unshift("#666666")
if(opts == undefined) opts = {}
opts.drawPoints = true
opts.strokeWidth = 0
opts.pointSize = 1
opts.series = {[seriesname]: {strokeWidth: 3.0, strokeColor:"green", drawPoints: false, pointSize: 0, highlightCircleSize: 3 }}
if (typeof window == 'undefined') return
if (!(title in this.graphs)) {
cols = utility.parseColours(cols)
graph_values.unshift(this.time)
graph_labels.unshift("Time")
this.graphs[title] = new Graph(graph_labels, graph_values, cols, title, opts)
}
else {
if (this.time % this.graph_interval == 0) {
graph_values.unshift(this.time)
graph_labels.unshift("Time")
this.graphs[title].push_data(graph_values)
}
if (this.time % this.graph_update == 0) {
this.graphs[title].update()
}
}
}
/**
* Adds a dygraph-plot to your DOM (if the DOM is loaded)
* @param {Array} graph_labels Array of strings for the graph legend
* @param {Array} graph_values Array of 2 floats to plot (first value for x-axis, second value for y-axis)
* @param {Array} cols Array of colours to use for plotting
* @param {String} title Title of the plot
* @param {Object} opts dictionary-style list of opts to pass onto dygraphs
*/
plotXY(graph_labels, graph_values, cols, title, opts) {
if (typeof window == 'undefined') return
if (!(title in this.graphs)) {
cols = utility.parseColours(cols)
this.graphs[title] = new Graph(graph_labels, graph_values, cols, title, opts)
}
else {
if (this.time % this.graph_interval == 0) {
this.graphs[title].push_data(graph_values)
}
if (this.time % this.graph_update == 0) {
this.graphs[title].update()
}
}
}
/**
* Easy function to add a pop-sizes plot (wrapper for plotArrays)
* @param {String} property What property to plot (needs to exist in your model, e.g. "species" or "alive")
* @param {Array} values Which values are plotted (e.g. [1,3,4,6])
*/
plotPopsizes(property, values, opts) {
if (typeof window == 'undefined') return
if (this.time % this.graph_interval != 0 && this.graphs[`Population sizes (${this.name})`] !== undefined) return
// Wrapper for plotXY function, which expects labels, values, colours, and a title for the plot:
// Labels
let graph_labels = []
for (let val of values) { graph_labels.push(property + '_' + val) }
// Values
let popsizes = this.getPopsizes(property, values)
let graph_values = popsizes
// Colours
let colours = []
for (let c of values) {
if (this.statecolours[property].constructor != Object)
colours.push(this.statecolours[property])
else
colours.push(this.statecolours[property][c])
}
// Title
let title = "Population sizes (" + this.name + ")"
if(opts && opts.title) title = opts.title
this.plotArray(graph_labels, graph_values, colours, title, opts)
//this.graph = new Graph(graph_labels,graph_values,colours,"Population sizes ("+this.name+")")
}
drawSlide(canvasname,prefix="grid_") {
let canvas = this.canvases[canvasname].elem // Grab the canvas element
let timestamp = sim.time.toString()
timestamp = timestamp.padStart(5, "0")
canvas.toBlob(function(blob)
{
saveAs(blob, prefix+timestamp+".png");
});
}
resetPlots() {
this.time = 0
for (let g in this.graphs) {
this.graphs[g].reset_plot()
}
}
}
export default Flockmodel
////////////////////////////////////////////////////////////////////////////////////////////////////
// The functions below are not methods of grid-model as they are never unique for a particular model.
////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Make a grid, or when a template is given, a COPY of a grid.
* @param {int} cols Width of the new grid
* @param {int} rows Height of the new grid
* @param {2DArray} template Template to be used for copying (if not set, a new empty grid is made)
*/
let MakeGrid = function(cols, rows, template) {
let grid = new Array(rows); // Makes a column or <rows> long --> grid[cols]
for (let x = 0; x < cols; x++) {
grid[x] = new Array(cols); // Insert a row of <cols> long --> grid[cols][rows]
for (let y = 0; y < rows; y++) {
if (template) grid[x][y] = new Gridpoint(template[x][y]); // Make a deep or shallow copy of the GP
else grid[x][y] = new Gridpoint();
}
}
return grid;
}
/**
* Make a back-up of all the ODE states (for synchronous ODE updating)
* @param {int} cols Width of the grid
* @param {int} rows Height of the grid
* @param {2DArray} template Get ODE states from here
*/
let CopyGridODEs = function(cols, rows, template) {
let grid = new Array(rows); // Makes a column or <rows> long --> grid[cols]
for (let x = 0; x < cols; x++) {
grid[x] = new Array(cols); // Insert a row of <cols> long --> grid[cols][rows]
for (let y = 0; y < rows; y++) {
for (let o = 0; o < template[x][y].ODEs.length; o++) // every ode
{
grid[x][y] = []
let states = []
for (let s = 0; s < template[x][y].ODEs[o].state.length; s++) // every state
states.push(template[x][y].ODEs[o].state[s])
grid[x][y][o] = states;
}
}
}
return grid;
}