diff --git a/components/StackedBarGraph.module.css b/components/StackedBarGraph.module.css new file mode 100644 index 0000000..347b174 --- /dev/null +++ b/components/StackedBarGraph.module.css @@ -0,0 +1,22 @@ +.barStack:hover { + filter: drop-shadow(0 0 calc(4rem / 16) var(--label)); +} + +.legend { + position: relative; + display: flex; + justify-content: center; + font-size: 16px; +} + +.toolTip { + padding: 10px 0; +} + +.toolTip p { + margin: 0 5px; +} + +.key { + font-weight: 700; +} diff --git a/components/StackedBarGraph.tsx b/components/StackedBarGraph.tsx new file mode 100644 index 0000000..e18c4c9 --- /dev/null +++ b/components/StackedBarGraph.tsx @@ -0,0 +1,253 @@ +import { AxisLeft, AxisBottom } from "@visx/axis"; +import { localPoint } from "@visx/event"; +import { GridRows, GridColumns } from "@visx/grid"; +import { Group } from "@visx/group"; +import { LegendOrdinal } from "@visx/legend"; +import { Point } from "@visx/point"; +import { scaleBand, scaleLinear, scaleOrdinal } from "@visx/scale"; +import { BarStack, Line } from "@visx/shape"; +import { SeriesPoint } from "@visx/shape/lib/types"; +import { useTooltip, useTooltipInPortal, defaultStyles } from "@visx/tooltip"; +import React from "react"; +import { Color } from "utils/Color"; + +import styles from "./StackedBarGraph.module.css"; + +interface StackedBarData { + category: string; + [key: string]: number | string; +} + +type TooltipData = { + bar: SeriesPoint; + key: string; + index: number; + height: number; + width: number; + x: number; + y: number; + color: string; +}; + +export type StackedBarProps = { + data: StackedBarData[]; + /** Width of the entire graph, in pixels, greater than 10. */ + width: number; + /** Height of the entire graph, in pixels. */ + height: number; + /** Names of the groups appearing in the legend */ + keys: string[]; + /** Colours for each key */ + colorRange: string[]; + /** Distance between the edge of the graph and the area where the bars are drawn, in pixels. */ + margin: { top: number; left: number }; + /** Number of ticks for the value (y-)axis */ + numTicksLeftAxis?: number; + /** Distance between the left axis labels and the start of the lines of the graph, in px. */ + valueAxisLeftOffset?: 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; + scalePadding?: number; +}; + +const tooltipStyles = { + ...defaultStyles, + minWidth: 60, + backgroundColor: Color.primaryAccentLighter, + color: Color.primaryBackground, +}; + +let tooltipTimeout: number; + +export function StackedBarGraph({ + data, + width, + height, + keys, + colorRange, + margin, + scalePadding = 0.3, + numTicksLeftAxis = 6, + valueAxisLeftOffset = 40, + strokeWidth = 2.5, + strokeDashArray = "10,4", +}: StackedBarProps) { + const yTotals = data.reduce((allTotals, currCategory) => { + const yTotal = keys.reduce((categoryTotal, k) => { + categoryTotal += currCategory[k] as number; + return categoryTotal; + }, 0); + allTotals.push(yTotal); + return allTotals; + }, [] as number[]); + + const TICK_LABEL_FONT_WEIGHT = 800; + + // accessors + const getCategory = (d: StackedBarData) => d.category; + + // scales + const xScale = scaleBand({ + domain: data.map(getCategory), + padding: scalePadding, + }); + const yScale = scaleLinear({ + domain: [0, Math.max(...yTotals)], + nice: true, + }); + const colorScale = scaleOrdinal({ + domain: keys, + range: colorRange, + }); + const { + tooltipOpen, + tooltipLeft, + tooltipTop, + tooltipData, + hideTooltip, + showTooltip, + } = useTooltip(); + + const { containerRef, TooltipInPortal } = useTooltipInPortal({ + // TooltipInPortal is rendered in a separate child of and positioned + // with page coordinates which should be updated on scroll. + scroll: true, + }); + + if (width < 10) return null; + // bounds + const xMax = width; + const yMax = height - margin.top - 50; + + xScale.rangeRound([0, xMax - valueAxisLeftOffset]); + yScale.range([yMax, 0]); + + return width < 10 ? null : ( +
+ + + + + + + data={data} + keys={keys} + x={getCategory} + xScale={xScale} + yScale={yScale} + color={colorScale} + > + {(barStacks) => + barStacks.map((barStack) => + barStack.bars.map((bar) => ( + { + tooltipTimeout = window.setTimeout(() => { + hideTooltip(); + }, 300); + }} + onMouseMove={(event) => { + if (tooltipTimeout) clearTimeout(tooltipTimeout); + // TooltipInPortal expects coordinates to be relative to containerRef + // localPoint returns coordinates relative to the nearest SVG + const eventSvgCoords = localPoint(event); + const left = bar.x + bar.width / 2; + showTooltip({ + tooltipData: bar, + tooltipTop: eventSvgCoords?.y, + tooltipLeft: left, + }); + }} + /> + )) + ) + } + + + + ({ + fill: Color.label, + fontWeight: TICK_LABEL_FONT_WEIGHT, + })} + /> + { + return { + fill: Color.label, + fontWeight: TICK_LABEL_FONT_WEIGHT, + }; + }} + /> + + +
+ +
+ + {tooltipOpen && tooltipData ? ( + +

{tooltipData.key}

+

{tooltipData.bar.data[tooltipData.key]}

+

{getCategory(tooltipData.bar.data)}

+
+ ) : null} +
+ ); +} diff --git a/data/mocks.ts b/data/mocks.ts index 29818c6..10f49f4 100644 --- a/data/mocks.ts +++ b/data/mocks.ts @@ -94,3 +94,42 @@ export const mockBoxPlotData = [ outliers: [], }, ]; + +export const mockStackedBarGraphData = [ + { + category: "1A", + "geese watchers": 60, + "geese breeders": 80, + "geese catchers": 90, + }, + { + category: "1B", + "geese watchers": 25, + "geese breeders": 37, + "geese catchers": 80, + }, + { + category: "2A", + "geese watchers": 40, + "geese breeders": 50, + "geese catchers": 70, + }, + { + category: "2B", + "geese watchers": 40, + "geese breeders": 80, + "geese catchers": 88, + }, + { + category: "3A", + "geese watchers": 15, + "geese breeders": 30, + "geese catchers": 45, + }, +]; + +export const mockStackedBarKeys = [ + "geese watchers", + "geese breeders", + "geese catchers", +]; diff --git a/package-lock.json b/package-lock.json index 4de1bbb..a8353b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -580,6 +580,18 @@ "prop-types": "^15.6.2" } }, + "@visx/legend": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@visx/legend/-/legend-2.10.0.tgz", + "integrity": "sha512-OI8BYE6QQI9eXAng/C7UzuVw7d0fwlzrth6RmrdhlyT1K+BA3WpExapV+pDfwxu/tkEik8Ps5cZRV6HjX1/Mww==", + "requires": { + "@types/react": "*", + "@visx/group": "2.10.0", + "@visx/scale": "2.2.2", + "classnames": "^2.3.1", + "prop-types": "^15.5.10" + } + }, "@visx/mock-data": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-2.1.2.tgz", diff --git a/package.json b/package.json index f59b11a..7a18472 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@visx/event": "^2.6.0", "@visx/grid": "^2.10.0", "@visx/group": "^2.10.0", + "@visx/legend": "^2.10.0", "@visx/mock-data": "^2.1.2", "@visx/scale": "^2.2.2", "@visx/shape": "^2.10.0", diff --git a/pages/playground.tsx b/pages/playground.tsx index b856cf9..e309aef 100644 --- a/pages/playground.tsx +++ b/pages/playground.tsx @@ -3,9 +3,14 @@ import Boxplot from "components/Boxplot"; import { mockCategoricalData, moreMockCategoricalData, - mockBoxPlotData + mockBoxPlotData, + mockStackedBarGraphData, + mockStackedBarKeys, } from "data/mocks"; import React from "react"; +import { Color } from "utils/Color"; + +import { StackedBarGraph } from "@/components/StackedBarGraph"; import { ColorPalette } from "../components/ColorPalette"; import { WordCloud } from "../components/WordCloud"; @@ -79,6 +84,25 @@ export default function Home() { strokeWidth={2.5} strokeDashArray="10,4" /> + +

+ {""} +

+ ); }