Skip to content

Instantly share code, notes, and snippets.

@kevinlin1
Last active June 18, 2023 18:40
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save kevinlin1/d9e4b8d629f7f4163307d68272bf86a0 to your computer and use it in GitHub Desktop.
Save kevinlin1/d9e4b8d629f7f4163307d68272bf86a0 to your computer and use it in GitHub Desktop.
k-d Tree Visualization
license: cc-by-4.0
height: 500
scrolling: no
border: no

k-d Tree Visualization

k-d trees are space-partitioning data structures for organizing points in k-dimensional space. They are a useful data structure for finding, for example, the n nearest neighbors of a point in k-dimensional space.

This block provides a visualization of k-d tree creation which connects the intuition of binary trees with the concept of space partitioning.

Operation

Click on the left square to add points in a 2D projection of RGB space.

Additional Reading

Acknowledgements

Forked from ludwigschubert's block: kD-tree explorations in D3.js

<html>
<head>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="kdtree"></div>
<div id="binarytree"></div>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tinycolor/1.4.1/tinycolor.min.js"></script>
<script src="kdTree.js"></script>
<script>
var data;
var colorSubset = [];
var tree = new kdTree(colorSubset, colorDistance, ["red", "green"]);
var bTree;
// Pretty good color distance from
// http://www.compuphase.com/cmetric.htm
function colorDistance(a, b) {
var dr = a.red - b.red;
var dg = a.green - b.green;
var db = a.blue - b.blue;
var redMean = (a.red + b.red)/2;
return (2 + redMean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - redMean) / 256) * db * db;
}
$(function(){ // on document load
function update(color) {
$("#picked").css('background', color.toHex());
var rgb = color.toRgb();
var search = {red: rgb.r, green: rgb.g, blue: rgb.b};
var nearest = tree.nearest(search, 4);
nearest.sort(function(a,b){return a[1] - b[1]});
var $list = $("#results ul");
$list.html("");
for(var i=0; i<nearest.length; i++) {
var c = nearest[i][0];
var $box = $("<div>")
.css('background', c.hex)
.css('display', 'inline-block')
.css('margin-right', '10px')
.height('30px')
.width('30px');
var $line = $("<li>").append($box).append(c.title);
$list.append($line);
}
updateSearchResults(nearest);
}
function setRGBfromHex (hex_color) {
var color = new tinycolor(hex_color.hex).toRgb();
hex_color.red = color.r;
hex_color.green = color.g;
hex_color.blue = color.b;
}
// D3 kdtree
var margin = {top: 0, right: 0, bottom: 0, left: 0},
width = 470 - margin.left - margin.right,
height = width - margin.top - margin.bottom,
pointRadius = 6;
var x = d3.scale.linear()
.range([0, width])
.domain([0, 255]);
var y = d3.scale.linear()
.range([height, 0])
.domain([0, 255]);
var svg = d3.select("#kdtree").append("svg")
.attr("class", "kdtree")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
var graph = svg.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.attr("width", width)
.attr("height", height);
var linesG = graph.append("g")
.attr("class", "lines");
var pointsG = graph.append("g")
.attr("class", "points");
// D3 binarytree
bTree = d3.layout.tree()
.size([height, width]);
var diagonal = d3.svg.diagonal()
.projection(function(d) { return [d.y, d.x]; });
var bSvg = d3.select("#binarytree").append("svg")
.attr("class", "binarytree")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom);
var bLinesG = bSvg.append("g")
.attr("class", "lines");
var bPointsG = bSvg.append("g")
.attr("class", "points");
svg.on('click', function() {
coordinates = d3.mouse(this);
var red = x.invert( coordinates[0] );
var green = y.invert( coordinates[1] );
var color = d3.rgb(red, green, 128);
var object = { title: "user defined", hex: color.toString() };
setRGBfromHex(object);
// check for very similar colors; don't insert to keep layouter sane
var potentialDuplicates = tree.nearest(object, 1);
if (potentialDuplicates.length > 0) {
// var potentialDuplicate = potentialDuplicates[0][0];
var distance = potentialDuplicates[0][1];
if (distance < 10) return;
}
colorSubset.push(object);
tree.insert(object);
data = tree.pointsBFS();
drawDataSubset(data);
});
data = tree.pointsBFS();
drawDataSubset(data);
var i = 1;
function drawIteratively(){
drawDataSubset(data.slice(0, i));
if (i < data.length) {
i++;
setTimeout(drawIteratively, 250);
} else {
i = 1;
}
}
function drawDataSubset(dataSubset) {
// points
var pointsSelection = pointsG.selectAll(".point")
.data(dataSubset, function(d){return d.hex + "point";});
pointsSelection.enter()
.append("circle")
.attr("class", function(d) { d.fresh = true; return "point" })
.attr("class", "point")
.attr("dimension", function(d) { return d.dimension; })
.attr("r", pointRadius)
.attr("cx", function(d) { return x(d.red); })
.attr("cy", function(d) { return y(d.green); })
.style("fill", function(d) { return d3.rgb(d.hex); })
.style("stroke", function(d) { return d3.rgb(d.hex); })
.on("mouseover", function(d) {
if (d.fresh) return;
var parent = d.node;
while(parent) {
parent.onAccessPath = true;
parent = parent.parent;
}
drawDataSubset(data);
})
.on("mouseout", function(d) {
if (d.fresh) {
d.fresh = false;
return;
}
var parent = d.node;
while(parent) {
parent.onAccessPath = false;
parent = parent.parent;
}
drawDataSubset(data);
})
pointsSelection
.style("stroke-width", function(d) { return d.node.onAccessPath || d.node.isSearchResult ? 2 : 0; });
// lines
var selection = linesG.selectAll(".point-line")
.data(dataSubset, function(d){return d.hex + "line";});
selection // update
.style("stroke-width", function(d) { return d.node.onAccessPath ? 2*height/400 : height/400; })
.transition()
.attr("x1", function(d) { return x(d.x1); })
.attr("y1", function(d) { return y(d.y1); })
.attr("x2", function(d) { return x(d.x2); })
.attr("y2", function(d) { return y(d.y2); })
.attr("stroke",function(d) {
switch(d.dimension) {
case 0: return d3.rgb(d.red, 0, 0);
case 1: return d3.rgb(0, d.green, 0);
// case 2: return "green";
}
})
selection.enter()
.append("line")
.attr("class", "point-line")
.attr("stroke-width", height/400)
.attr("x1", function(d) { return x(d.red); })
.attr("y1", function(d) { return y(d.green); })
.attr("x2", function(d) { return x(d.red); })
.attr("y2", function(d) { return y(d.green); })
.attr("stroke",function(d) {
switch(d.dimension) {
case 0: return d3.rgb(d.red, 0, 0);
case 1: return d3.rgb(0, d.green, 0);
// case 2: return "green";
}
})
.transition()
.attr("x1", function(d) { return x(d.x1); })
.attr("y1", function(d) { return y(d.y1); })
.attr("x2", function(d) { return x(d.x2); })
.attr("y2", function(d) { return y(d.y2); })
selection.exit()
.remove();
// binary tree
// nodes
var depth = d3.max(dataSubset, function(d){return d.depth;})
var binaryTreePoints = bPointsG.selectAll(".binarytreepoint")
.data(dataSubset, function(d){return d.hex + "binarytreepoint";});
binaryTreePoints.enter()
.append("circle")
.attr("class", "binarytreepoint")
.attr("dimension", function(d) { return d.dimension; })
.attr("r", pointRadius)
.style("fill", function(d) { return d3.rgb(d.hex); })
.style("stroke", function(d) { return d3.rgb(d.hex); });
binaryTreePoints // enter + update
.style("stroke-width", function(d) { return d.node.onAccessPath ? 2 : 0; })
.transition()
.attr("cx", function(d) {
if (!d.node.parent) {
d.node.bx = width / 2;
} else {
var inc = (width - 4*pointRadius) / Math.pow(2, d.depth + 1 );
if (d.node == d.node.parent.left) { // left child
d.node.bx = d.node.parent.bx - inc;
} else { // right child
d.node.bx = d.node.parent.bx + inc;
}
}
return d.node.bx;
})
.attr("cy", function(d) {
d.node.by = (d.depth * ((height - 4*pointRadius) / depth)) + (2 * pointRadius);
return d.node.by;
})
.style("fill", function(d) { return d3.rgb(d.hex); })
binaryTreePoints.exit()
.remove();
// edges
var selection = bLinesG.selectAll(".binarytreepoint-edge")
.data(dataSubset, function(d){return d.hex + "binarytreepoint-edge";});
selection // update
.style("stroke-width", function(d) { return d.node.onAccessPath ? 2*height/400 : height/400; })
.transition()
.attr("x1", function(d) { return d.node.parent ? d.node.parent.bx : d.node.bx; })
.attr("y1", function(d) { return d.node.parent ? d.node.parent.by : d.node.by; })
.attr("x2", function(d) { return d.node.bx; })
.attr("y2", function(d) { return d.node.by; })
.attr("stroke",function(d) {
switch(d.dimension) {
case 0: return d3.rgb(d.red, 0, 0);
case 1: return d3.rgb(0, d.green, 0);
}
})
selection.enter()
.append("line")
.attr("class", "binarytreepoint-edge")
// .attr("stroke-width", function(d) { return w(d.depth); })
.attr("stroke-dasharray", "1,3")
.attr("stroke-width", height/400)
.style("opacity", 0.8)
// start all animation from point
.attr("x1", function(d) { return d.node.parent ? d.node.parent.bx : d.node.bx; })
.attr("y1", function(d) { return d.node.parent ? d.node.parent.by : d.node.by; })
.attr("x2", function(d) { return d.node.parent ? d.node.parent.bx : d.node.bx; })
.attr("y2", function(d) { return d.node.parent ? d.node.parent.by : d.node.by; })
.attr("stroke",function(d) {
switch(d.dimension) {
case 0: return d3.rgb(d.red, 0, 0);
case 1: return d3.rgb(0, d.green, 0);
}
})
.transition()
.attr("x1", function(d) { return d.node.parent ? d.node.parent.bx : d.node.bx; })
.attr("y1", function(d) { return d.node.parent ? d.node.parent.by : d.node.by; })
.attr("x2", function(d) { return d.node.bx; })
.attr("y2", function(d) { return d.node.by; })
selection.exit()
.remove();
}
});
</script>
</body>
</html>
/**
* k-d Tree JavaScript - V 1.01
*
* https://github.com/ubilabs/kd-tree-javascript
*
* @author Mircea Pricop <pricop@ubilabs.net>, 2012
* @author Martin Kleppe <kleppe@ubilabs.net>, 2012
* @author Ubilabs http://ubilabs.net, 2012
* @license MIT License <http://www.opensource.org/licenses/mit-license.php>
*/
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define(['exports'], factory);
} else if (typeof exports === 'object') {
factory(exports);
} else {
factory((root.commonJsStrict = {}));
}
}(this, function (exports) {
function Node(obj, dimension, parent) {
this.obj = obj;
this.left = null;
this.right = null;
this.parent = parent;
this.dimension = dimension;
}
function kdTree(points, metric, dimensions) {
var self = this;
function buildTree(points, depth, parent) {
var dim = depth % dimensions.length,
median,
node;
if (points.length === 0) {
return null;
}
if (points.length === 1) {
return new Node(points[0], dim, parent);
}
points.sort(function (a, b) {
return a[dimensions[dim]] - b[dimensions[dim]];
});
median = Math.floor(points.length / 2);
node = new Node(points[median], dim, parent);
node.left = buildTree(points.slice(0, median), depth + 1, node);
node.right = buildTree(points.slice(median + 1), depth + 1, node);
return node;
}
// Reloads a serialied tree
function loadTree (data) {
// Just need to restore the `parent` parameter
self.root = data;
function restoreParent (root) {
if (root.left) {
root.left.parent = root;
restoreParent(root.left);
}
if (root.right) {
root.right.parent = root;
restoreParent(root.right);
}
}
restoreParent(self.root);
}
// If points is not an array, assume we're loading a pre-built tree
if (!Array.isArray(points)) loadTree(points, metric, dimensions);
else this.root = buildTree(points, 0, null);
// Convert to a JSON serializable structure; this just requires removing
// the `parent` property
this.toJSON = function (src) {
if (!src) src = this.root;
var dest = new Node(src.obj, src.dimension, null);
if (src.left) dest.left = self.toJSON(src.left);
if (src.right) dest.right = self.toJSON(src.right);
// if (src.parent) dest.parent = self.toJSON(src.parent);
return dest;
};
this.insert = function (point) {
function innerSearch(node, parent) {
if (node === null) {
return parent;
}
var dimension = dimensions[node.dimension];
if (point[dimension] < node.obj[dimension]) {
return innerSearch(node.left, node);
} else {
return innerSearch(node.right, node);
}
}
var insertPosition = innerSearch(this.root, null),
newNode,
dimension;
if (insertPosition === null) {
this.root = new Node(point, 0, null);
return;
}
newNode = new Node(point, (insertPosition.dimension + 1) % dimensions.length, insertPosition);
dimension = dimensions[insertPosition.dimension];
if (point[dimension] < insertPosition.obj[dimension]) {
insertPosition.left = newNode;
} else {
insertPosition.right = newNode;
}
};
this.remove = function (point) {
var node;
function nodeSearch(node) {
if (node === null) {
return null;
}
if (node.obj === point) {
return node;
}
var dimension = dimensions[node.dimension];
if (point[dimension] < node.obj[dimension]) {
return nodeSearch(node.left, node);
} else {
return nodeSearch(node.right, node);
}
}
function removeNode(node) {
var nextNode,
nextObj,
pDimension;
function findMin(node, dim) {
var dimension,
own,
left,
right,
min;
if (node === null) {
return null;
}
dimension = dimensions[dim];
if (node.dimension === dim) {
if (node.left !== null) {
return findMin(node.left, dim);
}
return node;
}
own = node.obj[dimension];
left = findMin(node.left, dim);
right = findMin(node.right, dim);
min = node;
if (left !== null && left.obj[dimension] < own) {
min = left;
}
if (right !== null && right.obj[dimension] < min.obj[dimension]) {
min = right;
}
return min;
}
if (node.left === null && node.right === null) {
if (node.parent === null) {
self.root = null;
return;
}
pDimension = dimensions[node.parent.dimension];
if (node.obj[pDimension] < node.parent.obj[pDimension]) {
node.parent.left = null;
} else {
node.parent.right = null;
}
return;
}
// If the right subtree is not empty, swap with the minimum element on the
// node's dimension. If it is empty, we swap the left and right subtrees and
// do the same.
if (node.right !== null) {
nextNode = findMin(node.right, node.dimension);
nextObj = nextNode.obj;
removeNode(nextNode);
node.obj = nextObj;
} else {
nextNode = findMin(node.left, node.dimension);
nextObj = nextNode.obj;
removeNode(nextNode);
node.right = node.left;
node.left = null;
node.obj = nextObj;
}
}
node = nodeSearch(self.root);
if (node === null) { return; }
removeNode(node);
};
this.nearest = function (point, maxNodes, maxDistance) {
var i,
result,
bestNodes;
bestNodes = new BinaryHeap(
function (e) { return -e[1]; }
);
function nearestSearch(node) {
var bestChild,
dimension = dimensions[node.dimension],
ownDistance = metric(point, node.obj),
linearPoint = {},
linearDistance,
otherChild,
i;
function saveNode(node, distance) {
bestNodes.push([node, distance]);
if (bestNodes.size() > maxNodes) {
bestNodes.pop();
}
}
for (i = 0; i < dimensions.length; i += 1) {
if (i === node.dimension) {
linearPoint[dimensions[i]] = point[dimensions[i]];
} else {
linearPoint[dimensions[i]] = node.obj[dimensions[i]];
}
}
linearDistance = metric(linearPoint, node.obj);
if (node.right === null && node.left === null) {
if (bestNodes.size() < maxNodes || ownDistance < bestNodes.peek()[1]) {
saveNode(node, ownDistance);
}
return;
}
if (node.right === null) {
bestChild = node.left;
} else if (node.left === null) {
bestChild = node.right;
} else {
if (point[dimension] < node.obj[dimension]) {
bestChild = node.left;
} else {
bestChild = node.right;
}
}
nearestSearch(bestChild);
if (bestNodes.size() < maxNodes || ownDistance < bestNodes.peek()[1]) {
saveNode(node, ownDistance);
}
if (bestNodes.size() < maxNodes || Math.abs(linearDistance) < bestNodes.peek()[1]) {
if (bestChild === node.left) {
otherChild = node.right;
} else {
otherChild = node.left;
}
if (otherChild !== null) {
nearestSearch(otherChild);
}
}
}
if (maxDistance) {
for (i = 0; i < maxNodes; i += 1) {
bestNodes.push([null, maxDistance]);
}
}
if(self.root)
nearestSearch(self.root);
result = [];
for (i = 0; i < Math.min(maxNodes, bestNodes.content.length); i += 1) {
if (bestNodes.content[i][0]) {
result.push([bestNodes.content[i][0].obj, bestNodes.content[i][1]]);
}
}
return result;
};
this.pointsBFS = function () {
var result = [];
if (!this.root) return result;
var queue = [];
var bounds = { x1: 0, y1: 0, x2: 255, y2: 255 };
var bundle = { node: this.root, bounds: bounds, depth: 0 };
queue.push(bundle);
while(queue.length > 0) {
var bundle = queue.shift();
var node = bundle.node;
var bounds = bundle.bounds;
var depth = bundle.depth;
var object = node.obj;
object.dimension = node.dimension;
object.depth = depth;
node.children = [node.left, node.right];
object.node = node
var leftBounds, rightBounds;
switch(object.dimension) {
case 0:
object.x1 = object.x2 = object.red;
object.y1 = bounds.y1;
object.y2 = bounds.y2;
leftBounds = { x1: bounds.x1, y1: bounds.y1, x2: object.x2, y2: bounds.y2 };
rightBounds = { x1: object.x1, y1: bounds.y1, x2: bounds.x2, y2: bounds.y2 };
break;
case 1:
object.y1 = object.y2 = object.green;
object.x1 = bounds.x1;
object.x2 = bounds.x2;
leftBounds = { x1: bounds.x1, y1: bounds.y1, x2: bounds.x2, y2: object.y2 };
rightBounds = { x1: bounds.x1, y1: object.y1, x2: bounds.x2, y2: bounds.y2 };
break;
}
result.push(object);
//console.log(depth);
// Traverse the tree
if (node.left) {
queue.push({ node: node.left, bounds: leftBounds, depth: depth + 1 });
};
if (node.right) {
queue.push({ node: node.right, bounds: rightBounds, depth: depth + 1 });
};
} // while
return result;
};
this.d3tree = function () {
function dfs (node) {
if (!node) return "null"; // d3 expects JSON like nulls
var object = {};
object.name = node.obj.toString();
if (node.parent) {
object.parent = node.parent.obj.toString();
} else {
object.parent = "null"; // d3 expects JSON like nulls
}
object.children = [dfs(node.left), dfs(node.right)];
return object;
}
return dfs(this.root);
};
this.balanceFactor = function () {
function height(node) {
if (node === null) {
return 0;
}
return Math.max(height(node.left), height(node.right)) + 1;
}
function count(node) {
if (node === null) {
return 0;
}
return count(node.left) + count(node.right) + 1;
}
return height(self.root) / (Math.log(count(self.root)) / Math.log(2));
};
}
// Binary heap implementation from:
// http://eloquentjavascript.net/appendix2.html
function BinaryHeap(scoreFunction){
this.content = [];
this.scoreFunction = scoreFunction;
}
BinaryHeap.prototype = {
push: function(element) {
// Add the new element to the end of the array.
this.content.push(element);
// Allow it to bubble up.
this.bubbleUp(this.content.length - 1);
},
pop: function() {
// Store the first element so we can return it later.
var result = this.content[0];
// Get the element at the end of the array.
var end = this.content.pop();
// If there are any elements left, put the end element at the
// start, and let it sink down.
if (this.content.length > 0) {
this.content[0] = end;
this.sinkDown(0);
}
return result;
},
peek: function() {
return this.content[0];
},
remove: function(node) {
var len = this.content.length;
// To remove a value, we must search through the array to find
// it.
for (var i = 0; i < len; i++) {
if (this.content[i] == node) {
// When it is found, the process seen in 'pop' is repeated
// to fill up the hole.
var end = this.content.pop();
if (i != len - 1) {
this.content[i] = end;
if (this.scoreFunction(end) < this.scoreFunction(node))
this.bubbleUp(i);
else
this.sinkDown(i);
}
return;
}
}
throw new Error("Node not found.");
},
size: function() {
return this.content.length;
},
bubbleUp: function(n) {
// Fetch the element that has to be moved.
var element = this.content[n];
// When at 0, an element can not go up any further.
while (n > 0) {
// Compute the parent element's index, and fetch it.
var parentN = Math.floor((n + 1) / 2) - 1,
parent = this.content[parentN];
// Swap the elements if the parent is greater.
if (this.scoreFunction(element) < this.scoreFunction(parent)) {
this.content[parentN] = element;
this.content[n] = parent;
// Update 'n' to continue at the new position.
n = parentN;
}
// Found a parent that is less, no need to move it further.
else {
break;
}
}
},
sinkDown: function(n) {
// Look up the target element and its score.
var length = this.content.length,
element = this.content[n],
elemScore = this.scoreFunction(element);
while(true) {
// Compute the indices of the child elements.
var child2N = (n + 1) * 2, child1N = child2N - 1;
// This is used to store the new position of the element,
// if any.
var swap = null;
// If the first child exists (is inside the array)...
if (child1N < length) {
// Look it up and compute its score.
var child1 = this.content[child1N],
child1Score = this.scoreFunction(child1);
// If the score is less than our element's, we need to swap.
if (child1Score < elemScore)
swap = child1N;
}
// Do the same checks for the other child.
if (child2N < length) {
var child2 = this.content[child2N],
child2Score = this.scoreFunction(child2);
if (child2Score < (swap == null ? elemScore : child1Score)){
swap = child2N;
}
}
// If the element needs to be moved, swap it, and continue.
if (swap != null) {
this.content[n] = this.content[swap];
this.content[swap] = element;
n = swap;
}
// Otherwise, we are done.
else {
break;
}
}
}
};
this.kdTree = kdTree;
exports.kdTree = kdTree;
exports.BinaryHeap = BinaryHeap;
}));
svg {
border: 1px solid #DDD;
}
#binarytree {
float: left;
width: 50%;
}
#kdtree {
float: left;
width: 50%;
background-size: 100%;
background-repeat: no-repeat;
background-position: center;
background-image: url('data:image/png;base64,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oJmBFXQZeN4FF337istHnXJ8t/Ztsm84/QdTkl6LHgQWAM0PrJ1isVgQ5HXtc9jEB57aWp82DggsAJojzKTq/15DY2PGtSsQWAAACCwAAIEFACCwAAAQWAAAAgsAQGABACCwAAAEFgCAwAIAEFgAAAgsAACBBQAgsAAAEFgAAAILAEBgAQAgsAAABBYAgMACAEBgAewzmUxm8eLFU6dO/cUvfjF79uyqqiqbgMACoJlSqdQjjzwyaNCgoqKiRCIRj8cLCgratWv39a9/fenSpfYBgQVA09TU1IwcOTIbVcHuZJPr5z//eRiGhgKBBUAk6XT6ggsuCPYokUi88MILtgKBBUAkzz//fBDBAQccUFNTYy4QWADkdvLJJwfRzJw501wgsADIoba2NhaLRQys733vexYDgQVADuvWrQsiO+eccywGAguAHN5///3ogXXppZdaDAQWADmEYdivX7+IgfXII49YDAQWALlNnTo1Sl3F4/GNGzeaCwQWALk1NjYecsghOQNr8uTJtgKBBUBU69evLy0t3UNdXXnllalUylAgsACIKgzDTZs2jRkzZrf3cH/qqacaGxutBAILgCZLpVIrV67M5tS11147duzYSZMmzZ07d+vWrV6FEAQWAHslm1PpdDobW5lMRlqBwAIAQGABAAgsAACBBQCAwAIAEFgAAAILAACBBQAgsAAABBYAAAILAEBgAQAILAAAgQUAgMACABBYAAACCwAAgQUAILAAAAQWAAACC/i0pJPVy8rfeOGFuW8senPt+qpMGNoEEFgAzbR1zcJrR4/8XNcORUVtCgsL2xQVFbdt1/XAg044ZdRf1my1DyCwAJpmQ/msL3Rtm5df/JULv/vLR2e88MLzTz8x7c7/O+Gc04d/pl3+7MXvmQgQWABN0LjlnZN7t40VdJjw6GuN6cyu70o11G/ZuKa6rtFKgMACiC6cPemcIAiGfvOutCdcAQILaK0ymUxFRcUrr7wyb968JUuW1NfX78O8Sm44o1ciiLf/9dsNlgcEFtAKpVKp3//+98ccc0xJSUlBQUF+fn7btm27du16++23V1dX74vP2LjqD+2DINGlf7XLV4DAAlqf2tra0aNHx+PxYHe6d+++dOnSFv+kG159LBYEHQ46NuMAAIEFtDLpdPr8888P9qhdu3Zr1qxp2c+7dt6U7EfuePAJLmABAgto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')
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment