block by Kcnarf 81f4ce6a76abe132427a29b1519caee8

Update & Animate a Voronoï map

Full Screen

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:

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

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

User interactions :

Acknowledgments to :

index.html

<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>