Published
Edited
Oct 15, 2021
42 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
{
await visibility();
let field = slots[colorFieldParms.field.field];
let colorField = slots[colorFieldParms.field.colorField];

let trace = new FieldCurveTrace(field);
let ctx = DOM.context2d(width, height, 1);
let t = Date.now();
let batchSize = 20;
let batch = 0;

let palette = colorFieldParms.palette;

let {
count,
sampling,
stepSize,
curveLength,
curveThick,
curveAlpha
} = colorFieldParms.line;

let { margin, minLengthRatio } = colorFieldParms.intAlg;

let sf = sampleFunction(sampling);
ctx.lineWidth = curveThick;
ctx.globalAlpha = curveAlpha;
let avoid = colorFieldParms.intAlg.enable.length > 0;
for (let [x, y] of _.shuffle([...sf(count, width, height)])) {
let colorIndex = ~~(
(((colorField.getField(x, y) + Math.PI * 2) % (Math.PI * 2)) /
(Math.PI * 2)) *
palette.length
);
ctx.strokeStyle = palette[colorIndex];
if (avoid) {
let curve = trace.sampleCurve(
Vec(x, y),
stepSize,
curveLength,
curveThick
);
let trim = ~~(margin / stepSize);
if (curve.length >= minLengthRatio * curveLength + trim * 2) {
trace.markCurve(curve, curveThick + margin);
curve = curve.slice(trim, curve.length - trim);
drawCurve(ctx, curve);
}
} else {
const curve = fieldCurve(field, x, y, stepSize, curveLength);
drawCurve(ctx, curve);
}
if (batch-- <= 0) {
// Progressive update so we don't hog the CPU
batch = batchSize;
if (Date.now() - t > 100) {
t = Date.now();
yield ctx.canvas;
}
}
}

yield ctx.canvas;
}
Insert cell
Insert cell
//
// Interface for noise parameters
//
function noiseInterface(title, defaults = {}) {
return paged(
{
noiseScale: Inputs.range([0.1, 40], {
label: "noise scale",
value: defaults.noiseScale || 1,
step: 0.1
}),
noiseOctaves: Inputs.select([1, 2, 3, 4, 5, 6], {
label: "octaves",
value: defaults.noiseOctaves || 2
}),
noiseOffset: Inputs.range([-1, 1], {
label: "noise offset",
value: defaults.noiseOffset || 0,
step: 0.01
}),
angleScale: Inputs.range([1, 8], {
label: "angle scale",
value: defaults.angleScale || 1,
step: 0.1
})
},
title
);
}
Insert cell
//
// Interface for flow field line drawing parameters
//
function flowLineInterface(title, defaults = {}) {
return paged(
{
sampling: Inputs.radio(["grid", "random", "poisson"], {
label: "sample function",
value: defaults.sampling || "poisson"
}),
count: Inputs.range([10, 50000], {
label: "samples",
step: 1,
value: defaults.count || 5000
}),
stepSize: Inputs.range([1, 50], {
label: "step size",
step: 1,
value: defaults.stepSize || 4
}),
curveLength: Inputs.range([5, 200], {
label: "curve length",
value: defaults.curveLength || 50,
step: 1
}),
curveThick: Inputs.range([0.1, 40], {
label: "curve thickness",
value: defaults.curveThick || 1,
step: 0.1
}),
curveAlpha: Inputs.range([0.01, 1], {
label: "curve alpha",
value: defaults.curveAlpha || 0.8,
step: 0.01
})
},
title
);
}
Insert cell
//
// Interface for selecting brush parameters
//
function brushInterface(title, defaults = {}) {
return paged(
{
hardness: Inputs.range([0, 1], {
label: "hardness",
value: defaults.hardness || 1,
step: 0.01
}),
near: Inputs.range([0, width], {
label: "min distance",
value: defaults.near || 0,
step: 1
}),
far: Inputs.range([0, width], {
label: "max distance",
value: defaults.far || 100,
step: 1
})
},
title
);
}
Insert cell
//
// Interfaces for parameters that control the intersection avoidance mechanism
//
function intersectionInterface(title, defaults = {}) {
return paged({
enable: Inputs.checkbox(["yes"], {
label: "Enable intersection avoidance",
value: ["yes"]
}),
margin: Inputs.range([0, 50], {
label: "Margin",
step: 1,
value: defaults.margin === undefined ? 2 : defaults.margin
}),
minLengthRatio: Inputs.range([0, 1], {
label: "Discard line if length ratio less than",
value: defaults.minLengthRatio || 0.4,
step: 0.01
})
});
}
Insert cell
//
// Exhibits a radio button for choosing a field slot
//
function slotInput(value = 1, label = "slot", title) {
let div = html`<div>`;
let input = Inputs.radio([1, 2, 3, 4], {
label,
value: value
});
div.append(input);
div.value = input.value;
input.addEventListener("input", () => {
div.value = input.value;
div.dispatchEvent(new CustomEvent("input"));
});
if (title) div.prepend(title);
return div;
}
Insert cell
Insert cell
aspectRatio = window.screen.width / window.screen.height
Insert cell
//
// Default height of all canvases
//
height = ~~(width / aspectRatio)
Insert cell
//
// Field slots
//
mutable slots = {
let defField = new GridField(width, height, 20);
return { 1: defField, 2: defField, 3: defField, 4: defField };
}
Insert cell
//
// Smooth Step function from https://en.wikipedia.org/wiki/Smoothstep
//
smoothStep = (edge0, edge1) => {
return (x) => {
x = Math.max(Math.min((x - edge0) / (edge1 - edge0), 1.0), 0.0);
return x * x * (3 - 2 * x);
};
}
Insert cell
//
// Simplex noise is preferrable to perlin in most cases
//
function makeSimplexField(width, height, cellSize, options = {}) {
let {
noiseScale = 1,
noiseOctaves = 1,
noiseOffset = 0,
seeds = [1, 1],
angleScale = 4
} = options;
let g = new GridField(width, height, cellSize);
let [seedX, seedY] = seeds;
let noise = octave(simplex2D, noiseOctaves);
for (let i of d3.range(g.nx)) {
for (let j of d3.range(g.ny)) {
let angle =
(noiseOffset +
noise(
(seedX + i / g.ny) * noiseScale,
(seedY + j / g.ny) * noiseScale
)) *
Math.PI *
angleScale;
g.setCell(i, j, angle);
}
}
return g;
}
Insert cell
//
// Returns a sampling function given a name
//
sampleFunction = function (sampleFunctionName) {
return sampleFunctionName == "poisson"
? poissonSampling
: sampleFunctionName == "grid"
? gridSampling
: randomSampling;
}
Insert cell
//
// Support for combing fields
//
function brushField(field, stroke, weight) {
let cells = new Map();
let visited = new Set();
let n = stroke.length;
const modify = () => {
for (let [key, { seed, angle }] of cells) {
let [ix, iy] = key.split(",").map((x) => +x);
let d =
Math.sqrt((seed[0] - ix) ** 2 + (seed[1] - iy) ** 2) * field.cellSize;
let alpha = weight(d);
//let newAngle = angle * alpha + field.getCell(ix, iy) * (1 - alpha);
let newAngle = angleLerp(field.getCell(ix, iy), angle, alpha);
field.setCell(ix, iy, newAngle);
visited.add([ix, iy].toString());
}
};
const propagate = () => {
let { nx, ny } = field;
let newCells = new Map();
let dist = new Map();
for (let [key, { seed, angle }] of cells) {
let [ix, iy] = key.split(",").map((x) => +x);
for (let [dx, dy] of [
[-1, -1],
[-1, 0],
[-1, 1],
[1, -1],
[1, 0],
[1, 1],
[0, -1],
[0, 1]
]) {
let [jx, jy] = [ix + dx, iy + dy];
if (jx >= 0 && jx < nx && jy >= 0 && jy < ny) {
let nkey = [jx, jy].toString();
if (visited.has(nkey)) continue;
let d = (seed[0] - jx) ** 2 + (seed[1] - jy) ** 2;
if (dist.has(nkey) && dist.get(nkey) < d) continue;
dist.set(nkey, d);
newCells.set(nkey, { seed, angle });
}
}
}
cells = newCells;
};
if (n < 2) return;
for (let i = 0; i < n; i++) {
let p = stroke[i];
let prev = i > 0 ? stroke[i - 1] : p;
let next = i + 1 < n ? stroke[i + 1] : p;
let v = next.sub(prev).normalize();
let [ix, iy] = field.getCellIndex(p.x, p.y);
let key = [ix, iy].toString();
visited.add(key);
let angle = Math.atan2(v.y, v.x);
cells.set(key, { seed: [ix, iy], angle });
}
for (let i = 0; i < width / field.cellSize && cells.size > 0; i++) {
modify();
propagate();
}
}
Insert cell
//
// Support for non-intersecting field curves
//
class FieldCurveTrace {
constructor(field) {
this.field = field;
[this.width, this.height] = [field.width, field.height];
this.collisionCtx = DOM.context2d(field.width, field.height, 1);
}
empty(p) {
let pixel = this.collisionCtx.getImageData(p.x, p.y, 1, 1);
let data = pixel.data;
return data[3] == 0;
}
sampleCurve(p, stepLength = 1, numSteps = 2, thick = 1) {
let q = p.clone();
let n = numSteps >> 1;
let k = thick >> 1;
let curve = new Curve();
if (!this.empty(p)) return curve;
while (--n > 0) {
let angle = this.field.getField(p.x, p.y);
let v = Vec(1, 0).rotate(angle).scale(stepLength);
p = p.add(v);
if (!this.empty(p)) break;
let u1 = Vec(1, 0).rotate(angle + Math.PI / 2);
let u2 = Vec(1, 0).rotate(angle - Math.PI / 2);
let good = true;
for (let i = 1; i <= k && good; i++) {
good &&=
this.empty(p.add(u1.scale(i))) && this.empty(p.add(u2.scale(i)));
}
if (!good) break;
curve.push(p);
}
curve = curve.reverse();
n = numSteps - (numSteps >> 1);
while (--n > 0) {
let angle = this.field.getField(q.x, q.y);
let v = Vec(-1, 0).rotate(angle).scale(stepLength);
q = q.add(v);
if (!this.empty(q)) break;
let u1 = Vec(-1, 0).rotate(angle + Math.PI / 2);
let u2 = Vec(-1, 0).rotate(angle - Math.PI / 2);
let good = true;
for (let i = 1; i <= k && good; i++) {
good &&=
this.empty(q.add(u1.scale(i))) && this.empty(q.add(u2.scale(i)));
}
if (!good) break;
curve.push(q);
}
return curve;
}
markCurve(curve, thick) {
this.collisionCtx.beginPath();
this.collisionCtx.lineWidth = thick;
for (let { x, y } of curve) this.collisionCtx.lineTo(x, y);
this.collisionCtx.stroke();
}
}
Insert cell
//
// Makes a copy of field with all angles rotated 90 degrees
//
function crossField(field) {
let cross = new GridField(field.width, field.height, field.cellSize);
for (let i = 0; i < field.nx; i++)
for (let j = 0; j < field.ny; j++)
cross.setCell(i, j, field.getCell(i, j) + Math.PI / 2);
return cross;
}
Insert cell
Insert cell
function drawColoredCurve(
ctx,
curve,
segmentColor = (i) => "black",
segmentShape = (i) => 1
) {
let up = new Curve();
let down = new Curve();
let c = segmentColor(0, curve);

let draw = () => {
ctx.fillStyle = c;
ctx.beginPath();
for (let p of down.concat(up.reverse())) ctx.lineTo(p.x, p.y);
ctx.closePath();
ctx.fill();
};
let n = curve.length;
let thick = ctx.lineWidth / 2;
for (let i = 0; i < curve.length; i++) {
let prev = curve[Math.max(i - 1, 0)];
let next = curve[Math.min(i + 1, n - 1)];
let norm = next
.sub(prev)
.normalize()
.rotate(Math.PI / 2)
.scale(thick);
let p = curve[i];
let nextc = segmentColor(i, curve);
let [u, d] = [
p.add(norm.scale(segmentShape(i))),
p.sub(norm.scale(segmentShape(i)))
];
up.push(u);
down.push(d);
if (nextc != c) {
draw();
up = new Curve(u);
down = new Curve(d);
c = nextc;
}
}
draw();
}
Insert cell
function colorByArcLen(curve, colors) {
let q = curve.arcLength();
let rate = colors.length / q[curve.length - 1];
return (i) => colors[~~(q[i] * rate)];
}
Insert cell
function colorByCurvature(curve, colors, exponent = 1, interpolate = false) {
let curvature = [0];
let n = curve.length;
for (let i = 1; i + 1 < n; i++) {
let u = curve[i].sub(curve[i - 1]).normalize();
let v = curve[i].sub(curve[i + 1]).normalize();
curvature[i] = 1 - Math.pow(Math.abs(u.dot(v)), exponent);
}
if (n > 1) {
curvature[0] = curvature[1];
curvature[n - 1] = curvature[n - 2];
}
let ncolors = colors.length;
if (interpolate) {
let rate = (1 / (ncolors - 1)) * 1.0000001;
return (i) => {
let j = ~~(curvature[i] / rate);
let t = curvature[i] / rate - j;
return d3.interpolateHsl(colors[j], colors[j + 1])(t);
};
} else {
let rate = 1 / ncolors;
return (i) => colors[~~(curvature[i] / rate)];
}
}
Insert cell
function shapeByArcLen(curve, offsets) {
let q = curve.arcLength();
let len = q[curve.length - 1];
let nranges = offsets.length;
let rate = len / (nranges - 1);
return (i) => {
let j = ~~(q[i] / rate);
let t = q[i] - j * rate;
return offsets[j] * (1 - t) + offsets[j + 1] * t;
};
}
Insert cell
function shapeByArcLen2(curve, offsets) {
let q = curve.arcLength();
let len = q[curve.length - 1];
let nranges = offsets.length;
let rate = (len / (nranges - 1)) * 1.00000001;
return (i) => {
let j = ~~(q[i] / rate);
let t = q[i] / rate - j;
return offsets[j] * (1 - t) + offsets[j + 1] * t;
};
}
Insert cell
Insert cell
import { Vec, Curve } from "@esperanc/2d-geometry-utils"
Insert cell
import { color as ColorInput } from "@esperanc/color-input"
Insert cell
import { paletteBuilder } from "@esperanc/color-palettes-from-one-color"
Insert cell
import {
angleLerp,
GridField,
poissonSampling,
gridSampling,
randomSampling,
fieldCurve,
drawCurve
} from "@esperanc/flow-fields"
Insert cell
import { combo, paged, tabbed } from "@esperanc/aggregated-inputs"
Insert cell
simplexNoise = require("simplex-noise@2.4.0")
Insert cell
simplex = new simplexNoise("1")
Insert cell
//
// In case you're wondering, you can't just
// say simplex2D = simplex.noise2D,
//
simplex2D = (function () {
function f(x, y) {
return simplex.noise2D(x, y);
}
return f;
})()
Insert cell
import { octave } from "@mbostock/perlin-noise"
Insert cell
import { harmonicColors, paletteDisplay } from "@esperanc/color-harmonies"
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more