summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBrian Picciano <mediocregopher@gmail.com>2021-07-02 09:31:44 -0600
committerBrian Picciano <mediocregopher@gmail.com>2021-07-02 09:31:44 -0600
commit5b0cc1f13c2162588aaa1b689a477d4038a57cda (patch)
tree9ccb346b3bd5efc3261ccb69478258182cf10e03
parent14dc57c1105f25aa5f58a214af9413d3be887924 (diff)
viz7
-rw-r--r--src/_posts/2021-07-01-viz-7.md440
1 files changed, 440 insertions, 0 deletions
diff --git a/src/_posts/2021-07-01-viz-7.md b/src/_posts/2021-07-01-viz-7.md
new file mode 100644
index 0000000..5bf3e8d
--- /dev/null
+++ b/src/_posts/2021-07-01-viz-7.md
@@ -0,0 +1,440 @@
+---
+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>