diff --git a/components/Boxplot.module.css b/components/Boxplot.module.css new file mode 100644 index 0000000..f776cf6 --- /dev/null +++ b/components/Boxplot.module.css @@ -0,0 +1,38 @@ +.boxplot { + fill: var(--primary-accent-light); +} + +.boxplot:hover { + fill: var(--primary-accent); + filter: drop-shadow(0 0 calc(4rem / 16) var(--primary-accent)); +} + +.tooltip { + font-family: "Inconsolata", monospace; + top: 0; + left: 0; + position: absolute; + background-color: var(--label); + color: var(--primary-background); + pointer-events: none; + padding: calc(10rem / 16); + border-radius: calc(10rem / 16); +} + +.tooltip .category { + margin: calc(10rem / 16) 0 0 0; + font-size: calc(16rem / 16); + font-weight: 700; +} + +.tooltip .toolTipData { + margin-top: calc(5rem / 16); + margin-bottom: calc(10rem / 16); + font-size: calc(16rem / 16); +} + +.tooltip .toolTipData p { + margin: 0; + padding: 0; + font-size: calc(16rem / 16); +} diff --git a/components/Boxplot.tsx b/components/Boxplot.tsx new file mode 100644 index 0000000..924bc65 --- /dev/null +++ b/components/Boxplot.tsx @@ -0,0 +1,361 @@ +import { AxisLeft, AxisBottom } from "@visx/axis"; +import { GridRows, GridColumns } from "@visx/grid"; +import { Group } from "@visx/group"; +import { Stats } from "@visx/mock-data/lib/generators/genStats"; +import { Point } from "@visx/point"; +import { scaleBand, scaleLinear } from "@visx/scale"; +import { Line } from "@visx/shape"; +import { BoxPlot as VisxBoxPlot } from "@visx/stats"; +import { withTooltip, Tooltip } from "@visx/tooltip"; +import { WithTooltipProvidedProps } from "@visx/tooltip/lib/enhancers/withTooltip"; +import React from "react"; +import { Color } from "utils/Color"; + +import styles from "./Boxplot.module.css"; + +const DEFAULT_LABEL_SIZE = 16; +const TICK_LABEL_FONT_WEIGHT = 800; + +interface BoxPlotData { + category: string; + min: number; + median: number; + max: number; + firstQuartile: number; + thirdQuartile: number; + outliers?: number[]; +} + +type TooltipData = Omit; + +export type StatsPlotProps = { + data: BoxPlotData[]; + /** Width of the entire graph, in pixels, greater than 10. */ + width: number; + /** Height of the entire graph, in pixels. */ + height: number; + /** Distance between the edge of the graph and the area where the bars are drawn, in pixels. */ + margin: { + top: number; + left: number; + }; + /** Width of the lines in the graph, in px. */ + strokeWidth?: number; + /** Length of the dashes and the gaps in the graph, in px. */ + strokeDashArray?: string; + /** Number of ticks for the value (y-)axis */ + numTicksLeftAxis?: number; + /** Distance between the boxplot and the top of the grid, in px. */ + plotTopOffset?: number; + /** Distance between the left axis labels and the start of the lines of the graph, in px. */ + valueAxisLeftOffset?: number; + /** Distance between the top and the first label of the y axis, in px. */ + valueAxisLabelTopOffset?: number; + /** Distance between the left and the labels of the y axis, in px. */ + valueAxisLabelLeftOffset?: number; + /** Distance between the left and the start of the first label of the x axis, in px. */ + categoryAxisLabelLeftOffset?: number; + /** Distance between the top and the column lines of the grid of the graph, in px. */ + gridColumnTopOffset?: number; + /** Distance between the top of the point in the boxplot and the start of the tooltip box, in px. */ + toolTipTopOffset?: number; + /** Distance between the left of the point in the boxplot and the start of the tooltip box, in px. */ + toolTipLeftOffset?: number; + /** Font size of the category (x-)axis labels */ + categoryAxisLabelSize?: number; + /** Font size of the value (y-)axis labels */ + valueAxisLabelSize?: number; + /** Font size of the text in the tool tip box */ + toolTipFontSize?: number; + /** Factor multiplied with the compressed width to determine the box width, in px. */ + boxPlotWidthFactor?: number; + /** Factor multiplied with the compressed width to determine the distance between boxes, in px. */ + boxPlotLeftOffset?: number; +}; + +export const BoxPlot = withTooltip( + ({ + width, + height, + data, + margin, + tooltipOpen, + tooltipLeft, + tooltipTop, + tooltipData, + showTooltip, + hideTooltip, + strokeWidth = 2.5, + strokeDashArray = "10,4", + numTicksLeftAxis = 6, + plotTopOffset = 10, + valueAxisLeftOffset = 40, + gridColumnTopOffset = -20, + valueAxisLabelTopOffset = 5, + valueAxisLabelLeftOffset = 10, + categoryAxisLabelLeftOffset = 30, + toolTipTopOffset = 20, + toolTipLeftOffset = 5, + categoryAxisLabelSize = DEFAULT_LABEL_SIZE, + valueAxisLabelSize = DEFAULT_LABEL_SIZE, + boxPlotWidthFactor = 0.4, + boxPlotLeftOffset = 0.3, + }: StatsPlotProps & WithTooltipProvidedProps) => { + // bounds + const xMax = width; + const yMax = height - 120; + // formatting data + const plotData: Stats[] = data.map((d) => { + return { + boxPlot: { + ...d, + x: d.category, + outliers: [], + }, + binData: [], + }; + }); + + // accessors + const getX = (d: Stats) => d.boxPlot.x; + const getMin = (d: Stats) => d.boxPlot.min; + const getMax = (d: Stats) => d.boxPlot.max; + const getMedian = (d: Stats) => d.boxPlot.median; + const getFirstQuartile = (d: Stats) => d.boxPlot.firstQuartile; + const getThirdQuartile = (d: Stats) => d.boxPlot.thirdQuartile; + + // scales + const xScale = scaleBand({ + range: [18, xMax - 80], // scaling is needed due to the left offset + round: true, + domain: plotData.map(getX), + padding: 0.3, + }); + + const values = plotData.reduce((allValues, { boxPlot }) => { + allValues.push(boxPlot.min, boxPlot.max); + return allValues; + }, [] as number[]); + const minYValue = Math.min(...values); + const maxYValue = Math.max(...values); + + const yScale = scaleLinear({ + range: [yMax, 0], + round: true, + domain: [minYValue, maxYValue], + }); + + const constrainedWidth = Math.min(200, xScale.bandwidth()); + + return width < 10 ? null : ( +
+ + + + + + + { + return { + fill: Color.label, + fontWeight: TICK_LABEL_FONT_WEIGHT, + }; + }} + /> + { + return { + fill: Color.label, + fontWeight: TICK_LABEL_FONT_WEIGHT, + }; + }} + /> + + {plotData.map((d: Stats, i) => ( + + { + showTooltip({ + tooltipTop: + (yScale(getMin(d)) ?? 0) + toolTipTopOffset, + tooltipLeft: + xScale(getX(d))! + + constrainedWidth + + toolTipLeftOffset, + tooltipData: { + ...d.boxPlot, + category: getX(d), + }, + }); + }, + onMouseLeave: () => { + hideTooltip(); + }, + }} + maxProps={{ + onMouseOver: () => { + showTooltip({ + tooltipTop: + (yScale(getMax(d)) ?? 0) + toolTipTopOffset, + tooltipLeft: + xScale(getX(d))! + + constrainedWidth + + toolTipLeftOffset, + tooltipData: { + ...d.boxPlot, + category: getX(d), + }, + }); + }, + onMouseLeave: () => { + hideTooltip(); + }, + }} + boxProps={{ + onMouseOver: () => { + showTooltip({ + tooltipTop: + (yScale(getMedian(d)) ?? 0) + toolTipTopOffset, + tooltipLeft: + xScale(getX(d))! + + constrainedWidth + + toolTipLeftOffset, + tooltipData: { + ...d.boxPlot, + category: getX(d), + }, + }); + }, + strokeWidth: 0, + onMouseLeave: () => { + hideTooltip(); + }, + }} + medianProps={{ + style: { + stroke: Color.label, + }, + onMouseOver: () => { + showTooltip({ + tooltipTop: + (yScale(getMedian(d)) ?? 0) + toolTipTopOffset, + tooltipLeft: + xScale(getX(d))! + + constrainedWidth + + toolTipLeftOffset, + tooltipData: { + ...d.boxPlot, + category: getX(d), + }, + }); + }, + onMouseLeave: () => { + hideTooltip(); + }, + }} + /> + + ))} + + + + + {tooltipOpen && tooltipData && ( + +

{tooltipData.category}

+
+

max: {tooltipData.max}

+

third quartile: {tooltipData.thirdQuartile}

+

median: {tooltipData.median}

+

first quartile: {tooltipData.firstQuartile}

+

min: {tooltipData.min}

+
+
+ )} +
+ ); + } +); diff --git a/data/mocks.ts b/data/mocks.ts index 3631cd6..8a35df7 100644 --- a/data/mocks.ts +++ b/data/mocks.ts @@ -65,6 +65,36 @@ export const moreMockCategoricalData = [ { key: "Dart", value: 2.21 }, ]; +export const mockBoxPlotData = [ + { + category: "1A", + min: 20, + firstQuartile: 25, + median: 30, + thirdQuartile: 80, + max: 100, + outliers: [], + }, + { + category: "1B", + min: 0, + firstQuartile: 20, + median: 30, + thirdQuartile: 50, + max: 100, + outliers: [], + }, + { + category: "2A", + min: 25, + firstQuartile: 35, + median: 50, + thirdQuartile: 90, + max: 100, + outliers: [], + }, +]; + export const mockQuoteData = [ "The quick brown fox jumps over the lazy dog.", "Sphinx of black quartz, judge my vow!", diff --git a/package-lock.json b/package-lock.json index 7f2a1f1..f5a74dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "@visx/event": "^2.6.0", "@visx/grid": "^2.10.0", "@visx/group": "^2.10.0", + "@visx/mock-data": "^2.1.2", "@visx/scale": "^2.2.2", "@visx/shape": "^2.10.0", + "@visx/stats": "^2.10.0", "@visx/text": "^2.10.0", "@visx/tooltip": "^2.10.0", "@visx/wordcloud": "^2.10.0", @@ -608,6 +610,11 @@ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz", "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ==" }, + "node_modules/@types/d3-random": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.1.tgz", + "integrity": "sha512-5vvxn6//poNeOxt1ZwC7QU//dG9QqABjy1T7fP/xmFHY95GnaOw3yABf29hiu5SR1Oo34XcpyHFbzod+vemQjA==" + }, "node_modules/@types/d3-scale": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.2.tgz", @@ -967,6 +974,15 @@ "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" } }, + "node_modules/@visx/mock-data": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-2.1.2.tgz", + "integrity": "sha512-6xUVP56tiPwVi3BxvoXPQzDYWG6iX2nnOlsHEYsHgK8gHq1r7AhjQtdbQUX7QF0QkmkJM0cW8TBjZ2e+dItB8Q==", + "dependencies": { + "@types/d3-random": "^2.2.0", + "d3-random": "^2.2.2" + } + }, "node_modules/@visx/point": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@visx/point/-/point-2.6.0.tgz", @@ -1007,6 +1023,23 @@ "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0" } }, + "node_modules/@visx/stats": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@visx/stats/-/stats-2.10.0.tgz", + "integrity": "sha512-4p8rQamOc1IC3IkqTHgfMHbSXvRl9DMWFCglJy+DmbH6Wx1TaWt2nj/N0Ttp350UTRzBy4o5ou/D4Gts8LZHuA==", + "dependencies": { + "@types/d3-shape": "^1.3.2", + "@types/react": "*", + "@visx/group": "2.10.0", + "@visx/scale": "2.2.2", + "classnames": "^2.3.1", + "d3-shape": "^1.2.0", + "prop-types": "^15.5.10" + }, + "peerDependencies": { + "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, "node_modules/@visx/text": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/@visx/text/-/text-2.10.0.tgz", @@ -1392,6 +1425,7 @@ "version": "3.22.7", "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.7.tgz", "integrity": "sha512-wTriFxiZI+C8msGeh7fJcbC/a0V8fdInN1oS2eK79DMBGs8iIJiXhtFJCiT3rBa8w6zroHWW3p8ArlujZ/Mz+w==", + "deprecated": "core-js-pure@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js-pure.", "dev": true, "hasInstallScript": true, "funding": { @@ -1535,6 +1569,11 @@ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" }, + "node_modules/d3-random": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz", + "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw==" + }, "node_modules/d3-scale": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz", @@ -4790,6 +4829,11 @@ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz", "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ==" }, + "@types/d3-random": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-2.2.1.tgz", + "integrity": "sha512-5vvxn6//poNeOxt1ZwC7QU//dG9QqABjy1T7fP/xmFHY95GnaOw3yABf29hiu5SR1Oo34XcpyHFbzod+vemQjA==" + }, "@types/d3-scale": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.2.tgz", @@ -5043,6 +5087,15 @@ "prop-types": "^15.6.2" } }, + "@visx/mock-data": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-2.1.2.tgz", + "integrity": "sha512-6xUVP56tiPwVi3BxvoXPQzDYWG6iX2nnOlsHEYsHgK8gHq1r7AhjQtdbQUX7QF0QkmkJM0cW8TBjZ2e+dItB8Q==", + "requires": { + "@types/d3-random": "^2.2.0", + "d3-random": "^2.2.2" + } + }, "@visx/point": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@visx/point/-/point-2.6.0.tgz", @@ -5080,6 +5133,20 @@ "prop-types": "^15.5.10" } }, + "@visx/stats": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@visx/stats/-/stats-2.10.0.tgz", + "integrity": "sha512-4p8rQamOc1IC3IkqTHgfMHbSXvRl9DMWFCglJy+DmbH6Wx1TaWt2nj/N0Ttp350UTRzBy4o5ou/D4Gts8LZHuA==", + "requires": { + "@types/d3-shape": "^1.3.2", + "@types/react": "*", + "@visx/group": "2.10.0", + "@visx/scale": "2.2.2", + "classnames": "^2.3.1", + "d3-shape": "^1.2.0", + "prop-types": "^15.5.10" + } + }, "@visx/text": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/@visx/text/-/text-2.10.0.tgz", @@ -5443,6 +5510,11 @@ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" }, + "d3-random": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-2.2.2.tgz", + "integrity": "sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw==" + }, "d3-scale": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz", diff --git a/package.json b/package.json index 814c0d7..f59b11a 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,10 @@ "@visx/event": "^2.6.0", "@visx/grid": "^2.10.0", "@visx/group": "^2.10.0", + "@visx/mock-data": "^2.1.2", "@visx/scale": "^2.2.2", "@visx/shape": "^2.10.0", + "@visx/stats": "^2.10.0", "@visx/text": "^2.10.0", "@visx/tooltip": "^2.10.0", "@visx/wordcloud": "^2.10.0", diff --git a/pages/playground.tsx b/pages/playground.tsx index 1ff00bb..5a9fd37 100644 --- a/pages/playground.tsx +++ b/pages/playground.tsx @@ -1,7 +1,9 @@ import { BarGraphHorizontal, BarGraphVertical } from "components/BarGraph"; +import { BoxPlot } from "components/Boxplot"; import { mockCategoricalData, moreMockCategoricalData, + mockBoxPlotData, mockQuoteData, mockQuoteDataLong, } from "data/mocks"; @@ -30,7 +32,7 @@ export default function Home() { width={800} height={500} margin={{ - top: 20, + top: 25, bottom: 40, left: 150, right: 20, @@ -67,6 +69,19 @@ export default function Home() { }))} /> +

+ {""} +

+ +

{""}