mediocre-blog/static/src/_posts/2021-07-01-viz-7.md

441 lines
12 KiB
Markdown

---
title: >-
Visualization 7
description: >-
Feedback Loop.
series: viz
tags: tech art
---
<script type="text/javascript">
function randn(n) {
return Math.floor(Math.random() * n);
}
const w = 100;
const h = 60;
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 UniverseState {
constructor(layers) {
this.tick = 0;
this.layers = layers;
}
neighboringLayers(layerIndex) {
const prevIndex = layerIndex-1;
const prev = prevIndex < 0 ? null : this.layers[prevIndex];
const nextIndex = layerIndex+1;
const next = nextIndex >= this.layers.length ? null : this.layers[nextIndex];
return [prev, next];
}
}
const defaultKnobs = {
maxNewElsPerTick: 10,
ageOfDeath: 30,
drift: 30,
neighborScalar: 0,
prevLayerScalar: 0,
prevLayerLikenessScalar: 0,
nextLayerScalar: 0,
nextLayerLikenessScalar: 0,
chaos: 0,
};
class Layer {
constructor(className, newEl, knobs = {}) {
this.className = className;
this.els = {};
this.diff = {};
this.newEl = newEl;
this.knobs = { ...defaultKnobs, ...knobs };
}
_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, thisLayerIndex) {
// 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 newEl = this.newEl(this, [])
newEl.tick = state.tick;
this.set([w/2, h/2], newEl);
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 newEl = this.newEl(this, neighboringElsOf(this, nCoord))
newEl.tick = state.tick;
this.set(nCoord, newEl);
newEls++;
if (newEls >= this.knobs.maxNewElsPerTick) break;
}
const calcLayerBonus = (el, layer, scalar, likenessScalar) => {
if (!layer) return 0;
const nEls = neighboringElsOf(layer, el.coord, true)
const likeness = nEls.reduce((likeness, nEl) => {
const diff = Math.abs(nEl.c - el.c);
return likeness + Math.max(diff, Math.abs(1 - diff));
}, 0);
return (nEls.length * scalar) + (likeness * likenessScalar);
};
const [prevLayer, nextLayer] = state.neighboringLayers(thisLayerIndex);
for (const el of allEls) {
const age = state.tick - el.tick;
const neighborBonus = neighboringElsOf(this, el.coord).length * this.knobs.neighborScalar;
const prevLayerBonus = calcLayerBonus(el, prevLayer, this.knobs.prevLayerScalar, this.knobs.prevLayerLikenessScalar);
const nextLayerBonus = calcLayerBonus(el, nextLayer, this.knobs.nextLayerScalar, this.knobs.nextLayerLikenessScalar);
const chaos = (this.chaos > 0) ? randn(this.knobs.chaos) : 0;
if (age - neighborBonus - prevLayerBonus - nextLayerBonus + chaos >= this.knobs.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;
}
function newEl(h, l) {
return {
h: h,
s: "100%",
l: l,
c: h / 360, // c is used to compare the element to others
};
}
function mkNewEl(l) {
return (layer, nEls) => {
const s = "100%";
if (nEls.length == 0) {
const h = randn(360);
return newEl(h, 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() * layer.knobs.drift * 2) - layer.knobs.drift;
h = (h + 360) % 360;
return newEl(h, l);
}
}
class Universe {
constructor(canvasesByClass, layers) {
this.canvasesByClass = canvasesByClass;
this.state = new UniverseState(layers);
}
update() {
this.state.tick++;
let prevLayer;
this.state.layers.forEach((layer, i) => {
layer.update(this.state, i);
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>Color Drift</label><input type="text" param="drift" />
<label>Age of Death</label><input type="text" param="ageOfDeath" />
<label>Neighbor Scalar</label><input type="text" param="neighborScalar" />
<label>Top Layer Neighbor Scalar</label><input type="text" param="nextLayerScalar" />
<label>Top Layer Neighbor Likeness Scalar</label><input type="text" param="nextLayerLikenessScalar" />
</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>Color Drift</label><input type="text" param="drift" />
<label>Age of Death</label><input type="text" param="ageOfDeath" />
<label>Neighbor Scalar</label><input type="text" param="neighborScalar" />
<label>Bottom Layer Neighbor Scalar</label><input type="text" param="prevLayerScalar" />
<label>Bottom Layer Neighbor Likeness Scalar</label><input type="text" param="prevLayerLikenessScalar" />
</div>
</div>
</div>
Once again, this visualization iterates upon the previous. In the last one the
top layer was able to "see" the bottom, and was therefore able to bolster or
penalize its own elements which were on or near bottom layer elements, but not
vice-versa. This time both layers can see each other, and the "Layer Neighbor
Scalar" can be used to adjust lifetime of elements which are on/near elements of
the neighboring layer.
By default, the bottom layer has a high affinity to the top, and the top layer
has a some (but not as much) affinity in return.
Another addition is the "likeness" scalar. Likeness is defined as the degree to
which one element is like another. In this visualization likeness is determined
by color. The "Layer Neighbor Likeness Scalar" adjusts the lifetime of elements
based on how like they are to nearby elements on the neighboring layer.
By default, the top layer has a high affinity for the bottom's color, but the
bottom doesn't care about the top's color at all (and so its color will drift
aimlessly).
And finally "Color Drift" can be used to adjust the degree to which the color of
new elements can diverge from its parents. This has always been hardcoded, but
can now be adjusted separately across the different layers.
In the default configuration the top layer will (eventually) converge to roughly
match the bottom both in shape and color. When I first implemented the likeness
scaling I thought it was broken, because the top would never converge to the
bottom's color.
What I eventually realized was that the top must have a higher color drift than
the bottom in order for it to do so, otherwise the top would always be playing
catchup. However, if the drift difference is _too_ high then the top layer
becomes chaos and also doesn't really follow the color of the bottom. A
difference of 10 (degrees out of 360) is seemingly enough.
<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,
drift: 40,
neighborScalar: 50,
nextLayerScalar: 20,
}),
new Layer("layer2", mkNewEl("50%", ), {
maxNewElsPerTick: 15,
ageOfDeath: 1,
drift: 50,
neighborScalar: 5,
prevLayerScalar: 5,
prevLayerLikenessScalar: 20,
}),
];
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.knobs[param];
input.onchange = () => {
console.log(`setting ${layer.className}.${param} to ${input.value}`);
layer.knobs[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>