Skip to content

Instantly share code, notes, and snippets.

@Kcnarf
Last active November 2, 2022 14:33
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/89d9d2d575f5c4ad41235cad6b202742 to your computer and use it in GitHub Desktop.
Save Kcnarf/89d9d2d575f5c4ad41235cad6b202742 to your computer and use it in GitHub Desktop.
d3-voronoi-map v2 usage
license: mit

This block illustrates the use of the d3-voronoi-map plugin. It enhances a previous block by using the version 2 of the plugin, which is capable of displaying the self-organizing arrangement of the Voronoï map. This block is a remake of the HowMuch.net's post The Costs of Being Fat, in Actual Dollars.

The d3-voronoi-map plugin produces Voronoï maps (one-level treemap). Given a convex polygon (here, a 60-gon simulating a circle for each gender) and weighted data, it tesselates/partitions the polygon in several inner cells, such that the area of a cell represents the weight of the underlying datum.

This block always produces the same Voronoï map on reload thanks to the initialPosition() API. This API allows to define the initial positions of each sites, before launching the iterative computation of the Voronoï map. By default, a random positioning is used, which leads to distinct final Vornoï maps on each reload. By setting the initial sites' positions in a repeatable way, reloadings produce always the same final Voronoï map.

In this particular block, controlling initial positions of sites also helps to make the two Voronoï maps (men/women) having the same layout (e.g. placing sites/cells of the same type at the same position), which eases comparison.

Acknowledgments to :

