PIM Playground III See the PIM Playgroud document for a detailed description of what's going on here, and the larger project. Here cell is added every clock ticks.
{ restart; const populationChart = renderPopulation(200, 60); const chart = html`<div style="display: flex; justify-content: center; flex-wrap: wrap;"> <div style="flex: 1 1 0; display: flex; flex-direction: column; justify-content: space-between; align-items: center; min-width: 500px"> <h3>Legend</h3> <div style="width: 100%;">${renderLegand()}</div> </div> <div style="display: flex; flex-direction: column; justify-content: space-between; align-items: center;"> <h3>Population</h3> <div style="width: 100%;">${populationChart}</div> </div> </div>`; try { yield chart; yield invalidation; } finally { clearInterval(populationChart.intervalId); } }
{ restart; const context = DOM.context2d(width, height); context.setTransform(2 * scale, 0, 0, 2 * scale, 0, 0); simulation.on("tick", ticked); const linkForce = simulation.force('links'); function ticked() { context.clearRect(0, 0, sWidth + 1, sHeight + 1); clock.advance(0.1); for (const { source, target } of linkStore.links) { context.strokeStyle = "#ddd"; context.lineWidth = 2; context.beginPath(); context.moveTo(source.x, source.y); context.lineTo(target.x, target.y); context.stroke(); } cellStore.cells.forEach(cell => { const { x, y, radius, color, filled } = cell; context.fillStyle = color; context.beginPath(); context.arc(x, y, radius, 0, 2 * Math.PI); context.fill(); }); if (cellStore.dirty) { simulation.nodes(cellStore.cells); cellStore.clean(); const { births, deaths } = cellStore.stats; db('births', births); db('deaths', deaths); } if (linkStore.dirty) { linkForce.links(linkStore.links); linkStore.clean(); } } try { yield context.canvas; yield invalidation; } finally { simulation.stop(); } }
{ const buttons = [ callButton('reset', () => mutable restart += 1), callButton('add', () => population.createCells()), callButton(isRunning ? 'pause' : 'start', () => { isRunning ? simulation.stop() : simulation.restart(); mutable isRunning = !isRunning; }), ]; return html`<div style="display: flex; flex-direction: row">${ buttons.map(d => html`<div style="padding-right: 5px">${d}</div>`) }</div>`; }
viewof db = Debug()
Application State
mutable restart = 0
mutable isRunning = { restart; if (!startRunningByDefault) { simulation.stop(); } return startRunningByDefault; }
startRunningByDefault = true
simulation = { restart; const collideForce = forceCollide(); const handleCollide = (cell1, cell2) => { population.handleCollide(cell1, cell2); collideForce.radius(({ radius }) => radius); }; collideForce .onCollide(handleCollide) .radius(({ radius }) => radius); const linkForce = d3.forceLink().distance(20).strength(0.5); const simulation = d3.forceSimulation(cellStore.cells) .force('motion', forceCellMotion()) .force('links', linkForce) .force('collide', collideForce) .force('bounds', forceScreenBounds(sWidth, sHeight)) .alphaDecay(0); return simulation; }
population = { restart; return new Population(); }
clock = { restart; return new Clock(); }
idFactory = new IdFactory()
cellStore = { restart; return new CellStore(); }
linkStore = { restart; return new LinkStore(); }
Cell Types
class MoveCell extends Cell { constructor(x, y, properties) { super(x, y, properties); this._name = 'Mover'; this._color = d3.hsl(chromatic.schemeSet1[4]); this._color.opacity = this._opacity; } step(time) { const { alpha1, alpha2, velocity, timeOffset } = this.properties; const a1 = timeOffset + time * alpha1; const a2 = timeOffset + time * alpha2; this.vx += (Math.cos(a1) + Math.cos(a2)) * velocity; this.vy += (Math.sin(a1) + Math.sin(a2)) * velocity; super.step(time); } }
class ConnectCell extends Cell { constructor(x, y, properties) { super(x, y, properties); this._name = 'Connector'; this._color = d3.hsl(chromatic.schemeSet1[1]); this._color.opacity = this._opacity; } willLinkTo(other) { return this.degree < this.properties.maxDegree; } }
class EatCell extends Cell { constructor(x, y, properties) { super(x, y, properties); this._name = 'Eater'; this._color = d3.hsl(chromatic.schemeSet1[0]); this._color.opacity = this._opacity; } willEat(other) { return this.bodyId != other.bodyId; } }
class Cell { constructor(x = 0, y = 0, properties) { this.linkMap = {}; this.id = idFactory.id; this.x = x; this.y = y; this.vx = 0; this.vy = 0; this._energy = 1; this._name = 'Stem Cell'; //this.bodyId = idFactory.id; this.properties = properties || {}; this.startTime = clock.time; this._opacity = 0.75; this._color = d3.hsl('black'); this._color.opacity = this._opacity; } get color() { this._color.s = saturation(cellStore.getCellAge(this)); return this._color; } get name() { return this._name; } get velocityMagnitude() { return this.vx * this.vx + this.vy * this.vy; } get links() { return Object.values(this.linkMap); } link(other) { this.linkMap[other.id] = other; } unlink(other) { delete this.linkMap[other.id]; } willLinkTo(other) { return false; } willEat(other) { return false; } divide(types) { const dAlpha = Math.PI * 2 / types.length; return types.map((type, i) => { const position = { x: this.x + Math.cos(dAlpha * i) * 30, y: this.y + Math.sin(dAlpha * i) * 30, }; const child = cellStore.createCell(position, type, this.properties); linkStore.link(this, child); return child; }); } get radius() { return radius(this._energy); } get degree() { return Object.keys(this.linkMap).length; } get energy() { return this._energy; } set energy(energy) { this._energy = Math.min(Math.max(energy, 0), 1); } linkedInteraction(other) { this.closeGap('_energy', other, 0.1); } closeGap(field, other, fraction) { const v1 = this[field]; const v2 = other[field]; const dv = (v1 - v2) * fraction ; this[field] -= dv; other[field] += dv; } step() { const distance = Math.sqrt(this.vx * this.vx + this.vy * this.vy); this._energy = Math.max(0, this._energy - (costOfMotion * distance + costOfLiving)); } }
Classes
class LinkStore { constructor() { this._dirty = true; this.linkMap = {}; } static linkId(cell1, cell2) { return cell1.id < cell2.id ? cell1.id + '-' + cell2.id : cell2.id + '-' + cell1.id; } areLinked(linkId) { return this.linkMap[linkId]; } get links() { return Object.values(this.linkMap); } get dirty() { return this._dirty; } clean() { this._dirty = false; } link(source, target, id = LinkStore.linkId(source, target)) { if (source.bodyId !== target.bodyId) { this.setConnectedBodyId( ...(source.bodyId > target.bodyId ? [target, source.bodyId] : [source, target.bodyId]) ); } source.link(target); target.link(source); this.linkMap[id] = { id, source, target }; this._dirty = true; } setConnectedBodyId(cell, bodyId) { cell.bodyId = bodyId; cell.links.filter(d => d.bodyId !== bodyId).forEach(d => this.setConnectedBodyId(d, bodyId)); } unlink(cell1, cell2) { const id = LinkStore.linkId(cell1, cell2); delete this.linkMap[id]; cell1.unlink(cell2); cell2.unlink(cell1); this._dirty = true; } }
class CellStore { constructor() { this._cellMap = {}; this._dirty = true; this._age = d3.scaleLinear() .domain([Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER]); this._stats = { births: 0, deaths: 0, }; } getCellAge(cell) { return this._age(cell.startTime); } get dirty() { return this._dirty; } get cells() { return Object.values(this._cellMap); } get stats() { return this._stats; } clean() { this._dirty = false; } createCell(position, type, properties) { const cell = new type(position.x, position.y, properties); this._cellMap[cell.id] = cell; this.updateAge(); this._stats.births++; this._dirty = true; return cell; } updateAge() { this._age.domain(d3.extent(this.cells, d => d.startTime)); db('age domain', this._age.domain()); } destroyCell(cell) { cell.links.forEach(other => linkStore.unlink(cell, other)); delete this._cellMap[cell.id]; if (this.cells.length > 0) { this.updateAge(); } this._stats.deaths++; this._dirty = true; } }
class Population { constructor() { this.cellClassScale = d3.scaleQuantize().range([ConnectCell, EatCell, MoveCell]); this.cellTypeNames = ['MoveCell', 'ConnectCell', 'EatCell']; this._actionQueue = []; clock.setPeriodicCall(0.5, () => this.tick()); clock.setPeriodicCall(injectionCycleDelay, () => this.queueAction(() => this.createCells())); clock.setPeriodicCall(1, () => db('clock', Math.round(clock.time))); } createCells() { d3.range(injectionCellCount).map(() => this.createCell()); } createCell() { let cellType = null; let rules = null; let moveExpand = null; do { cellType = this.cellClassScale(Math.random()); rules = this.randomRulesGenerator(); moveExpand = _.uniq(rules['MoveCell']); } while (cellType === MoveCell && (moveExpand.length === 0 || (moveExpand.length === 1 && moveExpand[0] == MoveCell))); return cellStore.createCell( { x: x(Math.random()), y: y(Math.random()), }, cellType, { alpha1: d3.randomUniform(-0.5, 0.5)(), alpha2: d3.randomUniform(-0.5, 0.5)(), timeOffset: d3.randomUniform(0, Math.PI * 2)(), velocity: d3.randomUniform(0, 5)(), maxDegree: Math.round(d3.randomUniform(...maxDegreeExtent)()), rules, }, ); } randomRulesGenerator() { const countScale = d3.scaleQuantize().range(d3.range(4)); return this.cellTypeNames.reduce((rules, type) => { return { [type]: d3.range(countScale(Math.random())).map(d => this.cellClassScale(Math.random())), ...rules, }; }, {}); } queueAction(action) { this._actionQueue.push(action); } drainActionQueue() { while(this._actionQueue.length > 0) { this._actionQueue.shift()(); } } tick() { this.drainActionQueue(); // destory any dying/dead cells cellStore.cells .filter(d => d.dead || d.energy < 0.002) .forEach(d => cellStore.destroyCell(d)); // update any link ineractions between cells linkStore.links .forEach(({ source, target }) => source.linkedInteraction(target)); // divide any cells that have the juice cellStore.cells .filter(d => !d.dead && d.energy > divideThreshold) .forEach(d => this.divide(d, d.properties.rules[d.constructor.name])); } divide(cell, types) { const dAlpha = Math.PI * 2 / types.length; const alphaOffset = d3.randomUniform(2 * Math.PI)(); const energy = cell.energy / (types.length + 1); cell.energy = energy; return types.map((type, i) => { const position = { x: cell.x + Math.cos(alphaOffset + dAlpha * i) * 30, y: cell.y + Math.sin(alphaOffset + dAlpha * i) * 30, }; const child = cellStore.createCell(position, type, cell.properties); child.energy = energy; linkStore.link(cell, child); return child; }); } handleCollide(source, target) { // if the cells are linked, then the collide does nothing const id = LinkStore.linkId(source, target); if (linkStore.areLinked(id)) return; // if both cells want to link up, link up const sLink = source.willLinkTo(target); const tLink = target.willLinkTo(source); if (sLink && tLink) { this.queueAction(() => linkStore.link(source, target, id)); return; } // see if cells want to eat eachother const sEat = source.willEat(target); const tEat = target.willEat(source); // if neither want to eat eachother, do nothing if (!sEat && !tEat) return; // if both want to eat eachother, the faster one wins if (sEat && tEat) { if (source.velocityMagnitude > target.velocityMagnitude) { this.queueAction(() => this.eat(source, target)); } else { this.queueAction(() => this.eat(target, source)); } return; } // if only one cell wants to eat the other, do that if (sEat && !tEat) { this.queueAction(() => this.eat(source, target)); } else { this.queueAction(() => this.eat(target, source)); } } eat(eater, food) { eater.energy += food.energy; food.energy = 0; food.dead = true; } }
class Clock { constructor() { this._time = 0; this._periodicCalls = {}; } get time() { return this._time; } setPeriodicCall(period, call) { const id = idFactory.id; this._periodicCalls[id] = { call, period, nextCallTime: 0, }; return id; } advance(delta) { this._time += delta; // check to see if any periodic functions need calling Object.values(this._periodicCalls).forEach(periodicCall => { if (periodicCall.nextCallTime <= this.time) { periodicCall.nextCallTime += periodicCall.period; periodicCall.call(); } }); } }
class IdFactory { constructor() { this._nextId = 0; } get id() { return this._nextId++; } }
Renderers
function renderPopulation(width = 150, height = 70) { const radius = 2; const fontSize = 12; const margin = fontSize * 1.4; const svg = DOM.svg(width, height); const age = d3.scaleLinear().range([radius, width - 2 * radius]); const energy = d3.scaleLinear().range([height - 2 * radius, radius]); const root = d3.select(svg) .style('padding', margin); const legend = root .append('g') .attr('fill', '#666') .attr('font-size', `${fontSize}px`); legend.append('rect') .attr('width', width) .attr('height', height) .attr('rx', radius) .attr('ry', radius) .attr('fill', '#f0f0f0'); legend.append('text') .attr('transform', `rotate(-90) translate(${[-energy(0.5), -5]})`) .style('text-anchor', 'middle') .text('Old'); legend.append('text') .attr('transform', `rotate(-90) translate(${[-energy(0.5), age(1) + 18]})`) .style('text-anchor', 'middle') .text('Young'); legend.append('text') .attr('transform', `translate(${[age(0.5), -5]})`) .style('text-anchor', 'middle') .text('High Energy'); legend.append('text') .attr('transform', `translate(${[age(0.5), energy(0) + 18]})`) .style('text-anchor', 'middle') .text('Low Energy'); const update = (cells) => { db('cells', cells.length); const points = root .selectAll('.point') .data(cells, d => d.id); const enters = points.enter() .append('circle') .classed('point', true) .attr('r', radius) .attr('fill', 'rgba(128, 0, 128, 0.3)') .attr('cx', d => age(cellStore.getCellAge(d))) .attr('cy', d => energy(d.energy)); points .transition() .attr('cx', d => age(cellStore.getCellAge(d))) .attr('cy', d => energy(d.energy)); points.exit() .remove(); } svg.intervalId = setInterval(() => update(cellStore.cells), 200); return svg; }
function renderLegand() { clock; const cells = [MoveCell, ConnectCell, EatCell]; const size = 35; const computeColor = c => { const clr = c.color; clr.s = c.age; return clr; }; const renderCell = (c, mutation) => html` <svg width=${size} height=${size}><circle cx=${size / 2} cy=${size / 2} r=${c.radius} fill="${computeColor(c)}" />`; const renderRow = ({ name, mutation, cells}) => html` <div style="display: flex; align-items: center; justify-content: center;"> <div style="flex-basis: 80px; text-align: end; padding-right: 10px;">${name}</div> ${cells.map(c => {const d = new c(); d.energy = 0.5; d.age = 1; return d;}).map(c => renderCell(mutation(c)))} </div> `; const time = clock.time || 1000; const mutations = [ {name: 'Mover', mutation: d => d, cells: [MoveCell]}, {name: 'Connecter', mutation: d => d, cells: [ConnectCell]}, {name: 'Eater', mutation: d => d, cells: [EatCell]}, {name: 'Young', mutation: d => {d.age = 1.0; return d;}, cells}, {name: '', mutation: d => {d.age = (1 + oldSaturation) / 2; return d;}, cells}, {name: 'Old', mutation: d => {d.age = oldSaturation; return d}, cells}, {name: 'Energetic', mutation: d => {d.energy = 1.0; return d;}, cells}, {name: '', mutation: d => {d.energy = 0.5; return d;}, cells}, {name: 'Depleted', mutation: d => {d.energy = 0.0; return d;}, cells}, ]; return html` <div style="display: flex; flex-direction: column; flex-wrap: wrap; height: 110px;">${ mutations.map(renderRow) }</div>`; }
Forces
forceCellMotion = () => { let cells = null; let time = 0; const motion = () => { cells.forEach(cell => cell.step(clock.time)); } motion.initialize = _ => { cells = _; }; return motion; }
forceScreenBounds = (width, height) => { let cells = null; const bounds = () => { cells.forEach(cell => { const fx = cell.x + cell.vx; const radius = cell.radius; if (fx < radius || fx > (width - radius)) { cell.vx = 0; if (cell.x < 0) cell.x = 0; if (cell.x > width) cell.x = width - 1; } const fy = cell.y + cell.vy; if (fy < radius || fy > (height - radius)) { cell.vy = 0; if (cell.y < 0) cell.y = 0; if (cell.y > height) cell.y = height - 1; } }); } bounds.initialize = _ => cells = _; return bounds; }
forceCollide = { const constant = (x) => { return function() { return x; }; }; const jiggle = () => { return (Math.random() - 0.5) * 1e-6; } function x(d) { return d.x + d.vx; } function y(d) { return d.y + d.vy; } const collide = (radius) => { var nodes, radii, strength = 1, iterations = 1; let onCollide = null; if (typeof radius !== "function") radius = constant(radius == null ? 1 : +radius); function force() { var i, n = nodes.length, tree, node, xi, yi, ri, ri2; for (var k = 0; k < iterations; ++k) { tree = d3.quadtree(nodes, x, y).visitAfter(prepare); for (i = 0; i < n; ++i) { node = nodes[i]; ri = radii[node.index], ri2 = ri * ri; xi = node.x + node.vx; yi = node.y + node.vy; tree.visit(apply); } } function apply(quad, x0, y0, x1, y1) { var data = quad.data, rj = quad.r, r = ri + rj; if (data) { if (data.index > node.index) { var x = xi - data.x - data.vx, y = yi - data.y - data.vy, l = x * x + y * y; if (l < r * r) { if (onCollide) { onCollide(data, node); } if (x === 0) x = jiggle(), l += x * x; if (y === 0) y = jiggle(), l += y * y; l = (r - (l = Math.sqrt(l))) / l * strength; node.vx += (x *= l) * (r = (rj *= rj) / (ri2 + rj)); node.vy += (y *= l) * r; data.vx -= x * (r = 1 - r); data.vy -= y * r; } } return; } return x0 > xi + r || x1 < xi - r || y0 > yi + r || y1 < yi - r; } } function prepare(quad) { if (quad.data) return quad.r = radii[quad.data.index]; for (var i = quad.r = 0; i < 4; ++i) { if (quad[i] && quad[i].r > quad.r) { quad.r = quad[i].r; } } } function initialize() { if (!nodes) return; var i, n = nodes.length, node; radii = new Array(n); for (i = 0; i < n; ++i) node = nodes[i], radii[node.index] = +radius(node, i, nodes); } force.initialize = function(_) { nodes = _; //.filter(d => d.constructor.name !== 'MoveCell'); initialize(); }; force.iterations = function(_) { return arguments.length ? (iterations = +_, force) : iterations; }; force.strength = function(_) { return arguments.length ? (strength = +_, force) : strength; }; force.radius = function(_) { return arguments.length ? (radius = typeof _ === "function" ? _ : constant(+_), initialize(), force) : radius; }; force.onCollide = function(_) { onCollide = _; return force; }; return force; } return collide; }
Constants
oldSaturation = 0.15
scale = 1
height = 700
sWidth = width * (1 / scale)
sHeight = height * (1 / scale)
injectionCellCount = 1
injectionCycleDelay = 20
costOfMotion = 0.000002
costOfLiving = 0.000001
divideThreshold = 0.25
maxDegreeExtent = [2, 10]
radiusExtent = [2, 15]
Scales
radius = d3.scalePow().exponent(0.5).range(radiusExtent)
x = d3.scaleLinear().range([radiusExtent[1], sWidth - radiusExtent[1]])
y = d3.scaleLinear().range([radiusExtent[1], sHeight - radiusExtent[1]])
saturation = d3.scaleLinear().range([oldSaturation, 1])
Imports
function callButton(label, callback) { const button = md`<button>${label}</button>`; button.onclick = callback; return button }
import {button} from "@jashkenas/inputs"
d3 = require("https://d3js.org/d3.v5.min.js")
_ = require('lodash')
chromatic = require('d3-scale-chromatic')
import { Debug } from '@trebor/debug-tool'