Skip to content

Instantly share code, notes, and snippets.

@Kcnarf
Last active September 29, 2020 13:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Kcnarf/81f4ce6a76abe132427a29b1519caee8 to your computer and use it in GitHub Desktop.
Save Kcnarf/81f4ce6a76abe132427a29b1519caee8 to your computer and use it in GitHub Desktop.
Update & Animate a Voronoï map
license: gpl-3.0
border: no

This block (a continuation of a previous one) shows how to update an existing Voronoï map from an old set of weights to a set of new ones (in this demo, each weight is slightly updated). This is done thanks to the use of the initialPosition() API in combination with the initialWeight() API, that allows to reuse previously computed coordinates and weights (cf. the updateData function, and the first part of the loop function).

This technique offers several advantages:

  • it allows to maintain each cell in the same region from one Voronoï map to another, leading to a smooth and human friendly transitioning
  • it comes with performance enhancement, as the final layout is no longer computed from scratch and profits from the former computation

Also, it is compatible with both the live and static arrangement introduced in the v2 major release of the d3-voronoi-map plugin:

  • live Voronoï map: displays the evolution of the self-organizing Voronoï map; each iteration is displayed, with some delay between iterations so that the animation is appealing to human eyes;
  • static Voronoï map: displays only the final most representative Voronoï map, which is faster than the live use case; intermediate iterations are silently computed, one after each other, without any delay.

Take a look at the code of the 'loop' function to understand how to obtain the live and satic arrangement.

