viz6
This commit is contained in:
parent
21fe465deb
commit
9be52bdaa4
402
src/_posts/2021-06-23-viz-6.md
Normal file
402
src/_posts/2021-06-23-viz-6.md
Normal file
@ -0,0 +1,402 @@
|
||||
---
|
||||
title: >-
|
||||
Visualization 6
|
||||
description: >-
|
||||
Eat your heart out, Conway!
|
||||
series: viz
|
||||
tags: tech art
|
||||
---
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
function randn(n) {
|
||||
return Math.floor(Math.random() * n);
|
||||
}
|
||||
|
||||
const w = 100;
|
||||
const h = 50;
|
||||
|
||||
class Canvas {
|
||||
constructor(canvasDOM) {
|
||||
this.dom = canvasDOM;
|
||||
this.ctx = canvasDOM.getContext("2d");
|
||||
|
||||
// expand canvas element's width to match parent.
|
||||
this.dom.width = this.dom.parentElement.offsetWidth;
|
||||
|
||||
// rectSize must be an even number or the pixels don't display nicely.
|
||||
this.rectSize = Math.floor(this.dom.width / w /2) * 2;
|
||||
|
||||
this.dom.width = w * this.rectSize;
|
||||
this.dom.height = h * this.rectSize;
|
||||
}
|
||||
|
||||
rectSize() {
|
||||
return Math.floor(this.dom.width / w);
|
||||
}
|
||||
}
|
||||
|
||||
class Layer {
|
||||
constructor(className, newEl, {
|
||||
maxNewElsPerTick = 10,
|
||||
ageOfDeath = 60,
|
||||
neighborBonusScalar = 1,
|
||||
layerBonusScalar = 1,
|
||||
chaos = 0,
|
||||
} = {}) {
|
||||
this.className = className;
|
||||
this.els = {};
|
||||
this.diff = {};
|
||||
|
||||
this.newEl = newEl;
|
||||
this.maxNewElsPerTick = maxNewElsPerTick;
|
||||
this.ageOfDeath = ageOfDeath;
|
||||
this.neighborBonusScalar = neighborBonusScalar;
|
||||
this.layerBonusScalar = layerBonusScalar;
|
||||
this.chaos = chaos;
|
||||
}
|
||||
|
||||
_normCoord(coord) {
|
||||
if (typeof coord !== 'string') coord = JSON.stringify(coord);
|
||||
return coord;
|
||||
}
|
||||
|
||||
get(coord) {
|
||||
return this.els[this._normCoord(coord)];
|
||||
}
|
||||
|
||||
getAll() {
|
||||
return Object.values(this.els);
|
||||
}
|
||||
|
||||
set(coord, el) {
|
||||
this.diff[this._normCoord(coord)] = {action: "set", coord: coord, ...el};
|
||||
}
|
||||
|
||||
unset(coord) {
|
||||
this.diff[this._normCoord(coord)] = {action: "unset"};
|
||||
}
|
||||
|
||||
applyDiff() {
|
||||
for (const coordStr in this.diff) {
|
||||
const el = this.diff[coordStr];
|
||||
delete this.diff[coordStr];
|
||||
|
||||
if (el.action == "set") {
|
||||
delete el.action;
|
||||
this.els[coordStr] = el;
|
||||
} else {
|
||||
delete this.els[coordStr];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(state, prevLayer) {
|
||||
// Apply diff from previous update first. The diff can't be applied last
|
||||
// because it needs to be present during the draw phase.
|
||||
this.applyDiff();
|
||||
|
||||
const allEls = this.getAll().sort(() => Math.random() - 0.5);
|
||||
|
||||
if (allEls.length == 0) {
|
||||
const nEl = this.newEl([])
|
||||
nEl.tick = state.tick;
|
||||
this.set([w/2, h/2], nEl);
|
||||
return;
|
||||
}
|
||||
|
||||
let newEls = 0;
|
||||
for (const el of allEls) {
|
||||
const nCoord = randEmptyNeighboringCoord(this, el.coord);
|
||||
if (!nCoord) continue; // el has no empty neighboring spots
|
||||
|
||||
const nEl = this.newEl(neighboringElsOf(this, nCoord))
|
||||
nEl.tick = state.tick;
|
||||
this.set(nCoord, nEl);
|
||||
|
||||
newEls++;
|
||||
if (newEls >= this.maxNewElsPerTick) break;
|
||||
}
|
||||
|
||||
for (const el of allEls) {
|
||||
const age = state.tick - el.tick;
|
||||
const neighborBonus = neighboringElsOf(this, el.coord).length * this.neighborBonusScalar;
|
||||
|
||||
const layerBonus = prevLayer
|
||||
? neighboringElsOf(prevLayer, el.coord, true).length * this.layerBonusScalar
|
||||
: 0;
|
||||
|
||||
const chaos = (this.chaos > 0) ? randn(this.chaos) : 0;
|
||||
|
||||
if (age - neighborBonus - layerBonus + chaos >= this.ageOfDeath) {
|
||||
this.unset(el.coord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draw(canvas) {
|
||||
for (const coordStr in this.diff) {
|
||||
const el = this.diff[coordStr];
|
||||
const coord = JSON.parse(coordStr);
|
||||
|
||||
if (el.action == "set") {
|
||||
canvas.ctx.fillStyle = `hsl(${el.h}, ${el.s}, ${el.l})`;
|
||||
canvas.ctx.fillRect(
|
||||
coord[0]*canvas.rectSize, coord[1]*canvas.rectSize,
|
||||
canvas.rectSize, canvas.rectSize,
|
||||
);
|
||||
|
||||
} else {
|
||||
canvas.ctx.clearRect(
|
||||
coord[0]*canvas.rectSize, coord[1]*canvas.rectSize,
|
||||
canvas.rectSize, canvas.rectSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const neighbors = [
|
||||
[-1, -1], [0, -1], [1, -1],
|
||||
[-1, 0], /* [0, 0], */ [1, 0],
|
||||
[-1, 1], [0, 1], [1, 1],
|
||||
];
|
||||
|
||||
function neighborsOf(coord) {
|
||||
return neighbors.map((n) => {
|
||||
let nX = coord[0]+n[0];
|
||||
let nY = coord[1]+n[1];
|
||||
nX = (nX + w) % w;
|
||||
nY = (nY + h) % h;
|
||||
return [nX, nY];
|
||||
});
|
||||
}
|
||||
|
||||
function randEmptyNeighboringCoord(layer, coord) {
|
||||
const neighbors = neighborsOf(coord).sort(() => Math.random() - 0.5);
|
||||
for (const nCoord of neighbors) {
|
||||
if (!layer.get(nCoord)) return nCoord;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function neighboringElsOf(layer, coord, includeCoord = false) {
|
||||
const neighboringEls = [];
|
||||
|
||||
const neighboringCoords = neighborsOf(coord);
|
||||
if (includeCoord) neighboringCoords.push(coord);
|
||||
|
||||
for (const nCoord of neighboringCoords) {
|
||||
const el = layer.get(nCoord);
|
||||
if (el) neighboringEls.push(el);
|
||||
}
|
||||
return neighboringEls;
|
||||
}
|
||||
|
||||
const drift = 30;
|
||||
function mkNewEl(l) {
|
||||
return (nEls) => {
|
||||
const s = "100%";
|
||||
if (nEls.length == 0) {
|
||||
return {
|
||||
h: randn(360),
|
||||
s: s,
|
||||
l: l,
|
||||
};
|
||||
}
|
||||
|
||||
// for each h (which can be considered as degrees around a circle) break the h
|
||||
// down into x and y vectors, and add those up separately. Then find the angle
|
||||
// between those two resulting vectors, and that's the "average" h value.
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
nEls.forEach((el) => {
|
||||
const hRad = el.h * Math.PI / 180;
|
||||
x += Math.cos(hRad);
|
||||
y += Math.sin(hRad);
|
||||
});
|
||||
|
||||
let h = Math.atan2(y, x);
|
||||
h = h / Math.PI * 180;
|
||||
|
||||
// apply some random drift, normalize
|
||||
h += (Math.random() * drift * 2) - drift;
|
||||
h = (h + 360) % 360;
|
||||
|
||||
return {
|
||||
h: h,
|
||||
s: s,
|
||||
l: l,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class Universe {
|
||||
constructor(canvasesByClass, layers) {
|
||||
this.canvasesByClass = canvasesByClass;
|
||||
this.state = {
|
||||
tick: 0,
|
||||
layers: layers,
|
||||
};
|
||||
}
|
||||
|
||||
update() {
|
||||
this.state.tick++;
|
||||
let prevLayer;
|
||||
this.state.layers.forEach((layer) => {
|
||||
layer.update(this.state, prevLayer);
|
||||
prevLayer = layer;
|
||||
});
|
||||
}
|
||||
|
||||
draw() {
|
||||
this.state.layers.forEach((layer) => {
|
||||
if (!this.canvasesByClass[layer.className]) return;
|
||||
this.canvasesByClass[layer.className].forEach((canvas) => {
|
||||
layer.draw(canvas);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
.canvasContainer {
|
||||
display: grid;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 1px dashed #AAA;
|
||||
width: 100%;
|
||||
grid-area: 1/1/2/2;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div class="canvasContainer">
|
||||
<canvas class="layer1"></canvas>
|
||||
<canvas class="layer2"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
||||
<div class="columns six">
|
||||
<h3>Bottom Layer</h3>
|
||||
<div class="canvasContainer"><canvas class="layer1"></canvas></div>
|
||||
<div class="layer1 layerParams">
|
||||
<label>Max New Elements Per Tick</label><input type="text" param="maxNewElsPerTick" />
|
||||
<label>Age of Death</label><input type="text" param="ageOfDeath" />
|
||||
<label>Neighbor Bonus Scalar</label><input type="text" param="neighborBonusScalar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns six">
|
||||
<h3>Top Layer</h3>
|
||||
<div class="canvasContainer"><canvas class="layer2"></canvas></div>
|
||||
<div class="layer2 layerParams">
|
||||
<label>Max New Elements Per Tick</label><input type="text" param="maxNewElsPerTick" />
|
||||
<label>Age of Death</label><input type="text" param="ageOfDeath" />
|
||||
<label>Neighbor Bonus Scalar</label><input type="text" param="neighborBonusScalar" />
|
||||
<label>Layer Bonus Scalar</label><input type="text" param="layerBonusScalar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
This visualization is essentially the same as the previous, except that each
|
||||
layer now operates with different parameters than the other, allowing each to
|
||||
exhibit different behavior.
|
||||
|
||||
Additionally, the top layer has been made to be responsive to the bottom, via a
|
||||
new mechanism where the age of an element on the top layer can be extended based
|
||||
on the number of bottom layer elements it neighbors.
|
||||
|
||||
Finally, the UI now exposes the actual parameters which are used to tweak the
|
||||
behavior of each layer. Modifying any parameter will change the behavior of the
|
||||
associated layer in real-time. The default parameters have been chosen such that
|
||||
the top layer is now rather dependent on the bottom for sustenance, although it
|
||||
can venture away to some extent. However, by playing the parameters yourself you
|
||||
can find other behaviors and interesting cause-and-effects that aren't
|
||||
immediately obvious. Try it!
|
||||
|
||||
An explanation of the parameters is as follows:
|
||||
|
||||
On each tick, up to `maxNewElements` are created in each layer, where each new
|
||||
element neighbors an existing one.
|
||||
|
||||
Additionally, on each tick, _all_ elements in a layer are iterated through. Each
|
||||
one's age is determined as follows:
|
||||
|
||||
```
|
||||
age = (currentTick - birthTick)
|
||||
age -= (numNeighbors * neighborBonusScalar)
|
||||
age -= (numBottomLayerNeighbors * layerBonusScalar) // only for top layer
|
||||
```
|
||||
|
||||
If an element's age is greater than or equal to the `ageOfDeath` for that layer,
|
||||
then the element is removed.
|
||||
|
||||
<script>
|
||||
|
||||
const canvasesByClass = {};
|
||||
[...document.getElementsByTagName("canvas")].forEach((canvasDOM) => {
|
||||
|
||||
const canvas = new Canvas(canvasDOM);
|
||||
canvasDOM.classList.forEach((name) => {
|
||||
if (!canvasesByClass[name]) canvasesByClass[name] = [];
|
||||
canvasesByClass[name].push(canvas);
|
||||
})
|
||||
});
|
||||
|
||||
const layers = [
|
||||
|
||||
new Layer("layer1", mkNewEl("90%"), {
|
||||
maxNewElsPerTick: 2,
|
||||
ageOfDeath: 30,
|
||||
neighborBonusScalar: 50,
|
||||
}),
|
||||
|
||||
new Layer("layer2", mkNewEl("50%", ), {
|
||||
maxNewElsPerTick: 10,
|
||||
ageOfDeath: 1,
|
||||
neighborBonusScalar: 15,
|
||||
layerBonusScalar: 5,
|
||||
}),
|
||||
|
||||
];
|
||||
|
||||
for (const layer of layers) {
|
||||
document.querySelectorAll(`.${layer.className}.layerParams > input`).forEach((input) => {
|
||||
const param = input.getAttribute("param");
|
||||
|
||||
// pre-fill input values
|
||||
input.value = layer[param];
|
||||
|
||||
input.onchange = () => {
|
||||
console.log(`setting ${layer.className}.${param} to ${input.value}`);
|
||||
layer[param] = input.value;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const universe = new Universe(canvasesByClass, layers);
|
||||
|
||||
const requestAnimationFrame =
|
||||
window.requestAnimationFrame ||
|
||||
window.mozRequestAnimationFrame ||
|
||||
window.webkitRequestAnimationFrame ||
|
||||
window.msRequestAnimationFrame;
|
||||
|
||||
function doTick() {
|
||||
universe.update();
|
||||
universe.draw();
|
||||
requestAnimationFrame(doTick);
|
||||
}
|
||||
|
||||
doTick();
|
||||
|
||||
</script>
|
Loading…
Reference in New Issue
Block a user