Skip to content

Commit

Permalink
feat(ui): Treemap component
Browse files Browse the repository at this point in the history
  • Loading branch information
vio committed Oct 10, 2021
1 parent 4cc51eb commit 7e5ec21
Show file tree
Hide file tree
Showing 6 changed files with 404 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/ui/.eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules
lib
lib-esm
!**/.*.js
treemap-squarify.js
1 change: 1 addition & 0 deletions packages/ui/src/ui/treemap/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './treemap';
237 changes: 237 additions & 0 deletions packages/ui/src/ui/treemap/treemap-squarify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/*
* treemap-squarify.js - open source implementation of squarified treemaps
*
* Treemap Squared 0.5 - Treemap Charting library
*
* https://github.com/imranghory/treemap-squared/
*
* Copyright (c) 2012 Imran Ghory (imranghory@gmail.com)
* Licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) license.
*
*
* Implementation of the squarify treemap algorithm described in:
*
* Bruls, Mark; Huizing, Kees; van Wijk, Jarke J. (2000), "Squarified treemaps"
* in de Leeuw, W.; van Liere, R., Data Visualization 2000:
* Proc. Joint Eurographics and IEEE TCVG Symp. on Visualization, Springer-Verlag, pp. 33Ð42.
*
* Paper is available online at: http://www.win.tue.nl/~vanwijk/stm.pdf
*
* The code in this file is completeley decoupled from the drawing code so it should be trivial
* to port it to any other vector drawing library. Given an array of datapoints this library returns
* an array of cartesian coordinates that represent the rectangles that make up the treemap.
*
* The library also supports multidimensional data (nested treemaps) and performs normalization on the data.
*
* See the README file for more details.
*/

function Container(xoffset, yoffset, width, height) {
this.xoffset = xoffset; // offset from the the top left hand corner
this.yoffset = yoffset; // ditto
this.height = height;
this.width = width;

this.shortestEdge = function () {
return Math.min(this.height, this.width);
};

// getCoordinates - for a row of boxes which we've placed
// return an array of their cartesian coordinates
this.getCoordinates = function (row) {
var coordinates = [];
var subxoffset = this.xoffset, subyoffset = this.yoffset; //our offset within the container
var areawidth = sumArray(row) / this.height;
var areaheight = sumArray(row) / this.width;
var i;

if (this.width >= this.height) {
for (i = 0; i < row.length; i++) {
coordinates.push([subxoffset, subyoffset, subxoffset + areawidth, subyoffset + row[i] / areawidth]);
subyoffset = subyoffset + row[i] / areawidth;
}
} else {
for (i = 0; i < row.length; i++) {
coordinates.push([subxoffset, subyoffset, subxoffset + row[i] / areaheight, subyoffset + areaheight]);
subxoffset = subxoffset + row[i] / areaheight;
}
}
return coordinates;
};

// cutArea - once we've placed some boxes into an row we then need to identify the remaining area,
// this function takes the area of the boxes we've placed and calculates the location and
// dimensions of the remaining space and returns a container box defined by the remaining area
this.cutArea = function (area) {
var newcontainer;

if (this.width >= this.height) {
var areawidth = area / this.height;
var newwidth = this.width - areawidth;
newcontainer = new Container(this.xoffset + areawidth, this.yoffset, newwidth, this.height);
} else {
var areaheight = area / this.width;
var newheight = this.height - areaheight;
newcontainer = new Container(this.xoffset, this.yoffset + areaheight, this.width, newheight);
}
return newcontainer;
};
}



// normalize - the Bruls algorithm assumes we're passing in areas that nicely fit into our
// container box, this method takes our raw data and normalizes the data values into
// area values so that this assumption is valid.
function normalize(data, area) {
var normalizeddata = [];
var sum = sumArray(data);
var multiplier = area / sum;
var i;

for (i = 0; i < data.length; i++) {
normalizeddata[i] = data[i] * multiplier;
}
return normalizeddata;
}

// treemapMultidimensional - takes multidimensional data (aka [[23,11],[11,32]] - nested array)
// and recursively calls itself using treemapSingledimensional
// to create a patchwork of treemaps and merge them
function treemapMultidimensional(data, width, height, xoffset, yoffset) {
xoffset = (typeof xoffset === "undefined") ? 0 : xoffset;
yoffset = (typeof yoffset === "undefined") ? 0 : yoffset;

var mergeddata = [];
var mergedtreemap;
var results = [];
var i;

if(isArray(data[0])) { // if we've got more dimensions of depth
for(i=0; i<data.length; i++) {
mergeddata[i] = sumMultidimensionalArray(data[i]);
}
mergedtreemap = treemapSingledimensional(mergeddata, width, height, xoffset, yoffset);

for(i=0; i<data.length; i++) {
results.push(treemapMultidimensional(data[i], mergedtreemap[i][2] - mergedtreemap[i][0], mergedtreemap[i][3] - mergedtreemap[i][1], mergedtreemap[i][0], mergedtreemap[i][1]));
}
} else {
results = treemapSingledimensional(data,width,height, xoffset, yoffset);
}
return results;
}



// treemapSingledimensional - simple wrapper around squarify
function treemapSingledimensional(data, width, height, xoffset, yoffset) {
xoffset = (typeof xoffset === "undefined") ? 0 : xoffset;
yoffset = (typeof yoffset === "undefined") ? 0 : yoffset;

var rawtreemap = squarify(normalize(data, width * height), [], new Container(xoffset, yoffset, width, height), []);
return flattenTreemap(rawtreemap);
}

