diff --git a/components/StackedBarGraph.module.css b/components/StackedBarGraph.module.css new file mode 100644 index 0000000..ffe660f --- /dev/null +++ b/components/StackedBarGraph.module.css @@ -0,0 +1,35 @@ +.container { + position: relative; +} + +.barStack:hover { + filter: drop-shadow(0 0 calc(4rem / 16) var(--label)); +} + +.legend { + position: absolute; + display: flex; + font-size: calc(16rem / 16); + top: 0; +} + +.toolTip { + font-family: "Inconsolata", monospace; + top: 0; + left: 0; + position: absolute; + background-color: var(--label); + color: var(--primary-background); + pointer-events: none; + border-radius: calc(10rem / 16); + padding: calc(10rem / 16); +} + +.toolTip p { + margin: 0 calc(5rem / 16); + font-size: calc(16rem / 16); +} + +.key { + font-weight: bold; +} \ No newline at end of file diff --git a/components/StackedBarGraph.tsx b/components/StackedBarGraph.tsx new file mode 100644 index 0000000..7c195e0 --- /dev/null +++ b/components/StackedBarGraph.tsx @@ -0,0 +1,455 @@ +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, BarStackHorizontal, Line } from "@visx/shape"; +import { SeriesPoint } from "@visx/shape/lib/types"; +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 "./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 axis */ + numTicksValueAxis?: number; + /** Distance between the left axis labels and the start of the lines of the graph, in px. */ + axisLeftOffset?: number; + /** Distance between the bottom axis and the bottom of the container of the graph, in px. */ + axisBottomOffset?: number; + /** Distance between the right side of the graph and the legend, in px. */ + legendLeftOffset?: number; + /** Distance between the top of the graph and the legend, in px. */ + legendTopOffset?: 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; + /** Padding between each bar in the stacked bar graph, from 0 to 1 */ + scalePadding?: number; + /** Margin for each item in the legend */ + itemMargin?: string; + /** Factor multiplied with an offset to center the labels of the category-axis depending on the width/height of the graph. + * >1 for width/height <600 and <1 for width/height >600 (vertical=width/horizontal=height) */ + categoryAxisLeftFactor?: number; +}; + +let tooltipTimeout: number; + +export const StackedBarGraphVertical = withTooltip< + StackedBarProps, + TooltipData +>( + ({ + data, + width, + height, + keys, + colorRange, + margin, + scalePadding = 0.3, + numTicksValueAxis = 6, + axisLeftOffset = 40, + axisBottomOffset = 40, + strokeWidth = 2.5, + strokeDashArray = "10,4", + legendLeftOffset = 40, + legendTopOffset = 40, + itemMargin = "15px 0 0 0", + categoryAxisLeftFactor = 1, + tooltipOpen, + tooltipLeft, + tooltipTop, + tooltipData, + hideTooltip, + showTooltip, + }: StackedBarProps & WithTooltipProvidedProps) => { + 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 categoryScale = scaleBand({ + domain: data.map(getCategory), + padding: scalePadding, + }); + const valueScale = scaleLinear({ + domain: [0, Math.max(...yTotals)], + nice: true, + }); + const colorScale = scaleOrdinal({ + domain: keys, + range: colorRange, + }); + + // bounds + const xMax = width; + const yMax = height - margin.top - axisBottomOffset; + + categoryScale.rangeRound([0, xMax - axisLeftOffset]); + valueScale.range([yMax, 0]); + + return width < 10 ? null : ( +
+ + + + + + + data={data} + keys={keys} + x={getCategory} + xScale={categoryScale} + yScale={valueScale} + color={colorScale} + > + {(barStacks) => + barStacks.map((barStack) => + barStack.bars.map((bar) => ( + { + tooltipTimeout = window.setTimeout(() => { + hideTooltip(); + }, 300); + }} + onMouseMove={(event) => { + if (tooltipTimeout) clearTimeout(tooltipTimeout); + 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} +
+ ); + } +); + +export const StackedBarGraphHorizontal = withTooltip< + StackedBarProps, + TooltipData +>( + ({ + data, + width, + height, + keys, + colorRange, + margin, + scalePadding = 0.3, + numTicksValueAxis = 6, + axisLeftOffset = 40, + axisBottomOffset = 40, + strokeWidth = 2.5, + strokeDashArray = "10,4", + legendLeftOffset = 40, + legendTopOffset = 40, + itemMargin = "15px 0 0 0", + categoryAxisLeftFactor = 1, + tooltipOpen, + tooltipLeft, + tooltipTop, + tooltipData, + hideTooltip, + showTooltip, + }: StackedBarProps & WithTooltipProvidedProps) => { + 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 valueScale = scaleLinear({ + domain: [0, Math.max(...yTotals)], + nice: true, + }); + const categoryScale = scaleBand({ + domain: data.map(getCategory), + padding: scalePadding, + }); + const colorScale = scaleOrdinal({ + domain: keys, + range: colorRange, + }); + + // bounds + const xMax = width; + const yMax = height - margin.top - axisBottomOffset; + + categoryScale.rangeRound([yMax, 0]); + valueScale.range([0, xMax - axisLeftOffset]); + + return width < 10 ? null : ( +
+ + + + + + + data={data} + keys={keys} + y={getCategory} + xScale={valueScale} + yScale={categoryScale} + color={colorScale} + > + {(barStacks) => + barStacks.map((barStack) => + barStack.bars.map((bar) => ( + { + tooltipTimeout = window.setTimeout(() => { + hideTooltip(); + }, 300); + }} + onMouseMove={(event) => { + if (tooltipTimeout) clearTimeout(tooltipTimeout); + 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 95c9d40..f1eac77 100644 --- a/data/mocks.ts +++ b/data/mocks.ts @@ -80,6 +80,45 @@ export const moreMockCategoricalData = [ { key: "Dart", value: 2.21 }, ]; +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", +]; + export const mockTimelineData = [ { time: "Fall 2020", diff --git a/package-lock.json b/package-lock.json index 1e58472..9a82f92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,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", @@ -973,6 +974,21 @@ "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" } }, + "node_modules/@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==", + "dependencies": { + "@types/react": "*", + "@visx/group": "2.10.0", + "@visx/scale": "2.2.2", + "classnames": "^2.3.1", + "prop-types": "^15.5.10" + }, + "peerDependencies": { + "react": "^16.3.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", @@ -5086,6 +5102,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 18e20b2..65443c4 100644 --- a/pages/playground.tsx +++ b/pages/playground.tsx @@ -3,6 +3,8 @@ import { BoxPlot } from "components/Boxplot"; import { mockCategoricalData, moreMockCategoricalData, + mockStackedBarKeys, + mockStackedBarGraphData, mockBoxPlotData, mockQuoteData, mockQuoteDataLong, @@ -11,11 +13,16 @@ import { } from "data/mocks"; import { sectionsData } from "data/routes"; import React from "react"; +import { Color } from "utils/Color"; import { About } from "@/components/About"; import { PieChart } from "@/components/PieChart"; import { QuotationCarousel } from "@/components/QuotationCarousel"; import { Sections } from "@/components/Sections"; +import { + StackedBarGraphVertical, + StackedBarGraphHorizontal, +} from "@/components/StackedBarGraph"; import { Timeline } from "@/components/Timeline"; import { CenterWrapper } from "../components/CenterWrapper"; @@ -26,7 +33,7 @@ import styles from "./playground.module.css"; export default function Home() { return ( -
+

Playground

Show off your components here!

@@ -95,6 +102,48 @@ export default function Home() { }))} /> +

+ {""} +

+ + +

+ {""} +

+

+ {""} takes the same props as{" "} + {""}. +

+ +

{""}