diff options
author | Brian Picciano <mediocregopher@gmail.com> | 2021-07-02 09:31:44 -0600 |
---|---|---|
committer | Brian Picciano <mediocregopher@gmail.com> | 2021-07-02 09:31:44 -0600 |
commit | 5b0cc1f13c2162588aaa1b689a477d4038a57cda (patch) | |
tree | 9ccb346b3bd5efc3261ccb69478258182cf10e03 | |
parent | 14dc57c1105f25aa5f58a214af9413d3be887924 (diff) |
viz7
-rw-r--r-- | src/_posts/2021-07-01-viz-7.md | 440 |
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> |