From e75bd3c83d7ede41dc07196392bafdf941b0bcc3 Mon Sep 17 00:00:00 2001 From: Casey Dyer Date: Wed, 4 Sep 2024 16:27:12 -0700 Subject: [PATCH] feat: move treemap into repo --- @kiva/kv-components/package.json | 1 - .../tests/unit/specs/utils/treemap.spec.js | 248 ++++++++++++++++++ @kiva/kv-components/utils/treemap.js | 138 ++++++++++ @kiva/kv-components/vue/KvTreeMapChart.vue | 2 +- package-lock.json | 50 ---- 5 files changed, 387 insertions(+), 52 deletions(-) create mode 100644 @kiva/kv-components/tests/unit/specs/utils/treemap.spec.js create mode 100644 @kiva/kv-components/utils/treemap.js diff --git a/@kiva/kv-components/package.json b/@kiva/kv-components/package.json index 45557abd..0009e8c1 100644 --- a/@kiva/kv-components/package.json +++ b/@kiva/kv-components/package.json @@ -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": { diff --git a/@kiva/kv-components/tests/unit/specs/utils/treemap.spec.js b/@kiva/kv-components/tests/unit/specs/utils/treemap.spec.js new file mode 100644 index 00000000..9de5261a --- /dev/null +++ b/@kiva/kv-components/tests/unit/specs/utils/treemap.spec.js @@ -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); + }); + }); +}); diff --git a/@kiva/kv-components/utils/treemap.js b/@kiva/kv-components/utils/treemap.js new file mode 100644 index 00000000..a751fedb --- /dev/null +++ b/@kiva/kv-components/utils/treemap.js @@ -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), + })); +} diff --git a/@kiva/kv-components/vue/KvTreeMapChart.vue b/@kiva/kv-components/vue/KvTreeMapChart.vue index 8136ae13..dae440fb 100644 --- a/@kiva/kv-components/vue/KvTreeMapChart.vue +++ b/@kiva/kv-components/vue/KvTreeMapChart.vue @@ -43,8 +43,8 @@