id composition menCost womenCost color
0 Wage Discrimination 0 1855 #b5a8d8
1 Direct Medical 1474 1474 #bfe5df
2 Short-term Disability 389 309 #a3c5cb
3 Productivity (Presenteeism) 358 358 #abb6ab
4 Sick Leave (Absenteeism) 212 674 #b7d8a9
5 Life Insurance 121 121 #ffe7a4
6 Disability Pension Insurance 69 69 #f7c098
7 Gasoline for cars 23 21 #f3a39c
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-polygon'), require('d3-timer'), require('d3-dispatch'), require('d3-weighted-voronoi')) :
typeof define === 'function' && define.amd ? define(['exports', 'd3-polygon', 'd3-timer', 'd3-dispatch', 'd3-weighted-voronoi'], factory) :
(factory((global.d3 = global.d3 || {}),global.d3,global.d3,global.d3,global.d3));
}(this, function (exports,d3Polygon,d3Timer,d3Dispatch,d3WeightedVoronoi) { 'use strict';
function FlickeringMitigation() {
/////// Inputs ///////
this.growthChangesLength = DEFAULT_LENGTH;
this.totalAvailableArea = NaN;
//begin: internals
this.lastAreaError = NaN;
this.lastGrowth = NaN;
this.growthChanges = [];
this.growthChangeWeights = generateGrowthChangeWeights(this.growthChangesLength); //used to make recent changes weighter than older changes
this.growthChangeWeightsSum = computeGrowthChangeWeightsSum(this.growthChangeWeights);
//end: internals
}
const DEFAULT_LENGTH = 10;
function direction(h0, h1) {
return h0 >= h1 ? 1 : -1;
}
function generateGrowthChangeWeights(length) {
const initialWeight = 3; // a magic number
const weightDecrement = 1; // a magic number
const minWeight = 1;
let weightedCount = initialWeight;
const growthChangeWeights = [];
for (let i = 0; i < length; i++) {
growthChangeWeights.push(weightedCount);
weightedCount -= weightDecrement;
if (weightedCount < minWeight) {
weightedCount = minWeight;
}
}
return growthChangeWeights;
}
function computeGrowthChangeWeightsSum(growthChangeWeights) {
let growthChangeWeightsSum = 0;
for (let i = 0; i < growthChangeWeights.length; i++) {
growthChangeWeightsSum += growthChangeWeights[i];
}
return growthChangeWeightsSum;
}
///////////////////////
///////// API /////////
///////////////////////
FlickeringMitigation.prototype.reset = function() {
this.lastAreaError = NaN;
this.lastGrowth = NaN;
this.growthChanges = [];
this.growthChangesLength = DEFAULT_LENGTH;
this.growthChangeWeights = generateGrowthChangeWeights(this.growthChangesLength);
this.growthChangeWeightsSum = computeGrowthChangeWeightsSum(this.growthChangeWeights);
this.totalAvailableArea = NaN;
return this;
};
FlickeringMitigation.prototype.clear = function() {
this.lastAreaError = NaN;
this.lastGrowth = NaN;
this.growthChanges = [];
return this;
};
FlickeringMitigation.prototype.length = function(_) {
if (!arguments.length) {
return this.growthChangesLength;
}
if (parseInt(_) > 0) {
this.growthChangesLength = Math.floor(parseInt(_));
this.growthChangeWeights = generateGrowthChangeWeights(this.growthChangesLength);
this.growthChangeWeightsSum = computeGrowthChangeWeightsSum(this.growthChangeWeights);
} else {
console.warn('FlickeringMitigation.length() accepts only positive integers; unable to handle ' + _);
}
return this;
};
FlickeringMitigation.prototype.totalArea = function(_) {
if (!arguments.length) {
return this.totalAvailableArea;
}
if (parseFloat(_) > 0) {
this.totalAvailableArea = parseFloat(_);
} else {
console.warn('FlickeringMitigation.totalArea() accepts only positive numbers; unable to handle ' + _);
}
return this;
};
FlickeringMitigation.prototype.add = function(areaError) {
let secondToLastAreaError, secondToLastGrowth;
secondToLastAreaError = this.lastAreaError;
this.lastAreaError = areaError;
if (!isNaN(secondToLastAreaError)) {
secondToLastGrowth = this.lastGrowth;
this.lastGrowth = direction(this.lastAreaError, secondToLastAreaError);
}
if (!isNaN(secondToLastGrowth)) {
this.growthChanges.unshift(this.lastGrowth != secondToLastGrowth);
}
if (this.growthChanges.length > this.growthChangesLength) {
this.growthChanges.pop();
}
return this;
};
FlickeringMitigation.prototype.ratio = function() {
let weightedChangeCount = 0;
let ratio;
if (this.growthChanges.length < this.growthChangesLength) {
return 0;
}
if (this.lastAreaError > this.totalAvailableArea / 10) {
return 0;
}
for (let i = 0; i < this.growthChangesLength; i++) {
if (this.growthChanges[i]) {
weightedChangeCount += this.growthChangeWeights[i];
}
}
ratio = weightedChangeCount / this.growthChangeWeightsSum;
if (ratio > 0) {
console.log('flickering mitigation ratio: ' + Math.floor(ratio * 1000) / 1000);
}
return ratio;
};
function randomInitialPosition() {
//begin: internals
let clippingPolygon, extent, minX, maxX, minY, maxY, dx, dy;
//end: internals
///////////////////////
///////// API /////////
///////////////////////
function _random(d, i, arr, voronoiMapSimulation) {
let shouldUpdateInternals = false;
let x, y;
if (clippingPolygon !== voronoiMapSimulation.clip()) {
clippingPolygon = voronoiMapSimulation.clip();
extent = voronoiMapSimulation.extent();
shouldUpdateInternals = true;
}
if (shouldUpdateInternals) {
updateInternals();
}
x = minX + dx * voronoiMapSimulation.prng()();
y = minY + dy * voronoiMapSimulation.prng()();
while (!d3Polygon.polygonContains(clippingPolygon, [x, y])) {
x = minX + dx * voronoiMapSimulation.prng()();
y = minY + dy * voronoiMapSimulation.prng()();
}
return [x, y];
}
///////////////////////
/////// Private ///////
///////////////////////
function updateInternals() {
minX = extent[0][0];
maxX = extent[1][0];
minY = extent[0][1];
maxY = extent[1][1];
dx = maxX - minX;
dy = maxY - minY;
}
return _random;
}
function pie() {
//begin: internals
let startAngle = 0;
let clippingPolygon, dataArray, dataArrayLength, clippingPolygonCentroid, halfIncircleRadius, angleBetweenData;
//end: internals
///////////////////////
///////// API /////////
///////////////////////
function _pie(d, i, arr, voronoiMapSimulation) {
let shouldUpdateInternals = false;
if (clippingPolygon !== voronoiMapSimulation.clip()) {
clippingPolygon = voronoiMapSimulation.clip();
shouldUpdateInternals |= true;
}
if (dataArray !== arr) {
dataArray = arr;
shouldUpdateInternals |= true;
}
if (shouldUpdateInternals) {
updateInternals();
}
// add some randomness to prevent colinear/cocircular points
// substract -0.5 so that the average jitter is still zero
return [
clippingPolygonCentroid[0] +
Math.cos(startAngle + i * angleBetweenData) * halfIncircleRadius +
(voronoiMapSimulation.prng()() - 0.5) * 1e-3,
clippingPolygonCentroid[1] +
Math.sin(startAngle + i * angleBetweenData) * halfIncircleRadius +
(voronoiMapSimulation.prng()() - 0.5) * 1e-3
];
}
_pie.startAngle = function(_) {
if (!arguments.length) {
return startAngle;
}
startAngle = _;
return _pie;
};
///////////////////////
/////// Private ///////
///////////////////////
function updateInternals() {
clippingPolygonCentroid = d3Polygon.polygonCentroid(clippingPolygon);
halfIncircleRadius = computeMinDistFromEdges(clippingPolygonCentroid, clippingPolygon) / 2;
dataArrayLength = dataArray.length;
angleBetweenData = (2 * Math.PI) / dataArrayLength;
}
function computeMinDistFromEdges(vertex, clippingPolygon) {
let minDistFromEdges = Infinity;
let edgeIndex = 0,
edgeVertex0 = clippingPolygon[clippingPolygon.length - 1],
edgeVertex1 = clippingPolygon[edgeIndex];
let distFromCurrentEdge;
while (edgeIndex < clippingPolygon.length) {
distFromCurrentEdge = vDistance(vertex, edgeVertex0, edgeVertex1);
if (distFromCurrentEdge < minDistFromEdges) {
minDistFromEdges = distFromCurrentEdge;
}
edgeIndex++;
edgeVertex0 = edgeVertex1;
edgeVertex1 = clippingPolygon[edgeIndex];
}
return minDistFromEdges;
}
//from https://stackoverflow.com/questions/849211/shortest-distance-between-a-point-and-a-line-segment
function vDistance(vertex, edgeVertex0, edgeVertex1) {
const x = vertex[0],
y = vertex[1],
x1 = edgeVertex0[0],
y1 = edgeVertex0[1],
x2 = edgeVertex1[0],
y2 = edgeVertex1[1];
const A = x - x1,
B = y - y1,
C = x2 - x1,
D = y2 - y1;
const dot = A * C + B * D;
const len_sq = C * C + D * D;
let param = -1;
if (len_sq != 0)
//in case of 0 length line
param = dot / len_sq;
let xx, yy;
if (param < 0) {
// this should not arise as clippingpolygon is convex
xx = x1;
yy = y1;
} else if (param > 1) {
// this should not arise as clippingpolygon is convex
xx = x2;
yy = y2;
} else {
xx = x1 + param * C;
yy = y1 + param * D;
}
const dx = x - xx;
const dy = y - yy;
return Math.sqrt(dx * dx + dy * dy);
}
return _pie;
}
function halfAverageAreaInitialWeight() {
//begin: internals
let clippingPolygon, dataArray, siteCount, totalArea, halfAverageArea;
//end: internals
///////////////////////
///////// API /////////
///////////////////////
function _halfAverageArea(d, i, arr, voronoiMapSimulation) {
let shouldUpdateInternals = false;
if (clippingPolygon !== voronoiMapSimulation.clip()) {
clippingPolygon = voronoiMapSimulation.clip();
shouldUpdateInternals |= true;
}
if (dataArray !== arr) {
dataArray = arr;
shouldUpdateInternals |= true;
}
if (shouldUpdateInternals) {
updateInternals();
}
return halfAverageArea;
}
///////////////////////
/////// Private ///////
///////////////////////
function updateInternals() {
siteCount = dataArray.length;
totalArea = d3Polygon.polygonArea(clippingPolygon);
halfAverageArea = totalArea / siteCount / 2; // half of the average area of the the clipping polygon
}
return _halfAverageArea;
}
function voronoiMapSimulation(data) {
//begin: constants
const DEFAULT_CONVERGENCE_RATIO = 0.01;
const DEFAULT_MAX_ITERATION_COUNT = 50;
const DEFAULT_MIN_WEIGHT_RATIO = 0.01;
const DEFAULT_PRNG = Math.random;
const DEFAULT_INITIAL_POSITION = randomInitialPosition();
const DEFAULT_INITIAL_WEIGHT = halfAverageAreaInitialWeight();
const RANDOM_INITIAL_POSITION = randomInitialPosition();
const epsilon = 1;
//end: constants
/////// Inputs ///////
let weight = d => d.weight; // accessor to the weight
let convergenceRatio = DEFAULT_CONVERGENCE_RATIO; // targeted allowed error ratio; default 0.01 stops computation when cell areas error <= 1% clipping polygon's area
let maxIterationCount = DEFAULT_MAX_ITERATION_COUNT; // maximum allowed iteration; stops computation even if convergence is not reached; use a large amount for a sole converge-based computation stop
let minWeightRatio = DEFAULT_MIN_WEIGHT_RATIO; // used to compute the minimum allowed weight; default 0.01 means 1% of max weight; handle near-zero weights, and leaves enought space for cell hovering
let prng = DEFAULT_PRNG; // pseudorandom number generator
let initialPosition = DEFAULT_INITIAL_POSITION; // accessor to the initial position; defaults to a random position inside the clipping polygon
let initialWeight = DEFAULT_INITIAL_WEIGHT; // accessor to the initial weight; defaults to the average area of the clipping polygon
//begin: internals
const weightedVoronoi = d3WeightedVoronoi.weightedVoronoi(),
flickeringMitigation = new FlickeringMitigation();
let shouldInitialize = true, // should initialize due to changes via APIs
siteCount, // number of sites
totalArea, // area of the clipping polygon
areaErrorTreshold, // targeted allowed area error (= totalArea * convergenceRatio); below this treshold, map is considered obtained and computation stops
iterationCount, // current iteration
polygons, // current computed polygons
areaError, // current area error
converged, // true if (areaError < areaErrorTreshold)
ended; // stores if computation is ended, either if computation has converged or if it has reached the maximum allowed iteration
//end: internals
//being: internals/simulation
let simulation;
const stepper = d3Timer.timer(step),
event = d3Dispatch.dispatch('tick', 'end');
//end: internals/simulation
//begin: algorithm conf.
const handleOverweightedVariant = 1; // this option still exists 'cause for further experiments
let handleOverweighted;
//end: algorithm conf.
//begin: utils
function sqr(d) {
return Math.pow(d, 2);
}
function squaredDistance(s0, s1) {
return sqr(s1.x - s0.x) + sqr(s1.y - s0.y);
}
//end: utils
///////////////////////
///////// API /////////
///////////////////////
simulation = {
tick: tick,
restart: function() {
stepper.restart(step);
return simulation;
},
stop: function() {
stepper.stop();
return simulation;
},
weight: function(_) {
if (!arguments.length) {
return weight;
}
weight = _;
shouldInitialize = true;
return simulation;
},
convergenceRatio: function(_) {
if (!arguments.length) {
return convergenceRatio;
}
convergenceRatio = _;
shouldInitialize = true;
return simulation;
},
maxIterationCount: function(_) {
if (!arguments.length) {
return maxIterationCount;
}
maxIterationCount = _;
return simulation;
},
minWeightRatio: function(_) {
if (!arguments.length) {
return minWeightRatio;
}
minWeightRatio = _;
shouldInitialize = true;
return simulation;
},
clip: function(_) {
if (!arguments.length) {
return weightedVoronoi.clip();
}
weightedVoronoi.clip(_);
shouldInitialize = true;
return simulation;
},
extent: function(_) {
if (!arguments.length) {
return weightedVoronoi.extent();
}
weightedVoronoi.extent(_);
shouldInitialize = true;
return simulation;
},
size: function(_) {
if (!arguments.length) {
return weightedVoronoi.size();
}
weightedVoronoi.size(_);
shouldInitialize = true;
return simulation;
},
prng: function(_) {
if (!arguments.length) {
return prng;
}
prng = _;
shouldInitialize = true;
return simulation;
},
initialPosition: function(_) {
if (!arguments.length) {
return initialPosition;
}
initialPosition = _;
shouldInitialize = true;
return simulation;
},
initialWeight: function(_) {
if (!arguments.length) {
return initialWeight;
}
initialWeight = _;
shouldInitialize = true;
return simulation;
},
state: function() {
if (shouldInitialize) {
initializeSimulation();
}
return {
ended: ended,
iterationCount: iterationCount,
convergenceRatio: areaError / totalArea,
polygons: polygons
};
},
on: function(name, _) {
if (arguments.length === 1) {
return event.on(name);
}
event.on(name, _);
return simulation;
}
};
///////////////////////
/////// Private ///////
///////////////////////
//begin: simulation's main loop
function step() {
tick();
event.call('tick', simulation);
if (ended) {
stepper.stop();
event.call('end', simulation);
}
}
//end: simulation's main loop
//begin: algorithm used at each iteration
function tick() {
if (!ended) {
if (shouldInitialize) {
initializeSimulation();
}
polygons = adapt(polygons, flickeringMitigation.ratio());
iterationCount++;
areaError = computeAreaError(polygons);
flickeringMitigation.add(areaError);
converged = areaError < areaErrorTreshold;
ended = converged || iterationCount >= maxIterationCount;
// console.log("error %: "+Math.round(areaError*100*1000/totalArea)/1000);
}
}
//end: algorithm used at each iteration
function initializeSimulation() {
//begin: handle algorithm's variants
setHandleOverweighted();
//end: handle algorithm's variants
siteCount = data.length;
totalArea = Math.abs(d3Polygon.polygonArea(weightedVoronoi.clip()));
areaErrorTreshold = convergenceRatio * totalArea;
flickeringMitigation.clear().totalArea(totalArea);
iterationCount = 0;
converged = false;
polygons = initialize(data, simulation);
ended = false;
shouldInitialize = false;
}
function initialize(data, simulation) {
const maxWeight = data.reduce((max, d) => {
return Math.max(max, weight(d));
}, -Infinity),
minAllowedWeight = maxWeight * minWeightRatio;
let weights, mapPoints;
//begin: extract weights
weights = data.map((d, i, arr) => {
return {
index: i,
weight: Math.max(weight(d), minAllowedWeight),
initialPosition: initialPosition(d, i, arr, simulation),
initialWeight: initialWeight(d, i, arr, simulation),
originalData: d
};
});
//end: extract weights
// create map-related points
// (with targetedArea, initial position and initialWeight)
mapPoints = createMapPoints(weights, simulation);
return weightedVoronoi(mapPoints);
}
function createMapPoints(basePoints, simulation) {
const totalWeight = basePoints.reduce((acc, bp) => {
return (acc += bp.weight);
}, 0);
let initialPosition;
return basePoints.map((bp, i, bps) => {
initialPosition = bp.initialPosition;
if (!d3Polygon.polygonContains(weightedVoronoi.clip(), initialPosition)) {
initialPosition = DEFAULT_INITIAL_POSITION(bp, i, bps, simulation);
}
return {
index: bp.index,
targetedArea: (totalArea * bp.weight) / totalWeight,
data: bp,
x: initialPosition[0],
y: initialPosition[1],
weight: bp.initialWeight // ArlindNocaj/Voronoi-Treemap-Library uses an epsilonesque initial weight; using heavier initial weights allows faster weight adjustements, hence faster stabilization
};
});
}
function adapt(polygons, flickeringMitigationRatio) {
let adaptedMapPoints;
adaptPositions(polygons, flickeringMitigationRatio);
adaptedMapPoints = polygons.map(p => {
return p.site.originalObject;
});
polygons = weightedVoronoi(adaptedMapPoints);
if (polygons.length < siteCount) {
console.log('at least 1 site has no area, which is not supposed to arise');
debugger;
}
adaptWeights(polygons, flickeringMitigationRatio);
adaptedMapPoints = polygons.map(p => {
return p.site.originalObject;
});
polygons = weightedVoronoi(adaptedMapPoints);
if (polygons.length < siteCount) {
console.log('at least 1 site has no area, which is not supposed to arise');
debugger;
}
return polygons;
}
function adaptPositions(polygons, flickeringMitigationRatio) {
const newMapPoints = [],
flickeringInfluence = 0.5;
let flickeringMitigation, d, polygon, mapPoint, centroid, dx, dy;
flickeringMitigation = flickeringInfluence * flickeringMitigationRatio;
d = 1 - flickeringMitigation; // in [0.5, 1]
for (let i = 0; i < siteCount; i++) {
polygon = polygons[i];
mapPoint = polygon.site.originalObject;
centroid = d3Polygon.polygonCentroid(polygon);
dx = centroid[0] - mapPoint.x;
dy = centroid[1] - mapPoint.y;
//begin: handle excessive change;
dx *= d;
dy *= d;
//end: handle excessive change;
mapPoint.x += dx;
mapPoint.y += dy;
newMapPoints.push(mapPoint);
}
handleOverweighted(newMapPoints);
}
function adaptWeights(polygons, flickeringMitigationRatio) {
const newMapPoints = [],
flickeringInfluence = 0.1;
let flickeringMitigation, polygon, mapPoint, currentArea, adaptRatio, adaptedWeight;
flickeringMitigation = flickeringInfluence * flickeringMitigationRatio;
for (let i = 0; i < siteCount; i++) {
polygon = polygons[i];
mapPoint = polygon.site.originalObject;
currentArea = d3Polygon.polygonArea(polygon);
adaptRatio = mapPoint.targetedArea / currentArea;
//begin: handle excessive change;
adaptRatio = Math.max(adaptRatio, 1 - flickeringInfluence + flickeringMitigation); // in [(1-flickeringInfluence), 1]
adaptRatio = Math.min(adaptRatio, 1 + flickeringInfluence - flickeringMitigation); // in [1, (1+flickeringInfluence)]
//end: handle excessive change;
adaptedWeight = mapPoint.weight * adaptRatio;
adaptedWeight = Math.max(adaptedWeight, epsilon);
mapPoint.weight = adaptedWeight;
newMapPoints.push(mapPoint);
}
handleOverweighted(newMapPoints);
}
// heuristics: lower heavy weights
function handleOverweighted0(mapPoints) {
let fixCount = 0;
let fixApplied, tpi, tpj, weightest, lightest, sqrD, adaptedWeight;
do {
fixApplied = false;
for (let i = 0; i < siteCount; i++) {
tpi = mapPoints[i];
for (let j = i + 1; j < siteCount; j++) {
tpj = mapPoints[j];
if (tpi.weight > tpj.weight) {
weightest = tpi;
lightest = tpj;
} else {
weightest = tpj;
lightest = tpi;
}
sqrD = squaredDistance(tpi, tpj);
if (sqrD < weightest.weight - lightest.weight) {
// adaptedWeight = sqrD - epsilon; // as in ArlindNocaj/Voronoi-Treemap-Library
// adaptedWeight = sqrD + lightest.weight - epsilon; // works, but below heuristics performs better (less flickering)
adaptedWeight = sqrD + lightest.weight / 2;
adaptedWeight = Math.max(adaptedWeight, epsilon);
weightest.weight = adaptedWeight;
fixApplied = true;
fixCount++;
break;
}
}
if (fixApplied) {
break;
}
}
} while (fixApplied);
/*
if (fixCount>0) {
console.log("# fix: "+fixCount);
}
*/
}
// heuristics: increase light weights
function handleOverweighted1(mapPoints) {
const fixCount = 0;
let fixApplied, tpi, tpj, weightest, lightest, sqrD, overweight;
do {
fixApplied = false;
for (let i = 0; i < siteCount; i++) {
tpi = mapPoints[i];
for (let j = i + 1; j < siteCount; j++) {
tpj = mapPoints[j];
if (tpi.weight > tpj.weight) {
weightest = tpi;
lightest = tpj;
} else {
weightest = tpj;
lightest = tpi;
}
sqrD = squaredDistance(tpi, tpj);
if (sqrD < weightest.weight - lightest.weight) {
overweight = weightest.weight - lightest.weight - sqrD;
lightest.weight += overweight + epsilon;
fixApplied = true;
fixCount++;
break;
}
}
if (fixApplied) {
break;
}
}
} while (fixApplied);
/*
if (fixCount>0) {
console.log("# fix: "+fixCount);
}
*/
}
function computeAreaError(polygons) {
//convergence based on summation of all sites current areas
let areaErrorSum = 0;
let polygon, mapPoint, currentArea;
for (let i = 0; i < siteCount; i++) {
polygon = polygons[i];
mapPoint = polygon.site.originalObject;
currentArea = d3Polygon.polygonArea(polygon);
areaErrorSum += Math.abs(mapPoint.targetedArea - currentArea);
}
return areaErrorSum;
}
function setHandleOverweighted() {
switch (handleOverweightedVariant) {
case 0:
handleOverweighted = handleOverweighted0;
break;
case 1:
handleOverweighted = handleOverweighted1;
break;
default:
console.log("Variant of 'handleOverweighted' is unknown");
}
}
return simulation;
}
exports.voronoiMapSimulation = voronoiMapSimulation;
exports.voronoiMapInitialPositionRandom = randomInitialPosition;
exports.voronoiMapInitialPositionPie = pie;
Object.defineProperty(exports, '__esModule', { value: true });
}));
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>d3-voronoi-treemap usage</title>
<meta name="description" content="d3-voronoi-map plugin to remake 'The Costs of Being Fat, in Actual Dollars'">
<script src="https://d3js.org/d3.v5.min.js" charset="utf-8"></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.0/build/d3-voronoi-map.js"></script>
<style>
svg {
background-color: rgb(250,250,250);
}
#title {
letter-spacing: 4px;
font-weight: 700;
font-size: x-large;
}
text.tiny {
font-size: 10pt;
}
text.light {
fill: lightgrey
}
.symbol {
fill: none;
stroke: lightgrey;
stroke-width: 14px;
}
.cell {
stroke: darkgrey;
stroke-width: 1px;
}
.cost {
text-anchor: middle;
}
.total-cost {
fill: lightgrey;
text-anchor: middle;
font-size: 20px;
font-weight: 700;
}
.legend-color {
stroke-width: 1px;
stroke:darkgrey;
}
.highlighter {
fill: transparent;
stroke: none;
}
.highlight {
stroke: black;
stroke-width: 2px;
}
</style>
</head>
<body>
<svg>
<defs>
<filter id="inset-shadow">
<feGaussianBlur stdDeviation="5" result="offset-blur"></feGaussianBlur>
<!-- Shadow Blur -->
<feComposite operator="out" in="SourceGraphic" in2="offset-blur" result="inverse"></feComposite>
<!-- Invert the drop shadow to create an inner shadow -->
<feFlood flood-color="grey" flood-opacity="1" result="color"></feFlood> <!-- Color & Opacity -->
<feComposite operator="in" in="color" in2="inverse" result="shadow"></feComposite>
<!-- Clip color inside shadow -->
<feComponentTransfer in="shadow" result="shadow">
<!-- Shadow Opacity -->
<feFuncA type="linear" slope=".75"></feFuncA>
</feComponentTransfer>
<feComposite operator="over" in="shadow" in2="SourceGraphic"></feComposite>
<!-- Put shadow over original object -->
</filter>
</defs>
</svg>
<script>
//begin: constants
var _2PI = 2*Math.PI;
//end: constants
//begin: raw data global def
var menTotalCost = 0,
womenTotalCost = 0;
//end: raw data global def
//begin: data-related utils
function menCostAccessor(d){ return d.menCost; };
function womenCostAccessor(d){ return d.womenCost; };
function highlighterGroupId(d){ return "group-"+d.id};
//end: data-related utils
//begin: layout conf.
var svgWidth = 960,
svgHeight = 500,
margin = {top: 10, right: 10, bottom: 10, left: 10},
height = svgHeight - margin.top - margin.bottom,
width = svgWidth - margin.left - margin.right,
halfWidth = width/2,
halfHeight = height/2,
quarterWidth = width/4,
quarterHeight = height/4,
titleY = 20,
legendsMinY = height - 20,
menTreemapCenter = [300, 200],
womenTreemapCenter = [650, 200];
//end: layout conf.
//begin: treemap conf.
var baseRadius = 100;
var menRadius, womenRadius,
menCirclingPolygon, womenCirclingPolygon,
menPolygons, womenPolygons;
//end: treemap conf.
//begin: reusable d3Selection
var svg, drawingArea, menContainer, womenContainer;
//end: reusable d3Selection
d3.csv("costOfBeingFat.csv").then(function(data) {
data.forEach(function(d) {csvParser(d)});
initData(data);
initLayout();
drawLegends(data);
var menData = data.filter( function(d){ return menCostAccessor(d)>0; }).reverse();
menSimulation = d3.voronoiMapSimulation(menData)
.clip(menCirclingPolygon)
.weight(menCostAccessor)
.initialPosition(d3.voronoiMapInitialPositionPie().startAngle(-Math.PI*3/5))
.on("tick", function() {
// function called after each iteration of computation
// called only in simulation mode, not in static mode (see below)
menPolygons = menSimulation.state().polygons;
drawTreemap("men");
})
.on("end", function() {
attachMouseListener(data);
});
var womenData = data.filter( function(d){ return womenCostAccessor(d)>0; }).reverse();
womenSimulation = d3.voronoiMapSimulation(womenData)
.clip(womenCirclingPolygon)
.weight(womenCostAccessor)
.initialPosition(d3.voronoiMapInitialPositionPie().startAngle(-Math.PI*3/4))
.on("tick", function() {
// function called after each iteration of computation
// called only in simulation mode, not in static mode (see below)
womenPolygons = womenSimulation.state().polygons;
drawTreemap("women");
})
.on("end", function() {
attachMouseListener(data);
});
//begin: how to draw a static arrangement
// set simulation to false for a staic arrangement
var simulate = true;
if (!simulate) {
// firstly, stop simulations
menSimulation.stop();
womenSimulation.stop();
// secondly, manually call ticks until final arrangment is produced
while (!menSimulation.state().ended) {
menSimulation.tick();
// function defined by .on("tick", ...) above are not called
}
while (!womenSimulation.state().ended) {
womenSimulation.tick();
// function defined by .on("tick", ...) above are not called
}
// finally, polygons are available and can be used as desired
menPolygons = menSimulation.state().polygons;
womenPolygons = womenSimulation.state().polygons;
drawTreemap("men");
drawTreemap("women");
attachMouseListener(data);
}
//end: how to draw a static arrangement
});
function csvParser(d) {
d.id = +d.id;
d.composition = d.composition;
d.menCost = +d.menCost;
d.womenCost = +d.womenCost;
d.color = d.color;
menTotalCost += d.menCost;
womenTotalCost += d.womenCost;
return d;
};
function initData(data) {
menRadius = baseRadius;
womenRadius = baseRadius*Math.sqrt(womenTotalCost/menTotalCost);
menCirclingPolygon = computeCirclingPolygon(menRadius);
womenCirclingPolygon = computeCirclingPolygon(womenRadius);
}
function computeCirclingPolygon(radius) {
var points = 60,
increment = _2PI/points,
circlingPolygon = [];
for (var a=0, i=0; i<points; i++, a+=increment) {
circlingPolygon.push(
[radius*Math.cos(a), radius*Math.sin(a)]
)
}
return circlingPolygon;
};
function initLayout() {
svg = d3.select("svg")
.attr("width", svgWidth)
.attr("height", svgHeight);
drawingArea = svg.append("g")
.classed("drawingArea", true)
.attr("transform", "translate("+[margin.left,margin.top]+")");
menContainer = drawingArea.append("g")
.classed("men-container", true)
.attr("transform", "translate("+menTreemapCenter+")");
drawMenSymbol();
menContainer.append("text")
.classed("total-cost", true)
.attr("transform", "rotate(-45)translate(0,"+(-menRadius-6)+")")
.text("$"+menTotalCost);
menContainer.append("g")
.classed('cells', true);
menContainer.append("g")
.classed('costs', true);
menContainer.append("g")
.classed('highlighters', true);
womenContainer = drawingArea.append("g")
.classed("women-container", true)
.attr("transform", "translate("+womenTreemapCenter+")")
drawWomenSymbol();
womenContainer.append("text")
.classed("total-cost", true)
.attr("transform", "rotate(45)translate(0,"+(-womenRadius-6)+")")
.text("$"+womenTotalCost);
womenContainer.append("g")
.classed('cells', true);
womenContainer.append("text")
.classed("total-cost", true);
womenContainer.append("g")
.classed('costs', true);
womenContainer.append("g")
.classed('highlighters', true);
drawTitle();
drawFooter();
}
function drawTitle() {
drawingArea.append("text")
.attr("id", "title")
.attr("transform", "translate("+[halfWidth, titleY]+")")
.attr("text-anchor", "middle")
.text("The Individual Costs of Being Obese in the U.S. (2010)")
}
function drawFooter() {
drawingArea.append("text")
.classed("tiny light", true)
.attr("transform", "translate("+[0, height]+")")
.attr("text-anchor", "start")
.text("Remake of HowMuch.net's post 'The Costs of Being Fat, in Actual Dollars'")
drawingArea.append("text")
.classed("tiny light", true)
.attr("transform", "translate("+[halfWidth+45, height]+")")
.attr("text-anchor", "middle")
.text("by @_Kcnarf")
drawingArea.append("text")
.classed("tiny light", true)
.attr("transform", "translate("+[width, height]+")")
.attr("text-anchor", "end")
.text("bl.ocks.org/Kcnarf/89d9d2d575f5c4ad41235cad6b202742")
}
function drawLegends(data) {
var legendHeight = 13,
interLegend = 4,
colorWidth = legendHeight*4;
var legendContainer = drawingArea.append("g")
.classed("legend", true)
.attr("transform", "translate("+[0, legendsMinY]+")");
var legends = legendContainer.selectAll(".legend")
.data(data.reverse())
.enter();
var legend = legends.append("g")
.classed("legend", true)
.attr("transform", function(d,i){
return "translate("+[0, -i*(legendHeight+interLegend)]+")";
})
legend.append("rect")
.classed("legend-color", true)
.attr("filter", "url(#inset-shadow)")
.attr("y", -legendHeight)
.attr("width", colorWidth)
.attr("height", legendHeight)
.style("fill", function(d){ return d.color; });
legend.append("text")
.classed("tiny", true)
.attr("transform", "translate("+[colorWidth+5, -2]+")")
.text(function(d){ return d.composition; });
legend.append("rect")
.attr("class", highlighterGroupId)
.classed("highlighter", true)
.attr("y", -legendHeight)
.attr("width", colorWidth)
.attr("height", legendHeight);
legendContainer.append("text")
.attr("transform", "translate("+[0, -data.length*(legendHeight+interLegend)-5]+")")
.text("Annual costs of being obese");
}
function drawMenSymbol() {
var delta = menRadius/10,
symbolLength = 40,
symbol = menContainer.append("g").classed("symbol", true);
symbol.append("circle")
.attr("r", menRadius-5);
symbol.append("path")
.attr("filter", "url(#inset-shadow)")
.attr("transform", "translate("+[delta,-delta]+")")
.attr("d", "M"+[0,0]+"L"+[menRadius,-menRadius]+
"M"+[menRadius-symbolLength,-menRadius]+"h"+symbolLength+",v"+symbolLength
);
}
function drawWomenSymbol() {
var delta = womenRadius,
symbolLength = 60,
midSymbolLength = symbolLength/2;
symbol = womenContainer.append("g").classed("symbol", true);
symbol.append("circle")
.attr("r", womenRadius-5);
symbol.append("path")
.attr("filter", "url(#inset-shadow)")
.attr("transform", "translate("+[0,delta]+")")
.attr("d", "M"+[0,0]+"v"+symbolLength+
"M"+[-midSymbolLength,midSymbolLength]+"h"+symbolLength
);
}
function drawTreemap(gender) {
var container, polygons, costAccessor, totalCost, totalCostRotation;
if (gender==="men") {
container = menContainer;
polygons = menPolygons;
costAccessor = menCostAccessor;
} else {
container = womenContainer;
polygons = womenPolygons;
costAccessor = womenCostAccessor;
}
var cells = container.select(".cells")
.selectAll(".cell")
.data(polygons);
cells.enter()
.append("path")
.classed("cell", true)
.merge(cells)
.attr("filter", "url(#inset-shadow)")
.attr("d", function(d){ return "M"+d.join(",")+"z"; })
.style("fill", function(d){
return d.site.originalObject.data.originalData.color;
});
var costs = container.select(".costs")
.selectAll(".cost")
.data(polygons);
costs.enter()
.append("text")
.classed("cost", true)
.merge(costs)
.attr("transform", function(d){
return "translate("+[d.site.x, d.site.y+6]+")"; // +6 for centering
})
.text(function(d){
return "$"+costAccessor(d.site.originalObject.data.originalData);
})
var highlighters = container.select(".highlighters")
.selectAll(".highlighter")
.data(polygons);
highlighters.enter()
.append("path")
.merge(highlighters)
.attr("class", function(d) {
return highlighterGroupId(d.site.originalObject.data.originalData);
})
.classed("highlighter", true)
.attr("d", function(d){ return "M"+d.join(",")+"z"; });
}
function attachMouseListener(data){
var id;
data.forEach(function(d){
id = d.id
d3.selectAll(".group-"+id)
.on("mouseenter", highlight(id, true))
.on("mouseleave", highlight(id, false));
})
}
function highlight(groupId, highlight){
return function() {
d3.selectAll(".group-"+groupId)
.classed("highlight", highlight);
}
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment