commit
5bee5a98c4
@ -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); |
||||
} |
@ -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<BoxPlotData, "outliers">; |
||||
|
||||
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<StatsPlotProps, TooltipData>( |
||||
({ |
||||
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<TooltipData>) => { |
||||
// 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<string>({ |
||||
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<number>({ |
||||
range: [yMax, 0], |
||||
round: true, |
||||
domain: [minYValue, maxYValue], |
||||
}); |
||||
|
||||
const constrainedWidth = Math.min(200, xScale.bandwidth()); |
||||
|
||||
return width < 10 ? null : ( |
||||
<div> |
||||
<svg width={width} height={height}> |
||||
<Group top={margin.top} left={margin.left}> |
||||
<GridRows |
||||
top={plotTopOffset} |
||||
left={valueAxisLeftOffset} |
||||
scale={yScale} |
||||
width={xMax} |
||||
numTicks={numTicksLeftAxis} |
||||
stroke={Color.tertiaryBackground} |
||||
strokeWidth={strokeWidth} |
||||
strokeDasharray={strokeDashArray} |
||||
/> |
||||
<GridColumns |
||||
scale={xScale} |
||||
height={yMax + plotTopOffset - gridColumnTopOffset} |
||||
top={gridColumnTopOffset} |
||||
left={valueAxisLeftOffset} |
||||
stroke={Color.tertiaryBackground} |
||||
strokeWidth={strokeWidth} |
||||
strokeDasharray={strokeDashArray} |
||||
/> |
||||
<Line |
||||
fill={Color.tertiaryBackground} |
||||
to={new Point({ x: valueAxisLeftOffset, y: gridColumnTopOffset })} |
||||
from={ |
||||
new Point({ x: valueAxisLeftOffset, y: yMax + plotTopOffset }) |
||||
} |
||||
stroke={Color.tertiaryBackground} |
||||
strokeWidth={strokeWidth} |
||||
strokeDasharray={strokeDashArray} |
||||
/> |
||||
<Line |
||||
fill={Color.tertiaryBackground} |
||||
to={ |
||||
new Point({ |
||||
x: xMax - margin.left - strokeWidth, |
||||
y: gridColumnTopOffset, |
||||
}) |
||||
} |
||||
from={ |
||||
new Point({ |
||||
x: xMax - margin.left - strokeWidth, |
||||
y: yMax + plotTopOffset, |
||||
}) |
||||
} |
||||
stroke={Color.tertiaryBackground} |
||||
strokeWidth={strokeWidth} |
||||
strokeDasharray={strokeDashArray} |
||||
/> |
||||
<AxisBottom |
||||
top={yMax + plotTopOffset - gridColumnTopOffset} |
||||
left={categoryAxisLabelLeftOffset} |
||||
scale={xScale} |
||||
hideAxisLine |
||||
hideTicks |
||||
labelProps={{ |
||||
fontSize: `${categoryAxisLabelSize / 16}rem`, |
||||
}} |
||||
tickLabelProps={() => { |
||||
return { |
||||
fill: Color.label, |
||||
fontWeight: TICK_LABEL_FONT_WEIGHT, |
||||
}; |
||||
}} |
||||
/> |
||||
<AxisLeft |
||||
scale={yScale} |
||||
top={plotTopOffset + valueAxisLabelTopOffset} |
||||
left={valueAxisLabelLeftOffset} |
||||
numTicks={numTicksLeftAxis} |
||||
hideAxisLine |
||||
labelProps={{ |
||||
fontSize: `${valueAxisLabelSize / 16}rem`, |
||||
}} |
||||
tickLabelProps={() => { |
||||
return { |
||||
fill: Color.label, |
||||
fontWeight: TICK_LABEL_FONT_WEIGHT, |
||||
}; |
||||
}} |
||||
/> |
||||
<Group top={plotTopOffset}> |
||||
{plotData.map((d: Stats, i) => ( |
||||
<Group key={i}> |
||||
<VisxBoxPlot |
||||
className={styles.boxplot} |
||||
min={getMin(d)} |
||||
max={getMax(d)} |
||||
left={ |
||||
xScale(getX(d))! + |
||||
boxPlotLeftOffset * constrainedWidth + |
||||
valueAxisLeftOffset |
||||
} |
||||
firstQuartile={getFirstQuartile(d)} |
||||
thirdQuartile={getThirdQuartile(d)} |
||||
median={getMedian(d)} |
||||
boxWidth={constrainedWidth * boxPlotWidthFactor} |
||||
rx={0} |
||||
ry={0} |
||||
stroke={Color.label} |
||||
strokeWidth={strokeWidth} |
||||
valueScale={yScale} |
||||
minProps={{ |
||||
onMouseOver: () => { |
||||
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(); |
||||
}, |
||||
}} |
||||
/> |
||||
</Group> |
||||
))} |
||||
</Group> |
||||
</Group> |
||||
</svg> |
||||
|
||||
{tooltipOpen && tooltipData && ( |
||||
<Tooltip |
||||
top={tooltipTop} |
||||
left={tooltipLeft} |
||||
className={styles.tooltip} |
||||
unstyled |
||||
> |
||||
<p className={styles.category}>{tooltipData.category}</p> |
||||
<div className={styles.toolTipData}> |
||||
<p>max: {tooltipData.max}</p> |
||||
<p>third quartile: {tooltipData.thirdQuartile}</p> |
||||
<p>median: {tooltipData.median}</p> |
||||
<p>first quartile: {tooltipData.firstQuartile}</p> |
||||
<p>min: {tooltipData.min}</p> |
||||
</div> |
||||
</Tooltip> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
); |
Loading…
Reference in new issue