User interactions :

  • you can choose to use visualize the live arrangement (default), or simply the static final arrangement.
  • you can choose to draw the Weighted Voronoï Diagram (default), the weights (visualized as circles), or both.
  • you can hide/show sites (hide by default)
  • you can choose among different rendering (greyscale, radial rainbow, or conical rainbow (default, having hard-&-fun time to implement it because canvas don't provides conical gradient)).

Acknowledgments to :

<html lang="en">
<head>
<meta charset="utf-8" />
<title>Animated Voronoï map 2</title>
<meta name="description" content="Update & Animate a Voronoi map in D3.js">
<script src="https://d3js.org/d3.v6.min.js"></script>
<script src="https://rawcdn.githack.com/Kcnarf/d3-weighted-voronoi/v1.0.0/build/d3-weighted-voronoi.js"></script>
<script src="https://rawcdn.githack.com/Kcnarf/d3-voronoi-map/v2.0.1/build/d3-voronoi-map.js"></script>
<style>
#layouter {
text-align: center;
position: relative;
}
#wip {
display: none;
position: absolute;
top: 200px;
left: 330px;
font-size: 40px;
text-align: center;
}
.control {
position: absolute;
}
.control.top{
top: 5px;
}
.control.bottom {
bottom: 5px;
}
.control.left{
left: 5px;
}
.control.right {
right: 5px;
}
.control.right div{
text-align: right;
}
.control.left div{
text-align: left;
}
.control .separator {
height: 5px;
}
canvas {
margin: 1px;
border-radius: 1000px;
box-shadow: 2px 2px 6px grey;
}
canvas#background-image, canvas#alpha {
display: none;
}
</style>
</head>
<body>
<div id="layouter">
<canvas id="background-image"></canvas>
<canvas id="alpha"></canvas>
<canvas id="colored"></canvas>
<div class="control top left">
<div>
<input id="arrangement" type="radio" name="arrangement" value="live" checked onchange="arrangementUpdated('live')"> live arrangement
</div>
<div>
<input id="arrangement" type="radio" name="arrangement" value="static" onchange="arrangementUpdated('static')"> static
</div>
</div>
<div class="control top right">
<div>
Weighted Voronoï <input id="cellsOrWeights" type="radio" name="cellsOrWeights" value="cells" checked onchange="cellsOrWeightsUpdated('cells')">
</div>
<div>
Weights <input id="cellsOrWeights" type="radio" name="cellsOrWeights" value="circles" onchange="cellsOrWeightsUpdated('weights')">
</div>
<div>
Both <input id="cellsOrWeights" type="radio" name="cellsOrWeights" value="circles" onchange="cellsOrWeightsUpdated('cellsAndWeights')">
</div>
</div>
<div class="control bottom left">
<div>
<input id="showSites" type="checkbox" name="showSites" onchange="siteVisibilityUpdated()"> Show sites
</div>
</div>
<div class="control bottom right">
<div>
Grey <input id="bgImgGrey" type="radio" name="bgImg" onchange="bgImgUpdated('grey')">
</div>
<div>
Radial rainbow <input id="bgImgRadialRainbow" type="radio" name="bgImg" onchange="bgImgUpdated('radialRainbow')">
</div>
<div>
Canonical rainbow <input id="bgImgCanonicalRainbow" type="radio" name="bgImg" checked onchange="bgImgUpdated('canonicalRainbow')">
</div>
</div>
<div id="wip">
Work in progress ...
</div>
</div>
<script>
var _PI = Math.PI,
_2PI = 2*Math.PI,
sqrt = Math.sqrt,
sqr = function(d) { return Math.pow(d,2); }
epsilon = 1;
//begin: layout conf.
var totalWidth = 550,
totalHeight = 500,
controlsHeight = 0,
canvasbw = 1, // canvas border width
canvasbs = 8, // canvas box-shadow
radius = (totalHeight-controlsHeight-canvasbs-2*canvasbw)/2,
width = 2*radius,
height = 2*radius,
halfRadius = radius/2
halfWidth = halfRadius,
halfHeight = halfRadius,
quarterRadius = radius/4;
quarterWidth = quarterRadius,
quarterHeight = quarterRadius;
//end: layout conf.
//begin: drawing conf.
var arrangementType = "live",
drawCellsOrWeights = "cells",
drawSites = false,
bgType = "canonicalRainbow",
bgImgCanvas, alphaCanvas, coloredCanvas,
bgImgContext, alphaContext, coloredContext,
radialGradient;
//end: drawing conf.
//begin: init layout
initLayout()
//end: init layout
//begin: data conf.
var siteCount = 20,
baseWeight = 10,
outlierCount = siteCount/10,
outlierWeight = 3*baseWeight,
baseWeightUpdate = baseWeight/2;
var data, previousData;
//end: data conf.
//begin: Voronoï map conf.
var clippingPolygon = computeClippingPolygon();
var simulation;
//end: Voronoï map conf.
function computeClippingPolygon() {
var circlingPolygon = [];
for (a=0; a<_2PI; a+=_2PI/60) {
circlingPolygon.push(
[radius + (radius-1)*Math.cos(a), radius + (radius-1)*Math.sin(a)]
)
}
return circlingPolygon;
};
//begin: user interaction handlers
function arrangementUpdated(type) {
arrangementType = type;
};
function cellsOrWeightsUpdated(type) {
drawCellsOrWeights = type;
};
function siteVisibilityUpdated() {
drawSites = d3.select("#showSites").node().checked;
};
function bgImgUpdated(newType) {
bgType = newType;
resetBackgroundImage() ;
};
//end: user interaction handlers
function initData() {
var weight;
data = [];
for (i=0; i<siteCount; i++) {
weight = (0+1*sqr(Math.random()))*baseWeight;
// weight = (i+1)*baseWeight; // +1: weights of 0 are not handled
// weight = i+1; // +1: weights of 0 are not handled
data.push({
index: i,
weight: weight,
previousX: NaN,
previousY: NaN,
previousWeight: NaN
});
}
for (i=0; i<outlierCount; i++) {
data[i].weight = outlierWeight;
}
};
function updateData() {
var previousDatum, previousPolygonByIndex, previousPolygon, updatedWeight;
previousData = data;
data = [];
previousPolygonByIndex = {};
simulation.state().polygons.forEach((p)=>{
previousPolygonByIndex[p.site.originalObject.data.originalData.index]=p
})
for (i=0; i<siteCount; i++) {
previousDatum = previousData[i];
previousPolygon = previousPolygonByIndex[i];
updatedWeight = 0;
while (updatedWeight <= 0) {
updatedWeight = previousDatum.weight + (Math.random()-0.5)*baseWeightUpdate;
}
data.push({
index: i,
weight: updatedWeight,
previousX: previousPolygon.site.x, //pick previously computed X coord
previousY: previousPolygon.site.y, //pick previously computed Y coord
previousWeight: previousPolygon.site.weight //pick previously computed weight
});
}
};
function loop (firstFrame) {
if (firstFrame) {
initData();
simulation = d3.voronoiMapSimulation(data)
.clip(clippingPolygon);
//there is no initial positions for the intial data, so the default positioning policy of d3-voronoi-map is used (i.e. Math.random)
//there is no intial weight for the initial data, so the default weight function of d3-voronoi-map is used (i.e. an average weighting)
} else {
updateData();
simulation = d3.voronoiMapSimulation(data)
.clip(clippingPolygon)
.initialPosition((d)=>[d.previousX, d.previousY])
.initialWeight((d)=>d.previousWeight);
}
resetCanvas();
if (arrangementType == "live") {
simulation
.on("tick", function() {
// function called after each iteration of computation
// called only in simulation mode, not in static mode
redraw(simulation.state().polygons);
})
.on("end", function() {
setTimeout(function(){
finalize(simulation.state().polygons, 20);
}, 50);
});
} else {
simulation.stop() // immedialty interupts the simulation
var state = simulation.state(); // retrieve the simulation's state, i.e. {ended, polygons, iterationCount, convergenceRatio}
//begin: manually launch each iteration until the simulation ends
while (!state.ended) {
simulation.tick();
state = simulation.state();
}
//end:manually launch each iteration until the simulation ends
redraw(simulation.state().polygons);
setTimeout(loop.bind(null, false), 1750);
}
}
function finalize(polygons, countDown) {
//used to fade intermediate cells
redraw(polygons);
if (countDown === 0) {
setTimeout(loop.bind(null, false), 1750);
} else {
setTimeout(function(){
finalize(polygons, countDown-1);
}, 50);
}
}
loop(true);
/********************************/
/* Drawing functions */
/* Playing with canvas :-) */
/* */
/* Experiment to draw */
/* with a uniform color, */
/* or with a radial rainbow, */
/* or over a background image */
/* (e.g. canonical rainbow) */
/********************************/
function initLayout() {
d3.select("#layouter").style("width", totalWidth+"px").style("height", totalHeight+"px");
d3.selectAll("canvas").attr("width", width).attr("height", height);
bgImgCanvas = document.querySelector("canvas#background-image");
bgImgContext = bgImgCanvas.getContext("2d");
alphaCanvas = document.querySelector("canvas#alpha");
alphaContext = alphaCanvas.getContext("2d");
coloredCanvas = document.querySelector("canvas#colored");
coloredContext = coloredCanvas.getContext("2d");
//begin: set a radial rainbow
radialGradient = coloredContext.createRadialGradient(radius, radius, 0, radius, radius, radius);
var gradientStopNumber = 10,
stopDelta = 0.9/gradientStopNumber;
hueDelta = 360/gradientStopNumber,
stop = 0.1,
hue = 0;
while (hue<360) {
radialGradient.addColorStop(stop, d3.hsl(Math.abs(hue+160), 1, 0.45));
stop += stopDelta;
hue += hueDelta;
}
//end: set a radial rainbow
//begin: set the background image
resetBackgroundImage();
//end: set the initial background image
}
function resetCanvas() {
alphaContext.clearRect(0, 0, width, height);
}
function resetBackgroundImage() {
if (bgType==="canonicalRainbow") {
//begin: make conical rainbow gradient
var imageData = bgImgContext.getImageData(0, 0, 2*radius, 2*radius);
var i = -radius,
j = -radius,
pixel = 0,
radToDeg = 180/Math.PI;
var aRad, aDeg, rgb;
while (i<radius) {
j = -radius;
while (j<radius) {
aRad = Math.atan2(j, i);
aDeg = aRad*radToDeg;
rgb = d3.hsl(aDeg, 1, 0.45).rgb();
imageData.data[pixel++] = rgb.r;
imageData.data[pixel++] = rgb.g;
imageData.data[pixel++] = rgb.b;
imageData.data[pixel++] = 255;
j++;
}
i++;
}
bgImgContext.putImageData(imageData, 0, 0);
//end: make conical rainbow gradient
} else if (bgType==="radialRainbow") {
bgImgContext.fillStyle = radialGradient;
bgImgContext.fillRect(0, 0, width, height);
} else {
bgImgContext.fillStyle = "grey";
bgImgContext.fillRect(0, 0, width, height);
}
}
function redraw(polygons) {
// At each iteration:
// 1- update the 'alpha' canvas
// 1.1- fade 'alpha' canvas to simulate passing time
// 1.2- add the new tessellation/weights to the 'alpha' canvas
// 2- blend 'background-image' and 'alpha' => produces colorfull rendering
alphaContext.lineWidth= 2;
fade();
alphaContext.beginPath();
//begin: add the new tessellation/weights (to the 'grey-scale' canvas)
if (drawCellsOrWeights==="cellsAndWeights") {
alphaContext.globalAlpha = 0.5;
polygons.forEach(function(polygon){
addCell(polygon);
});
alphaContext.globalAlpha = 0.2;
polygons.forEach(function(polygon){
addWeight(polygon.site);
});
} else if (drawCellsOrWeights==="cells") {
alphaContext.globalAlpha = 0.5;
polygons.forEach(function(polygon){
addCell(polygon);
});
} else {
alphaContext.globalAlpha = 0.2;
polygons.forEach(function(polygon){
addWeight(polygon.site);
});
}
//end: add the new tessellation/weights (to the 'grey-scale' canvas)
if (drawSites) {
//begin: add sites (to the 'grey-scale' canvas)
alphaContext.globalAlpha = 1;
polygons.forEach(function(polygon){
addSite(polygon.site);
});
//end: add sites (to the 'grey-scale' canvas)
}
alphaContext.stroke();
//begin: use 'background-image' to color pixels of the 'grey-scale' canvas
coloredContext.clearRect(0, 0, width, height);
coloredContext.globalCompositeOperation = "source-over";
coloredContext.drawImage(bgImgCanvas, 0, 0);
coloredContext.globalCompositeOperation = "destination-in";
coloredContext.drawImage(alphaCanvas, 0, 0);
//end: use 'background-image' to color pixels of the 'grey-scale' canvas
}
function addCell(polygon) {
alphaContext.moveTo(polygon[0][0], polygon[0][1]);
polygon.slice(1).forEach(function(vertex){
alphaContext.lineTo(vertex[0], vertex[1]);
});
alphaContext.lineTo(polygon[0][0], polygon[0][1]);
}
function addWeight(site) {
var radius = sqrt(site.weight);
alphaContext.moveTo(site.x+radius, site.y);
alphaContext.arc(site.x, site.y, radius, 0, _2PI);
}
function addSite(site) {
alphaContext.moveTo(site.x, site.y);
alphaContext.arc(site.x, site.y, 1, 0, _2PI);
// alphaContext.fillText(Math.round(site.weight), site.x, site.y);
}
function fade() {
var imageData = alphaContext.getImageData(0, 0, width, height);
for (var i = 3, l = imageData.data.length; i < l; i += 4) {
imageData.data[i] = Math.max(0, imageData.data[i] - 10);
}
alphaContext.putImageData(imageData, 0, 0);
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment