Skip to content

Commit

Permalink
Merge pull request #456 from kiva/update-treemap-package
Browse files Browse the repository at this point in the history
feat: move treemap into repo
  • Loading branch information
dyersituations authored Sep 5, 2024
2 parents 2f3a38b + e75bd3c commit 3e9ad23
Show file tree
Hide file tree
Showing 5 changed files with 387 additions and 52 deletions.
1 change: 0 additions & 1 deletion @kiva/kv-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@
"nanoid": "^3.1.23",
"numeral": "^2.0.6",
"popper.js": "^1.16.1",
"treemap-squarify": "github:kiva/treemap",
"vue-demi": "^0.14.7"
},
"peerDependencies": {
Expand Down
248 changes: 248 additions & 0 deletions @kiva/kv-components/tests/unit/specs/utils/treemap.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { getTreemap } from '../../../../utils/treemap';

describe('treemap.js', () => {
describe('getTreemap', () => {
it('should return an error if malformed height argument', () => {
const shouldThrow = () => getTreemap({
data: [
{ value: 10 },
],
width: 700,
});
expect(shouldThrow).toThrow(Error('You need to specify the height of your treemap'));
});

it('should return an error if malformed height argument', () => {
const shouldThrow = () => getTreemap({
data: [
{ value: 10 },
],
width: 700,
height: '600',
});
expect(shouldThrow).toThrow(Error('You need to specify the height of your treemap'));
});

it('should return an error if malformed width argument', () => {
const shouldThrow = () => getTreemap({
data: [
{ value: 10 },
],
height: 700,
});
expect(shouldThrow).toThrow(Error('You need to specify the width of your treemap'));
});

it('should return an error if malformed width argument', () => {
const shouldThrow = () => getTreemap({
data: [
{ value: 10 },
],
width: '700',
height: 600,
});
expect(shouldThrow).toThrow(Error('You need to specify the width of your treemap'));
});

it('should return an error if malformed data argument', () => {
const shouldThrow = () => getTreemap({
data: [
{ value: 10 },
{ value: -1 },
],
width: 700,
height: 600,
});
expect(shouldThrow).toThrow(Error('You data must be in this format [{ value: 1 }, { value: 2 }], \'value\' being a positive number'));
});

it('should return an error if malformed data argument', () => {
const shouldThrow = () => getTreemap({
data: [
{ value: 10 },
{ value: '1' },
],
width: 700,
height: 600,
});
expect(shouldThrow).toThrow(Error('You data must be in this format [{ value: 1 }, { value: 2 }], \'value\' being a positive number'));
});

it('should return an error if malformed data argument', () => {
const shouldThrow = () => getTreemap({
data: [
{ value: 10 },
1,
],
width: 700,
height: 600,
});
expect(shouldThrow).toThrow(Error('You data must be in this format [{ value: 1 }, { value: 2 }], \'value\' being a positive number'));
});

it('should return an error if malformed data argument', () => {
const shouldThrow = () => getTreemap({
data: '[{ value: 10 }]',
width: 700,
height: 600,
});
expect(shouldThrow).toThrow(Error('You data must be in this format [{ value: 1 }, { value: 2 }], \'value\' being a positive number'));
});

it('should return an error if malformed data argument', () => {
const shouldThrow = () => getTreemap({
width: 700,
height: 600,
});
expect(shouldThrow).toThrow(Error('You data must be in this format [{ value: 1 }, { value: 2 }], \'value\' being a positive number'));
});

it('should return an error if malformed data argument', () => {
const shouldThrow = () => getTreemap({
data: [],
width: 700,
height: 600,
});
expect(shouldThrow).toThrow(Error('You data must be in this format [{ value: 1 }, { value: 2 }], \'value\' being a positive number'));
});

it('should return the result expected', () => {
const result = [
{
x: 0,
y: 0,
width: 330.56,
height: 352.94,
data: { value: 10, color: 'red' },
},
{
x: 0,
y: 352.94,
width: 330.56,
height: 247.06,
data: { value: 7, color: 'black' },
},
{
x: 330.56,
y: 0,
width: 295.56,
height: 157.89,
data: { value: 4, color: 'blue' },
},
{
x: 626.11,
y: 0,
width: 73.89,
height: 157.89,
data: { value: 1, color: 'white' },
},
{
x: 330.56,
y: 157.89,
width: 369.44,
height: 157.89,
data: { value: 5, color: 'green' },
},
{
x: 330.56,
y: 315.79,
width: 369.44,
height: 284.21,
data: { value: 9, color: 'grey' },
},
];
expect(getTreemap({
data: [
{ value: 10, color: 'red' },
{ value: 7, color: 'black' },
{ value: 4, color: 'blue' },
{ value: 1, color: 'white' },
{ value: 5, color: 'green' },
{ value: 9, color: 'grey' },
],
width: 700,
height: 600,
})).toEqual(result);
});

it('should return the result expected', () => {
const result = [
{
x: 0,
y: 0,
width: 296.97,
height: 257.14,
data: { value: 6 },
},
{
x: 0,
y: 257.14,
width: 296.97,
height: 342.86,
data: { value: 8 },
},
{
x: 296.97,
y: 0,
width: 201.52,
height: 315.79,
data: { value: 5 },
},
{
x: 498.48,
y: 0,
width: 201.52,
height: 315.79,
data: { value: 5 },
},
{
x: 296.97,
y: 315.79,
width: 358.25,
height: 284.21,
data: { value: 8 },
},
{
x: 655.22,
y: 315.79,
width: 44.78,
height: 284.21,
data: { value: 1 },
},
];
expect(getTreemap({
data: [
{ value: 6 },
{ value: 8 },
{ value: 5 },
{ value: 5 },
{ value: 8 },
{ value: 1 },
],
width: 700,
height: 600,
})).toEqual(result);
});

it('should return the result with one data point', () => {
const result = [
{
data: {
value: 9,
},
height: 600,
width: 700,
x: 0,
y: 0,
},
];
expect(getTreemap({
data: [
{ value: 9 },
],
width: 700,
height: 600,
})).toEqual(result);
});
});
});
138 changes: 138 additions & 0 deletions @kiva/kv-components/utils/treemap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/* eslint-disable import/prefer-default-export, max-len, no-shadow */

const getMaximum = (array) => Math.max(...array);

const getMinimum = (array) => Math.min(...array);

const sumReducer = (acc, cur) => acc + cur;

const roundValue = (number) => Math.max(Math.round(number * 100) / 100, 0);

const validateArguments = ({ data, width, height }) => {
if (!width || typeof width !== 'number' || width < 0) {
throw new Error('You need to specify the width of your treemap');
}
if (!height || typeof height !== 'number' || height < 0) {
throw new Error('You need to specify the height of your treemap');
}
if (!data || !Array.isArray(data) || data.length === 0 || !data.every((dataPoint) => Object.prototype.hasOwnProperty.call(dataPoint, 'value') && typeof dataPoint.value === 'number' && dataPoint.value >= 0 && !Number.isNaN(dataPoint.value))) {
throw new Error('You data must be in this format [{ value: 1 }, { value: 2 }], \'value\' being a positive number');
}
};

/**
* Used to calculate the coordinates of a treemap representation following the "squarify" algorithm
*
* Clément Bataille, 2020
* Licensed under the MIT license
*
* {@link https://github.com/clementbat/treemap}
*
* @param param0.data The coordinate data to use in the calculation
* @param param0.width The width of the treemap
* @param param0.height The height of the treemap
* @returns The calculated coordinates
*/
export function getTreemap({ data, width, height }) {
let Rectangle = {};
let initialData = [];

function worstRatio(row, width) {
const sum = row.reduce(sumReducer, 0);
const rowMax = getMaximum(row);
const rowMin = getMinimum(row);
return Math.max(((width ** 2) * rowMax) / (sum ** 2), (sum ** 2) / ((width ** 2) * rowMin));
}

const getMinWidth = () => {
if (Rectangle.totalHeight ** 2 > Rectangle.totalWidth ** 2) {
return { value: Rectangle.totalWidth, vertical: false };
}
return { value: Rectangle.totalHeight, vertical: true };
};

const layoutRow = (row, width, vertical) => {
const rowHeight = row.reduce(sumReducer, 0) / width;

row.forEach((rowItem) => {
const rowWidth = rowItem / rowHeight;
const { xBeginning } = Rectangle;
const { yBeginning } = Rectangle;

let data;
if (vertical) {
data = {
x: xBeginning,
y: yBeginning,
width: rowHeight,
height: rowWidth,
data: initialData[Rectangle.data.length],
};
Rectangle.yBeginning += rowWidth;
} else {
data = {
x: xBeginning,
y: yBeginning,
width: rowWidth,
height: rowHeight,
data: initialData[Rectangle.data.length],
};
Rectangle.xBeginning += rowWidth;
}

Rectangle.data.push(data);
});

if (vertical) {
Rectangle.xBeginning += rowHeight;
Rectangle.yBeginning -= width;
Rectangle.totalWidth -= rowHeight;
} else {
Rectangle.xBeginning -= width;
Rectangle.yBeginning += rowHeight;
Rectangle.totalHeight -= rowHeight;
}
};

const layoutLastRow = (rows, children, width) => {
const { vertical } = getMinWidth();
layoutRow(rows, width, vertical);
layoutRow(children, width, vertical);
};

const squarify = (children, row, width) => {
if (children.length === 1) {
return layoutLastRow(row, children, width);
}

const rowWithChild = [...row, children[0]];

if (row.length === 0 || worstRatio(row, width) >= worstRatio(rowWithChild, width)) {
children.shift();
return squarify(children, rowWithChild, width);
}
layoutRow(row, width, getMinWidth().vertical);
return squarify(children, [], getMinWidth().value);
};

validateArguments({ data, width, height });
Rectangle = {
data: [],
xBeginning: 0,
yBeginning: 0,
totalWidth: width,
totalHeight: height,
};
initialData = data;
const totalValue = data.map((dataPoint) => dataPoint.value).reduce(sumReducer, 0);
const dataScaled = data.map((dataPoint) => (dataPoint.value * height * width) / totalValue);

squarify(dataScaled, [], getMinWidth().value);
return Rectangle.data.map((dataPoint) => ({
...dataPoint,
x: roundValue(dataPoint.x),
y: roundValue(dataPoint.y),
width: roundValue(dataPoint.width),
height: roundValue(dataPoint.height),
}));
}
Loading

0 comments on commit 3e9ad23

Please sign in to comment.