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 f068337..aaa5b3f 100644 --- a/data/mocks.ts +++ b/data/mocks.ts @@ -104,6 +104,36 @@ export const mockStackedBarKeys = [ "geese catchers", ]; +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/pages/playground.tsx b/pages/playground.tsx index 9ecc504..47b0a53 100644 --- a/pages/playground.tsx +++ b/pages/playground.tsx @@ -1,11 +1,13 @@ import { BarGraphHorizontal, BarGraphVertical } from "components/BarGraph"; +import { BoxPlot } from "components/Boxplot"; import { mockCategoricalData, moreMockCategoricalData, mockStackedBarKeys, mockStackedBarGraphData, - mockQuoteDataLong, + mockBoxPlotData, mockQuoteData, + mockQuoteDataLong, } from "data/mocks"; import React from "react"; import { Color } from "utils/Color"; @@ -116,6 +118,19 @@ export default function Home() { }} /> +

+ {""} +

+ +

{""}