Published
Edited
Mar 1, 2020
9 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
w = width
Insert cell
h = ((im.height / im.width) * w) | 0
Insert cell
// decouple resolution (stipples per area) from ink density (stipple radius)
class Stippling {
constructor(densityGrid, resolutionGrid, minRadius, maxRadius) {
this.densityGrid = densityGrid;
this.resolutionGrid = resolutionGrid;
// assumes widh and height are the same for both grids
this.width = resolutionGrid.width;
this.height = resolutionGrid.height;
this.densityGridExtent = d3.extent(densityGrid.values);
this.resolutionGridExtent = d3.extent(resolutionGrid.values);
this.radiusExtent = [minRadius, maxRadius];
this.resolutionDensityToRadius = d3
.scaleLinear()
.domain(this.resolutionGridExtent)
.range(this.radiusExtent);
this.inkDensityToRadius = d3
.scaleLinear()
.domain(this.densityGridExtent)
.range(this.radiusExtent);
this.threshold = 0.4;
this.delta_threshold = 0.01;
// we initialize the algorithm with random stipples - 1 per 25x25 pixels
this.stipples = this.random_stipples(this.width*this.height / 625 | 0);
}

// returns the thresholds for split and delete
thresholds(radius) {
const area = Math.PI * radius * radius;
return [
(1.0 + this.threshold / 2.0) * area,
(1.0 - this.threshold / 2.0) * area
];
}

// generates `num` random stipples on top of the grid.
random_stipples(num) {
const stipples = new Array(num);
const xran = d3.randomUniform(0, this.width);
const yran = d3.randomUniform(0, this.height);
for (var i = 0; i < num; ++i) {
stipples[i] = [xran(), yran()];
stipples[i].radius = this.radiusExtent[0];
}
return stipples;
}

// performs one iteration of the stippling algorithm
iterate() {
const delaunay = d3.Delaunay.from(this.stipples),
voronoi = delaunay.voronoi([0, 0, this.width, this.height]);

// initialize densities
for(let i = 0; i < this.stipples.length; i++) {
const st = this.stipples[i];
st.radiusDensity = 0;
st.resolutionDensity = 0;
st.moment10 = 0;
st.moment01 = 0;
st.moment11 = 0;
st.moment20 = 0;
st.moment02 = 0;
}

// compute the density and the weighted centroid of each cell
var found = 0;
for(let y = 0, {stipples, width} = this, dValues = this.densityGrid.values, rValues = this.resolutionGrid.values; y < this.height; y++) {
const line = y * width;
for(let x = 0; x < width; x++) {

found = delaunay.find(x, y, found);
const st = stipples[found];
const densityVal = dValues[x + line];
const resolutionVal = rValues[x + line];
st.radiusDensity += densityVal;
st.resolutionDensity += resolutionVal; // Moment00
const xval = x*resolutionVal;
const yval = y*resolutionVal;
st.moment10 += xval
st.moment01 += yval
st.moment11 += x * yval;
st.moment20 += x * xval;
st.moment02 += y * yval;
}
}

const {
// resolutionGridExtent,
// densityGridExtent,
resolutionDensityToRadius,
inkDensityToRadius
} = this;

const resolutionGridMax = this.resolutionGridExtent[1];
let deleted = 0;
let splitted = 0

const newStipples = [];
const {abs, atan2, cos, sin, sqrt, PI } = Math;
for (let i = 0; i < this.stipples.length; i++) {
const polygon = voronoi.cellPolygon(i);
if (!polygon) continue;
const st = this.stipples[i];
const density = st.resolutionDensity;

// determine point size based on average intensity
const area = abs(d3.polygonArea(polygon)) || 1;
const avgDensity = density / area;
const resolutionRadius = resolutionDensityToRadius(avgDensity);

const resolutionArea = PI * resolutionRadius * resolutionRadius * resolutionGridMax;
const splitThreshold = (1.0 + this.threshold / 2.0) * resolutionArea;
const deleteThreshold = (1.0 - this.threshold / 2.0) * resolutionArea;

const renderRadius = inkDensityToRadius(st.radiusDensity / area);

if (density < deleteThreshold) {
deleted++;
} else if (density > splitThreshold) {
// Split
splitted++;
const centroid = d3.polygonCentroid(polygon);
const cx = centroid[0];
const cy = centroid[1];

const dist = sqrt(area / PI) / 2.0;

const x = st.moment20 / density - cx * cx;
const y = 2 * (st.moment11 / density - cx * cy);
const z = st.moment02 / density - cy * cy;

var orientation = atan2(y, x - z) / 2.0;

var deltaX = dist * cos(orientation);
var deltaY = dist * sin(orientation);

st[0] = cx + deltaX;
st[1] = cy + deltaY;
st.radius = renderRadius;
// re-use arrays to reduce GC pressure
centroid[0] -= deltaX;
centroid[1] -= deltaY;
centroid.radius = renderRadius;
newStipples.push(st, centroid);
} else {
// Relax
st[0] = st.moment10 / density;
st[1] = st.moment01 / density;
st.radius = renderRadius;
newStipples.push(st);
}
}

// we increase the threshold with each iteration for faster convergence
this.threshold = this.threshold + this.delta_threshold;
this.stipples = newStipples;
// is done?
return splitted + deleted === 0;
}
}
Insert cell
Insert cell
Insert cell
Insert cell
import {file, number} from "@jashkenas/inputs"
Insert cell
GIF = {
const gif = await require("gif.js@0.2");
const workerScript = await fetch("https://unpkg.com/gif.js@0.2/dist/gif.worker.js")
.then(response => response.blob())
.then(blob => URL.createObjectURL(blob, {type: "text/javascript"}));
function GIF(options) { gif.call(this, {workerScript, ...options}); }
GIF.prototype = Object.create(gif.prototype);
return GIF;
}
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