From bf97ec2a212c314b34f82fdde0b519ee905e3098 Mon Sep 17 00:00:00 2001 From: Conglei Shi Date: Fri, 10 Nov 2017 13:13:09 -0800 Subject: [PATCH 1/4] created vx/stats for stats glyphs violin and box --- packages/vx-demo/components/tiles/boxplot.js | 221 ++++++++++-------- packages/vx-demo/package.json | 1 + .../vx-mock-data/src/generators/genBoxPlot.js | 21 -- .../vx-mock-data/src/generators/genStats.js | 67 ++++++ packages/vx-mock-data/src/index.js | 2 +- packages/vx-stats/.babelrc | 19 ++ packages/vx-stats/.npmrc | 1 + packages/vx-stats/Makefile | 1 + packages/vx-stats/Readme.md | 37 +++ packages/vx-stats/package.json | 50 ++++ packages/vx-stats/src/boxplot/BoxPlot.js | 207 ++++++++++++++++ packages/vx-stats/src/index.js | 2 + packages/vx-stats/src/util/additionalProps.js | 8 + packages/vx-stats/src/util/callOrValue.js | 6 + .../vx-stats/src/violinplot/ViolinPlot.js | 73 ++++++ 15 files changed, 595 insertions(+), 121 deletions(-) delete mode 100644 packages/vx-mock-data/src/generators/genBoxPlot.js create mode 100644 packages/vx-mock-data/src/generators/genStats.js create mode 100644 packages/vx-stats/.babelrc create mode 100644 packages/vx-stats/.npmrc create mode 100644 packages/vx-stats/Makefile create mode 100644 packages/vx-stats/Readme.md create mode 100644 packages/vx-stats/package.json create mode 100644 packages/vx-stats/src/boxplot/BoxPlot.js create mode 100644 packages/vx-stats/src/index.js create mode 100644 packages/vx-stats/src/util/additionalProps.js create mode 100644 packages/vx-stats/src/util/callOrValue.js create mode 100644 packages/vx-stats/src/violinplot/ViolinPlot.js diff --git a/packages/vx-demo/components/tiles/boxplot.js b/packages/vx-demo/components/tiles/boxplot.js index 477d01079..38ac7d451 100644 --- a/packages/vx-demo/components/tiles/boxplot.js +++ b/packages/vx-demo/components/tiles/boxplot.js @@ -1,23 +1,25 @@ import React from 'react'; import { Group } from '@vx/group'; -import { BoxPlot } from '@vx/boxplot'; +import { ViolinPlot, BoxPlot } from '@vx/stats'; import { LinearGradient } from '@vx/gradient'; import { scaleBand, scaleLinear } from '@vx/scale'; -import { genBoxPlot } from '@vx/mock-data'; +import { genStats } from '@vx/mock-data'; import { withTooltip, Tooltip } from '@vx/tooltip'; import { extent } from 'd3-array'; import { format } from 'd3-format'; +import { PatternLines } from '@vx/pattern'; -const data = genBoxPlot(5); +const data = genStats(5); const twoDecimalFormat = format('.2f'); // accessors -const x = d => d.x; -const min = d => d.min; -const max = d => d.max; -const median = d => d.median; -const firstQuartile = d => d.firstQuartile; -const thirdQuartile = d => d.thirdQuartile; +const x = d => d.boxPlot.x; +const min = d => d.boxPlot.min; +const max = d => d.boxPlot.max; +const median = d => d.boxPlot.median; +const firstQuartile = d => d.boxPlot.firstQuartile; +const thirdQuartile = d => d.boxPlot.thirdQuartile; +const outliers = d => d.boxPlot.outliers; export default withTooltip( ({ @@ -38,29 +40,29 @@ export default withTooltip( const yMax = height - 120; // scales - const xScale = scaleBand({ - rangeRound: [0, xMax], + const yScale = scaleBand({ + rangeRound: [0, yMax], domain: data.map(x), padding: 0.4 }); const values = data.reduce( - (r, e) => r.push(e.min, e.max) && r, + (r, { boxPlot:e }) => r.push(e.min, e.max) && r, [] ); - const minYValue = Math.min(...values); - const maxYValue = Math.max(...values); - const yDomain = [ - minYValue - 0.1 * Math.abs(minYValue), - maxYValue + 0.1 * Math.abs(minYValue) + const minValue = Math.min(...values); + const maxValue = Math.max(...values); + const valueDomain = [ + minValue - 0.1 * Math.abs(minValue), + maxValue + 0.1 * Math.abs(maxValue) ]; - const yScale = scaleLinear({ - rangeRound: [yMax, 0], - domain: [minYValue, maxYValue] + const xScale = scaleLinear({ + rangeRound: [20, xMax-20], + domain: [minValue, maxValue] }); - const boxWidth = xScale.bandwidth(); + const boxWidth = yScale.bandwidth(); const actualyWidth = Math.min(40, boxWidth); return ( @@ -75,86 +77,107 @@ export default withTooltip( fill={`url(#boxplot)`} rx={14} /> + {data.map((d, i) => - event => { - showTooltip({ - tooltipTop: yScale(data.data.min) + 40, - tooltipLeft: data.x2 + 5, - tooltipData: { - min: data.data.min, - name: x(d) - } - }); - }, - onMouseLeave: event => event => { - hideTooltip(); - } - }} - maxProps={{ - onMouseOver: data => event => { - showTooltip({ - tooltipTop: yScale(data.data.max) + 40, - tooltipLeft: data.x2 + 5, - tooltipData: { - max: data.data.max, - name: x(d) - } - }); - }, - onMouseLeave: event => event => { - hideTooltip(); - } - }} - boxProps={{ - onMouseOver: data => event => { - showTooltip({ - tooltipTop: yScale(data.data.median) + 40, - tooltipLeft: data.x2 + 5, - tooltipData: { - ...data.data, - name: x(d) - } - }); - }, - onMouseLeave: event => event => { - hideTooltip(); - } - }} - medianProps={{ - style: { - stroke: 'white' - }, - onMouseOver: data => event => { - showTooltip({ - tooltipTop: data.median + 40, - tooltipLeft: data.x2 + 5, - tooltipData: { - median: data.data.median, - name: x(d) - } - }); - }, - onMouseLeave: data => event => { - hideTooltip(); - } - }} - /> + + + event => { + showTooltip({ + tooltipTop: yScale(data.data.boxPlot.min) + 40, + tooltipLeft: data.x2 + 5, + tooltipData: { + min: data.data.boxPlot.min, + name: x(d) + } + }); + }, + onMouseLeave: event => event => { + hideTooltip(); + } + }} + maxProps={{ + onMouseOver: data => event => { + showTooltip({ + tooltipTop: xScale(data.data.boxPlot.max) + 40, + tooltipLeft: data.x2 + 5, + tooltipData: { + max: data.data.boxPlot.max, + name: x(d) + } + }); + }, + onMouseLeave: event => event => { + hideTooltip(); + } + }} + boxProps={{ + onMouseOver: data => event => { + showTooltip({ + tooltipTop: xScale(data.data.boxPlot.median) + 40, + tooltipLeft: data.x2 + 5, + tooltipData: { + ...data.data.boxPlot, + name: x(d) + } + }); + }, + onMouseLeave: event => event => { + hideTooltip(); + } + }} + medianProps={{ + style: { + stroke: 'white' + }, + onMouseOver: data => event => { + showTooltip({ + tooltipTop: data.median + 40, + tooltipLeft: data.x2 + 5, + tooltipData: { + median: data.data.boxPlot.median, + name: x(d) + } + }); + }, + onMouseLeave: data => event => { + hideTooltip(); + } + }} + /> + )} diff --git a/packages/vx-demo/package.json b/packages/vx-demo/package.json index 67accbff6..97cddfbda 100644 --- a/packages/vx-demo/package.json +++ b/packages/vx-demo/package.json @@ -43,6 +43,7 @@ "@vx/text": "0.0.143", "@vx/tooltip": "0.0.143", "@vx/voronoi": "0.0.143", + "@vx/stats": "0.0.143", "classnames": "^2.2.5", "d3-array": "^1.1.1", "d3-collection": "^1.0.4", diff --git a/packages/vx-mock-data/src/generators/genBoxPlot.js b/packages/vx-mock-data/src/generators/genBoxPlot.js deleted file mode 100644 index f1ab8b615..000000000 --- a/packages/vx-mock-data/src/generators/genBoxPlot.js +++ /dev/null @@ -1,21 +0,0 @@ -export default function genBoxPlot(number) { - const data = []; - let i; - for (i = 0; i < number; i += 1) { - const points = []; - let j; - for (j = 0; j < 5; j += 1) { - points.push(Math.random() * 100); - } - points.sort((a, b) => a - b); - data.push({ - x: `Statistics ${i}`, - min: points[0], - firstQuartile: points[1], - median: points[2], - thirdQuartile: points[3], - max: points[4] - }); - } - return data; -} diff --git a/packages/vx-mock-data/src/generators/genStats.js b/packages/vx-mock-data/src/generators/genStats.js new file mode 100644 index 000000000..6ba575ff4 --- /dev/null +++ b/packages/vx-mock-data/src/generators/genStats.js @@ -0,0 +1,67 @@ +import { randomNormal } from 'd3-random'; + +const random = randomNormal(4, 3); +const randomOffset = () => Math.random() * 10; +const sampleSize = 1000; + +export default function genStats(number) { + const data = []; + let i; + for (i = 0; i < number; i += 1) { + const points = []; + let j; + const offset = randomOffset(); + for (j = 0; j < sampleSize; j += 1) { + points.push(offset + random()); + } + + points.sort((a, b) => a-b); + + const firstQuartile = points[Math.round(sampleSize/4)]; + const thirdQuartile = points[Math.round(3 * sampleSize/4)]; + const IQR = thirdQuartile - firstQuartile; + + const min = firstQuartile - 1.5 * IQR; + const max = thirdQuartile + 1.5 * IQR; + + const outliers = points.filter(p => p < min || p > max); + const binWidth = 2 * IQR * ((sampleSize - outliers.length) ** (-1/3)); + const binNum = Math.round((max - min) / binWidth); + const actualBinWidth = (max - min) / binNum; + + const bins = Array(binNum + 2).fill(0); + const values = Array(binNum + 2).fill(min); + + for (let i = 1; i <= binNum; i += 1){ + values[i] += actualBinWidth * (i - 0.5); + } + + values[values.length-1] = max; + + points.filter(p => p >= min && p <= max).forEach(p => { + bins[Math.floor((p-min)/actualBinWidth) + 1] += 1; + }); + + const binData = values.map((v, i) => ({ + value: v, + count: bins[i], + })); + + const boxPlot = { + x: `Statistics ${i}`, + min, + firstQuartile, + median: points[Math.round(sampleSize/2)], + thirdQuartile, + max, + outliers, + }; + + + data.push({ + boxPlot, + binData, + }); + } + return data; +} diff --git a/packages/vx-mock-data/src/index.js b/packages/vx-mock-data/src/index.js index c4bf9f262..6fa83eb2b 100644 --- a/packages/vx-mock-data/src/index.js +++ b/packages/vx-mock-data/src/index.js @@ -4,7 +4,7 @@ export { } from './generators/genRandomNormalPoints'; export { default as genBin } from './generators/genBin'; export { default as genBins } from './generators/genBins'; -export { default as genBoxPlot } from './generators/genBoxPlot'; +export { default as genStats } from './generators/genStats'; export { default as appleStock } from './mocks/appleStock'; export { default as letterFrequency } from './mocks/letterFrequency'; export { default as browserUsage } from './mocks/browserUsage'; diff --git a/packages/vx-stats/.babelrc b/packages/vx-stats/.babelrc new file mode 100644 index 000000000..44157e5b6 --- /dev/null +++ b/packages/vx-stats/.babelrc @@ -0,0 +1,19 @@ +{ + "presets": ["es2015", "react", "stage-0"], + "plugins": [], + "env": { + "development": { + "plugins": [ + ["react-transform", { + "transforms": [{ + "transform": "react-transform-hmr", + "imports": ["react"], + "locals": ["module"] + }] + }], + "transform-runtime", + "transform-decorators-legacy" + ] + } + } +} diff --git a/packages/vx-stats/.npmrc b/packages/vx-stats/.npmrc new file mode 100644 index 000000000..9cf949503 --- /dev/null +++ b/packages/vx-stats/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/packages/vx-stats/Makefile b/packages/vx-stats/Makefile new file mode 100644 index 000000000..f7e19ad08 --- /dev/null +++ b/packages/vx-stats/Makefile @@ -0,0 +1 @@ +include node_modules/react-fatigue-dev/Makefile diff --git a/packages/vx-stats/Readme.md b/packages/vx-stats/Readme.md new file mode 100644 index 000000000..26c78f479 --- /dev/null +++ b/packages/vx-stats/Readme.md @@ -0,0 +1,37 @@ +# @vx/boxplot + +``` +npm install --save @vx/boxplot +``` + +A boxplot shows the minimum, maximum, and quartiles of a dataset. + +You can pass in props to target the `min`, `max`, `median`, and `box (interquartile range)` shapes using `minProps`, `maxProps`, `medianProps`, and `boxProps`. + +If you are looking to add events over the each boxplot group you can pass in `container={true}` and `containerProps={{ /** */ }}`. + +## Properties + +| Name |Default| Type | Description | +|:------------------|:------|:----------|:------------------------------| +| className | | string | The className for the boxplot | +| left | 0 | number | The left offset of the boxplot | +| data | | array | An array of data | +| max | | number | The maximum value for boxplot | +| min | | number | The minimum value for boxplot | +| firstQuartile | | number | The value for the first quartile for the boxplot | +| thirdQuartile | | number | The value for the third quartile for the boxplot | +| median | | number | The median value for the boxplot | +| boxWidth | | number | The width of the box | +| fill | | string | The color of the box | +| fillOpacity | | number | The opacity of the box | +| stroke | | string | The color of the lines in boxplot | +| strokeWidth | | number | Width of the lines in boxplot | +| rx | 2 | number | The [x-axis radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx) for the box | +| ry | 2 | number | The [y-axis radius](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/ry) for the box | +| container | false | boolean | Set to true and add `containerProps = {{ /** */}}` to add events to each boxplot group | +| maxProps | {} | object | Props passed to target the `line` shape for the maximum | +| minProps | {} | object | Props passed to target the `line` shape for the minimum | +| medianProps | {} | object | Props passed to target the `line` shape for the median| +| boxProps | {} | object | Props passed to target the `rect` shape for the box | +| containerProps | {} | object | Props passed to add events over each boxplot group (requires `container={true}`) | diff --git a/packages/vx-stats/package.json b/packages/vx-stats/package.json new file mode 100644 index 000000000..20f2a6231 --- /dev/null +++ b/packages/vx-stats/package.json @@ -0,0 +1,50 @@ +{ + "name": "@vx/stats", + "version": "0.0.140", + "description": "vx stats box violin", + "main": "build/index.js", + "scripts": { + "build": "make build SRC=./src", + "prepublish": "make build SRC=./src", + "test": "jest" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hshoff/vx.git" + }, + "keywords": [ + "vx", + "react", + "d3", + "visualizations", + "charts" + ], + "author": "@conglei", + "license": "MIT", + "bugs": { + "url": "https://github.com/hshoff/vx/issues" + }, + "homepage": "https://github.com/hshoff/vx#readme", + "dependencies": { + "@vx/group": "0.0.140", + "@vx/scale": "0.0.140", + "classnames": "^2.2.5", + "d3-shape": "^1.2.0" + }, + "devDependencies": { + "babel-jest": "^20.0.3", + "enzyme": "^2.8.2", + "jest": "^20.0.3", + "react": "^15.0.0-0 || ^16.0.0-0", + "react-fatigue-dev": "github:tj/react-fatigue-dev", + "react-test-renderer": "^15.6.1", + "react-tools": "^0.10.0", + "regenerator-runtime": "^0.10.5" + }, + "peerDependencies": { + "react": "^15.0.0-0 || ^16.0.0-0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/vx-stats/src/boxplot/BoxPlot.js b/packages/vx-stats/src/boxplot/BoxPlot.js new file mode 100644 index 000000000..a0435c968 --- /dev/null +++ b/packages/vx-stats/src/boxplot/BoxPlot.js @@ -0,0 +1,207 @@ +import React from 'react'; +import classnames from 'classnames'; +import { Group } from '@vx/group'; +import additionalProps from '../util/additionalProps'; + +function verticalToHorizontal([x1, y1, x2, y2]){ + return [y1, x1, y2, x2]; +} + +export default function BoxPlot({ + left = 0, + top = 0, + className, + data, + max, + min, + firstQuartile, + thirdQuartile, + median, + boxWidth, + fill, + fillOpacity, + stroke, + strokeWidth, + rx = 2, + ry = 2, + valueScale, + outliers, + horizontal, + medianProps = {}, + maxProps = {}, + minProps = {}, + boxProps = {}, + outlierProps = {}, + container = false, + containerProps = {}, + ...restProps +}) { + const offset = horizontal? top: left; + const center = offset + boxWidth / 2; + let maxLinePos = Array(4).fill(0); + let maxToBoxLinePos = Array(4).fill(0); + let boxPos = Array(4).fill(0); + let medianLinePos = Array(4).fill(0); + let minToBoxLinePos = Array(4).fill(0); + let minLinePos = Array(4).fill(0); + maxLinePos[0] = center - boxWidth / 4; + maxLinePos[1] = valueScale(max); + maxLinePos[2] = center + boxWidth / 4; + maxLinePos[3] = valueScale(max); + + maxToBoxLinePos[0] = center; + maxToBoxLinePos[1] = valueScale(max); + maxToBoxLinePos[2] = center; + maxToBoxLinePos[3] = valueScale(thirdQuartile); + + boxPos[0] = offset; + boxPos[1] = valueScale(thirdQuartile); + boxPos[2] = boxWidth; + boxPos[3] = Math.abs(valueScale(thirdQuartile) - valueScale(firstQuartile)); + + medianLinePos[0] = offset; + medianLinePos[1] = valueScale(median); + medianLinePos[2] = offset + boxWidth; + medianLinePos[3] = valueScale(median); + + minToBoxLinePos[0] = center; + minToBoxLinePos[1] = valueScale(firstQuartile); + minToBoxLinePos[2] = center; + minToBoxLinePos[3] = valueScale(min); + + minLinePos[0] = center - boxWidth / 4; + minLinePos[1] = valueScale(min); + minLinePos[2] = center + boxWidth / 4; + minLinePos[3] = valueScale(min); + if (horizontal) { + maxLinePos = verticalToHorizontal(maxLinePos); + maxToBoxLinePos = verticalToHorizontal(maxToBoxLinePos); + boxPos = verticalToHorizontal(boxPos); + boxPos[0] = valueScale(firstQuartile); + medianLinePos = verticalToHorizontal(medianLinePos); + minToBoxLinePos = verticalToHorizontal(minToBoxLinePos); + minLinePos = verticalToHorizontal(minLinePos); + } + return ( + + + + + + + + {outliers.map((d, i) => { + return ( + ); + }) + } + {container && + } + + ); +} diff --git a/packages/vx-stats/src/index.js b/packages/vx-stats/src/index.js new file mode 100644 index 000000000..e007b5703 --- /dev/null +++ b/packages/vx-stats/src/index.js @@ -0,0 +1,2 @@ +export { default as BoxPlot } from './boxplot/BoxPlot'; +export { default as ViolinPlot } from './violinplot/ViolinPlot'; diff --git a/packages/vx-stats/src/util/additionalProps.js b/packages/vx-stats/src/util/additionalProps.js new file mode 100644 index 000000000..b3ac93bc3 --- /dev/null +++ b/packages/vx-stats/src/util/additionalProps.js @@ -0,0 +1,8 @@ +import callOrValue from './callOrValue'; + +export default function additionalProps(restProps, data) { + return Object.keys(restProps).reduce((ret, cur) => { + ret[cur] = callOrValue(restProps[cur], data); + return ret; + }, {}); +} diff --git a/packages/vx-stats/src/util/callOrValue.js b/packages/vx-stats/src/util/callOrValue.js new file mode 100644 index 000000000..77afb1a09 --- /dev/null +++ b/packages/vx-stats/src/util/callOrValue.js @@ -0,0 +1,6 @@ +export default function callOrValue(maybeFn, data) { + if (typeof maybeFn === 'function') { + return maybeFn(data); + } + return maybeFn; +} diff --git a/packages/vx-stats/src/violinplot/ViolinPlot.js b/packages/vx-stats/src/violinplot/ViolinPlot.js new file mode 100644 index 000000000..54ded22cd --- /dev/null +++ b/packages/vx-stats/src/violinplot/ViolinPlot.js @@ -0,0 +1,73 @@ +import React from 'react'; +import classnames from 'classnames'; +import { Group } from '@vx/group'; +import { scaleLinear } from '@vx/scale'; +import { line, curveCardinal } from 'd3-shape'; +import additionalProps from '../util/additionalProps'; + +export default function ViolinPlot({ + left = 0, + top = 0, + className, + binData, + stroke = 'black', + fill = 'rgba(0,0,0,0.3)', + opacity, + strokeWidth, + width, + valueScale, + strokeDasharray, + horizontal, + ...restProps +}) { + const center = (horizontal?top:left) + width/2; + const binCounts = binData.map(bin => bin.count); + const widthScale = scaleLinear({ + rangeRound: [0, width/2], + domain: [0, Math.max(...binCounts)] + }); + + let path = ""; + if (horizontal){ + const topCurve = line() + .x(d => valueScale(d.value)) + .y(d => center - widthScale(d.count)) + .curve(curveCardinal); + + const bottomCurve = line() + .x(d => valueScale(d.value)) + .y(d => center + widthScale(d.count)) + .curve(curveCardinal); + + const topCurvePath = topCurve(binData); + const bottomCurvePath = bottomCurve([...binData].reverse()); + path = `${topCurvePath} ${bottomCurvePath.replace('M','L')} Z`; + } else { + const rightCurve = line() + .x(d => center + widthScale(d.count)) + .y(d => valueScale(d.value)) + .curve(curveCardinal); + + const leftCurve = line() + .x(d => center - widthScale(d.count)) + .y(d => valueScale(d.value)) + .curve(curveCardinal); + + const rightCurvePath = rightCurve(binData); + const leftCurvePath = leftCurve([...binData].reverse()); + path = `${rightCurvePath} ${leftCurvePath.replace('M','L')} Z`; + } + return ( + + + + ); +} From 4f732d97232da964fb1348ae58a3a771663e5296 Mon Sep 17 00:00:00 2001 From: Conglei Shi Date: Fri, 10 Nov 2017 13:46:00 -0800 Subject: [PATCH 2/4] updated galleries --- packages/vx-demo/components/gallery.js | 4 +-- packages/vx-demo/components/tiles/boxplot.js | 37 ++++++++++---------- packages/vx-demo/pages/boxplot.js | 2 +- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/vx-demo/components/gallery.js b/packages/vx-demo/components/gallery.js index a72c49662..e061cfe60 100644 --- a/packages/vx-demo/components/gallery.js +++ b/packages/vx-demo/components/gallery.js @@ -527,9 +527,9 @@ export default class Gallery extends React.Component { className="details" style={{ color: '#FFFFFF', zIndex: 1 }} > -
BoxPlot
+
Stats Plots
-
{` `}
+
{` +  `}
diff --git a/packages/vx-demo/components/tiles/boxplot.js b/packages/vx-demo/components/tiles/boxplot.js index 38ac7d451..2e766a594 100644 --- a/packages/vx-demo/components/tiles/boxplot.js +++ b/packages/vx-demo/components/tiles/boxplot.js @@ -40,8 +40,8 @@ export default withTooltip( const yMax = height - 120; // scales - const yScale = scaleBand({ - rangeRound: [0, yMax], + const xScale = scaleBand({ + rangeRound: [0, xMax], domain: data.map(x), padding: 0.4 }); @@ -50,19 +50,19 @@ export default withTooltip( (r, { boxPlot:e }) => r.push(e.min, e.max) && r, [] ); - const minValue = Math.min(...values); - const maxValue = Math.max(...values); - const valueDomain = [ - minValue - 0.1 * Math.abs(minValue), - maxValue + 0.1 * Math.abs(maxValue) + const minYValue = Math.min(...values); + const maxYValue = Math.max(...values); + const yDomain = [ + minYValue - 0.1 * Math.abs(minYValue), + maxYValue + 0.1 * Math.abs(minYValue) ]; - const xScale = scaleLinear({ - rangeRound: [20, xMax-20], - domain: [minValue, maxValue] + const yScale = scaleLinear({ + rangeRound: [yMax, 0], + domain: [minYValue, maxYValue] }); - const boxWidth = yScale.bandwidth(); + const boxWidth = xScale.bandwidth(); const actualyWidth = Math.min(40, boxWidth); return ( @@ -84,6 +84,7 @@ export default withTooltip( stroke='#ced4da' strokeWidth={1} fill='rgba(0,0,0,0.3)' + orientation={['horizontal']} /> {data.map((d, i) => @@ -91,17 +92,16 @@ export default withTooltip( event => { @@ -131,7 +130,7 @@ export default withTooltip( maxProps={{ onMouseOver: data => event => { showTooltip({ - tooltipTop: xScale(data.data.boxPlot.max) + 40, + tooltipTop: yScale(data.data.boxPlot.max) + 40, tooltipLeft: data.x2 + 5, tooltipData: { max: data.data.boxPlot.max, @@ -146,7 +145,7 @@ export default withTooltip( boxProps={{ onMouseOver: data => event => { showTooltip({ - tooltipTop: xScale(data.data.boxPlot.median) + 40, + tooltipTop: yScale(data.data.boxPlot.median) + 40, tooltipLeft: data.x2 + 5, tooltipData: { ...data.data.boxPlot, diff --git a/packages/vx-demo/pages/boxplot.js b/packages/vx-demo/pages/boxplot.js index 70ec6703a..25b97d7ae 100644 --- a/packages/vx-demo/pages/boxplot.js +++ b/packages/vx-demo/pages/boxplot.js @@ -8,7 +8,7 @@ export default () => { events={true} margin={{ top: 80 }} component={BoxPlot} - title="Box Plot" + title="BoxPlot With ViolinPlot" > {`import React from 'react'; import { Group } from '@vx/group'; From 1e0d89bcab6f13fe84beba66e16ca6ff6cc0d501 Mon Sep 17 00:00:00 2001 From: Conglei Shi Date: Fri, 10 Nov 2017 14:10:15 -0800 Subject: [PATCH 3/4] updated the version no. --- packages/vx-stats/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vx-stats/package.json b/packages/vx-stats/package.json index 20f2a6231..3cf82b756 100644 --- a/packages/vx-stats/package.json +++ b/packages/vx-stats/package.json @@ -1,6 +1,6 @@ { "name": "@vx/stats", - "version": "0.0.140", + "version": "0.0.143", "description": "vx stats box violin", "main": "build/index.js", "scripts": { @@ -26,8 +26,8 @@ }, "homepage": "https://github.com/hshoff/vx#readme", "dependencies": { - "@vx/group": "0.0.140", - "@vx/scale": "0.0.140", + "@vx/group": "0.0.143", + "@vx/scale": "0.0.143", "classnames": "^2.2.5", "d3-shape": "^1.2.0" }, From 21ca91d2411b34c5e6fffe46f2350ea131330fbb Mon Sep 17 00:00:00 2001 From: Conglei Shi Date: Sun, 12 Nov 2017 23:37:45 -0800 Subject: [PATCH 4/4] added tests --- packages/vx-mock-data/test/genBoxPlot.test.js | 13 ---- packages/vx-mock-data/test/genStats.test.js | 15 +++++ packages/vx-stats/package.json | 21 ++++-- packages/vx-stats/src/index.js | 1 + packages/vx-stats/src/util/computeStats.js | 48 ++++++++++++++ packages/vx-stats/test/BoxPlot.test.js | 64 +++++++++++++++++++ packages/vx-stats/test/ViolinPlot.test.js | 43 +++++++++++++ packages/vx-stats/test/computeStats.test.js | 15 +++++ packages/vx-stats/test/enzyme-setup.js | 4 ++ 9 files changed, 205 insertions(+), 19 deletions(-) delete mode 100644 packages/vx-mock-data/test/genBoxPlot.test.js create mode 100644 packages/vx-mock-data/test/genStats.test.js create mode 100644 packages/vx-stats/src/util/computeStats.js create mode 100644 packages/vx-stats/test/BoxPlot.test.js create mode 100644 packages/vx-stats/test/ViolinPlot.test.js create mode 100644 packages/vx-stats/test/computeStats.test.js create mode 100644 packages/vx-stats/test/enzyme-setup.js diff --git a/packages/vx-mock-data/test/genBoxPlot.test.js b/packages/vx-mock-data/test/genBoxPlot.test.js deleted file mode 100644 index c6c8ba580..000000000 --- a/packages/vx-mock-data/test/genBoxPlot.test.js +++ /dev/null @@ -1,13 +0,0 @@ -import { genBoxPlot } from '../src'; - -describe('generators/genBoxPlot', () => { - test('it should be defined', () => { - expect(genBoxPlot).toBeDefined(); - }); - - test('it should be an array', () => { - const data = genBoxPlot(2); - expect(data.length).toBeDefined(); - expect(data.length).toEqual(2); - }); -}); diff --git a/packages/vx-mock-data/test/genStats.test.js b/packages/vx-mock-data/test/genStats.test.js new file mode 100644 index 000000000..69e193e33 --- /dev/null +++ b/packages/vx-mock-data/test/genStats.test.js @@ -0,0 +1,15 @@ +import { genStats } from '../src'; + +describe('generators/genStats', () => { + test('it should be defined', () => { + expect(genStats).toBeDefined(); + }); + + test('it should have boxPlot and binData', () => { + const data = genStats(2); + expect(data.length).toBeDefined(); + expect(data.length).toEqual(2); + expect(data[0].boxPlot).toBeDefined(); + expect(data[0].binData).toBeDefined(); + }); +}); diff --git a/packages/vx-stats/package.json b/packages/vx-stats/package.json index 3cf82b756..3c23f21fd 100644 --- a/packages/vx-stats/package.json +++ b/packages/vx-stats/package.json @@ -32,19 +32,28 @@ "d3-shape": "^1.2.0" }, "devDependencies": { - "babel-jest": "^20.0.3", - "enzyme": "^2.8.2", - "jest": "^20.0.3", - "react": "^15.0.0-0 || ^16.0.0-0", + "babel-jest": "^21.2.0", + "enzyme": "^3.1.0", + "enzyme-adapter-react-16": "^1.0.2", + "jest": "^21.2.1", + "react": "^16.0.0", + "react-dom": "^16.0.0", "react-fatigue-dev": "github:tj/react-fatigue-dev", - "react-test-renderer": "^15.6.1", + "react-test-renderer": "^16.0.0", "react-tools": "^0.10.0", - "regenerator-runtime": "^0.10.5" + "regenerator-runtime": "^0.10.5", + "raf": "^3.4.0" }, "peerDependencies": { "react": "^15.0.0-0 || ^16.0.0-0" }, "publishConfig": { "access": "public" + }, + "jest": { + "setupFiles": [ + "raf/polyfill", + "/test/enzyme-setup.js" + ] } } diff --git a/packages/vx-stats/src/index.js b/packages/vx-stats/src/index.js index e007b5703..95deb9b03 100644 --- a/packages/vx-stats/src/index.js +++ b/packages/vx-stats/src/index.js @@ -1,2 +1,3 @@ export { default as BoxPlot } from './boxplot/BoxPlot'; export { default as ViolinPlot } from './violinplot/ViolinPlot'; +export { default as computeStats } from './util/computeStats'; diff --git a/packages/vx-stats/src/util/computeStats.js b/packages/vx-stats/src/util/computeStats.js new file mode 100644 index 000000000..ebbbd96ae --- /dev/null +++ b/packages/vx-stats/src/util/computeStats.js @@ -0,0 +1,48 @@ + +export default function(numericalArray) { + const points = [...numericalArray].sort((a, b) => a-b); + const sampleSize = points.length; + const firstQuartile = points[Math.round(sampleSize/4)]; + const thirdQuartile = points[Math.round(3 * sampleSize/4)]; + const IQR = thirdQuartile - firstQuartile; + + const min = firstQuartile - 1.5 * IQR; + const max = thirdQuartile + 1.5 * IQR; + + const outliers = points.filter(p => p < min || p > max); + const binWidth = 2 * IQR * ((sampleSize - outliers.length) ** (-1/3)); + const binNum = Math.round((max - min) / binWidth); + const actualBinWidth = (max - min) / binNum; + + const bins = Array(binNum + 2).fill(0); + const values = Array(binNum + 2).fill(min); + + for (let i = 1; i <= binNum; i += 1){ + values[i] += actualBinWidth * (i - 0.5); + } + + values[values.length-1] = max; + + points.filter(p => p >= min && p <= max).forEach(p => { + bins[Math.floor((p-min)/actualBinWidth) + 1] += 1; + }); + + const binData = values.map((v, i) => ({ + value: v, + count: bins[i], + })); + + const boxPlot = { + min, + firstQuartile, + median: points[Math.round(sampleSize/2)], + thirdQuartile, + max, + outliers, + }; + + return { + boxPlot, + binData, + } +} diff --git a/packages/vx-stats/test/BoxPlot.test.js b/packages/vx-stats/test/BoxPlot.test.js new file mode 100644 index 000000000..832a1e636 --- /dev/null +++ b/packages/vx-stats/test/BoxPlot.test.js @@ -0,0 +1,64 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { scaleLinear } from '../../vx-scale'; +import { BoxPlot, computeStats } from '../src'; + +const data = [1, 2, 3, 4, 5, 6, 6, 7, 8, 9, 1]; +const { boxPlot: boxPlotData } = computeStats(data); +const { + min, + firstQuartile, + median, + thirdQuartile, + max, + outliers, +} = boxPlotData; + +const valueScale = scaleLinear({ + rangeRound: [10, 0], + domain: [0, 10] +}); + +describe('', () => { + test('it should be defined', () => { + expect(BoxPlot).toBeDefined(); + }); + + test('it should have className .vx-boxplot', () => { + const wrapper = shallow( + + ); + expect(wrapper.prop('className')).toEqual('vx-boxplot'); + }); + + test('it should render 5 lines and one rectangle', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('line').length).toEqual(5); + expect(wrapper.find('rect').length).toEqual(1); + }); + +}); diff --git a/packages/vx-stats/test/ViolinPlot.test.js b/packages/vx-stats/test/ViolinPlot.test.js new file mode 100644 index 000000000..6e74ba29b --- /dev/null +++ b/packages/vx-stats/test/ViolinPlot.test.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { scaleLinear } from '../../vx-scale'; +import { ViolinPlot, computeStats } from '../src'; + +const data = [1, 2, 3, 4, 5, 6, 6, 7, 8, 9, 1]; +const { binData } = computeStats(data); + +const valueScale = scaleLinear({ + rangeRound: [10, 0], + domain: [0, 10] +}); + +describe('', () => { + test('it should be defined', () => { + expect(ViolinPlot).toBeDefined(); + }); + + test('it should have className .vx-violin', () => { + const wrapper = shallow( + , + ); + expect(wrapper.prop('className')).toEqual('vx-violin'); + }); + + test('it should render one path element', () => { + const wrapper = shallow( + , + ); + expect(wrapper.find('path').length).toEqual(1); + }); + +}); diff --git a/packages/vx-stats/test/computeStats.test.js b/packages/vx-stats/test/computeStats.test.js new file mode 100644 index 000000000..d7c6606bc --- /dev/null +++ b/packages/vx-stats/test/computeStats.test.js @@ -0,0 +1,15 @@ +import { computeStats } from '../src'; + +const data = [1, 2, 3, 4, 5, 6, 6, 7, 8, 9, 1]; + +describe('computeStats', () => { + test('it should be defined', () => { + expect(computeStats).toBeDefined(); + }); + + test('it should have boxPlot and binData', () => { + const stats = computeStats(data); + expect(stats.boxPlot).toBeDefined(); + expect(stats.binData).toBeDefined(); + }); +}); diff --git a/packages/vx-stats/test/enzyme-setup.js b/packages/vx-stats/test/enzyme-setup.js new file mode 100644 index 000000000..acd71d494 --- /dev/null +++ b/packages/vx-stats/test/enzyme-setup.js @@ -0,0 +1,4 @@ +import Enzyme from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +Enzyme.configure({ adapter: new Adapter() }); \ No newline at end of file