// flattenTreemap - squarify implementation returns an array of arrays of coordinates
// because we have a new array everytime we switch to building a new row
// this converts it into an array of coordinates.
function flattenTreemap(rawtreemap) {
var flattreemap = [];
var i, j;

for (i = 0; i < rawtreemap.length; i++) {
for (j = 0; j < rawtreemap[i].length; j++) {
flattreemap.push(rawtreemap[i][j]);
}
}
return flattreemap;
}

// squarify - as per the Bruls paper
// plus coordinates stack and containers so we get
// usable data out of it
function squarify(data, currentrow, container, stack) {
var length;
var nextdatapoint;
var newcontainer;

if (data.length === 0) {
stack.push(container.getCoordinates(currentrow));
return;
}

length = container.shortestEdge();
nextdatapoint = data[0];

if (improvesRatio(currentrow, nextdatapoint, length)) {
currentrow.push(nextdatapoint);
squarify(data.slice(1), currentrow, container, stack);
} else {
newcontainer = container.cutArea(sumArray(currentrow), stack);
stack.push(container.getCoordinates(currentrow));
squarify(data, [], newcontainer, stack);
}
return stack;
}

// improveRatio - implements the worse calculation and comparision as given in Bruls
// (note the error in the original paper; fixed here)
function improvesRatio(currentrow, nextnode, length) {
var newrow;

if (currentrow.length === 0) {
return true;
}

newrow = currentrow.slice();
newrow.push(nextnode);

var currentratio = calculateRatio(currentrow, length);
var newratio = calculateRatio(newrow, length);

// the pseudocode in the Bruls paper has the direction of the comparison
// wrong, this is the correct one.
return currentratio >= newratio;
}

// calculateRatio - calculates the maximum width to height ratio of the
// boxes in this row
function calculateRatio(row, length) {
var min = Math.min.apply(Math, row);
var max = Math.max.apply(Math, row);
var sum = sumArray(row);
return Math.max(Math.pow(length, 2) * max / Math.pow(sum, 2), Math.pow(sum, 2) / (Math.pow(length, 2) * min));
}

// isArray - checks if arr is an array
function isArray(arr) {
return arr && arr.constructor === Array;
}

// sumArray - sums a single dimensional array
function sumArray(arr) {
var sum = 0;
var i;

for (i = 0; i < arr.length; i++) {
sum += arr[i];
}
return sum;
}

// sumMultidimensionalArray - sums the values in a nested array (aka [[0,1],[[2,3]]])
function sumMultidimensionalArray(arr) {
var i, total = 0;

if(isArray(arr[0])) {
for(i=0; i<arr.length; i++) {
total += sumMultidimensionalArray(arr[i]);
}
} else {
total = sumArray(arr);
}
return total;
}

export default treemapSingledimensional;
87 changes: 87 additions & 0 deletions packages/ui/src/ui/treemap/treemap.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React, { useEffect, useRef, useState } from 'react';
import cx from 'classnames';
import PropTypes from 'prop-types';

import squarify from './treemap-squarify';
import css from './treemap.module.css'

const DefaultItem = ({ item, ...restProps }) => (
<div {...restProps}>
<span className={css.itemLabel}>
{item.data?.label || item.id}
</span>
</div>
);

DefaultItem.propTypes = {
item: PropTypes.shape({
id: PropTypes.string,
data: PropTypes.shape({
label: PropTypes.string,
}),
}).isRequired,
};

/**
* Treemap component
* @NOTE Based on https://github.com/pastelsky/bundlephobia/blob/babd0d71b54ad1404c4399e0c08363c493c53502/client/components/Treemap/Treemap.js
*/
export const Treemap = ({ className, data, Item }) => {
const rootClassName = cx(css.root, className);
const containerRef = useRef();
const [items, setItems] = useState([]);

useEffect(() => {
if (!data) {
return;
}

const width = containerRef?.current?.clientWidth || 640;
const height = containerRef?.current?.clientHeight || 210;

const values = data.map(({ value }) => value);
const itemCoordinates = squarify(values, width, height, 0, 0);

const newItems = data.map((item, index) => {
const coordinates = itemCoordinates[index];

// @todo - test relative position/dimension
return {
...item,
style: {
left: coordinates[0],
top: coordinates[1],
width: coordinates[2] - coordinates[0],
height: coordinates[3] - coordinates[1],
},
};
});

setItems(newItems);
}, [data]);

return (
<div className={rootClassName} ref={containerRef}>
{items.map((item) => (
<Item className={css.item} style={item.style} item={item} />
))}
</div>
);
};

Treemap.defaultProps = {
className: '',
Item: DefaultItem,
};

Treemap.propTypes = {
className: PropTypes.string,
data: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
value: PropTypes.number.isRequired,
data: PropTypes.object, // eslint-disable-line react/forbid-prop-types
}),
).isRequired,
Item: PropTypes.element,
};
35 changes: 35 additions & 0 deletions packages/ui/src/ui/treemap/treemap.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
.root {
position: relative;
border: 1px solid var(--color-outline);
}

.root::before {
content: '';
display: block;
padding: 0 0 33.33%;
}

.item {
position: absolute;
padding: var(--space-xxxsmall);
display: flex;
align-items: center;
justify-content: center;
}

.item::before {
left: 2px;
top: 2px;
right: 2px;
bottom: 2px;
content: '';
position: absolute;
display: block;
outline: 1px solid var(--color-outline);
}

.itemLabel {
max-width: 100%;
color: var(--color-text-light);
font-size: var(--size-small);
}
Loading

0 comments on commit 7e5ec21

Please sign in to comment.