From 9cd5c158e7a3d75222490d07728e7348f97032a4 Mon Sep 17 00:00:00 2001 From: Shahan Neda Date: Fri, 2 Sep 2022 17:39:46 -0400 Subject: [PATCH 1/4] Sample page and graph wrappers (#32) --- .vscode/settings.json | 3 +- components/ComponentWrapper.module.css | 59 ++++++++++++ components/ComponentWrapper.tsx | 42 ++++++++ components/WordCloud.tsx | 10 +- package.json | 2 +- pages/_app.css | 10 +- pages/_app.tsx | 13 ++- pages/index.tsx | 2 + pages/samplePage.module.css | 5 + pages/samplePage.tsx | 127 +++++++++++++++++++++++++ utils/getWindowDimensions.ts | 35 +++++++ utils/isMobile.ts | 3 + 12 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 components/ComponentWrapper.module.css create mode 100644 components/ComponentWrapper.tsx create mode 100644 pages/samplePage.module.css create mode 100644 pages/samplePage.tsx create mode 100644 utils/getWindowDimensions.ts create mode 100644 utils/isMobile.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 99e58e0..eb98407 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,12 +2,13 @@ "typescript.tsdk": "node_modules/typescript/lib", "eslint.format.enable": true, "eslint.codeActionsOnSave.mode": "all", + "css.lint.validProperties": ["composes"], "css.format.spaceAroundSelectorSeparator": true, "[css]": { "editor.suggest.insertMode": "replace", "gitlens.codeLens.scopes": ["document"], "editor.formatOnSave": true, - "editor.defaultFormatter": "vscode.css-language-features" + "editor.defaultFormatter": "vscode.css-language-features", }, "[javascript]": { "editor.formatOnSave": false, diff --git a/components/ComponentWrapper.module.css b/components/ComponentWrapper.module.css new file mode 100644 index 0000000..4fad333 --- /dev/null +++ b/components/ComponentWrapper.module.css @@ -0,0 +1,59 @@ +.sideWrapperCommon { + background-color: var(--secondary-background); + display: flex; + padding: calc(40rem / 16) calc(50rem / 16); + margin: calc(65rem / 16) 0; + width: 90%; +} + +.wrapperRight { + composes: sideWrapperCommon; + align-self: end; + margin-right: 0; + padding-right: 0; + border-radius: calc(200rem / 16) 0 0 calc(200rem / 16); + flex-direction: row-reverse; + padding-right: calc(50rem / 16); +} + +.wrapperLeft { + composes: sideWrapperCommon; + align-self: start; + margin-left: 0; + padding-left: 0; + border-radius: 0 calc(200rem / 16) calc(200rem / 16) 0; + flex-direction: row; + padding-left: calc(50rem / 16); +} + +.noBackground { + background: none; + align-self: center; +} + +.wrapperCenter { + flex-direction: column; + text-align: center; + gap: calc(25rem / 16); + /* to match the 65px margin with the left/right variant: + add 45px bottom margin, since internal wrapper contributes 20px for the center component + 0px top margin, since h3 contributes 45px and internal wrapper contributes 20px for the center component + */ + margin: 0 0 calc(45rem / 16) 0; + padding: 0 15%; +} + +@media screen and (max-width: 768px) { + .sideWrapperCommon { + margin: auto; + flex-direction: column; + text-align: center; + padding: 0; + border-radius: 0; + width: 100%; + } +} + +.internalWrapper { + padding: calc(20rem / 16); +} \ No newline at end of file diff --git a/components/ComponentWrapper.tsx b/components/ComponentWrapper.tsx new file mode 100644 index 0000000..8893905 --- /dev/null +++ b/components/ComponentWrapper.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +import styles from "./ComponentWrapper.module.css"; + +type AlignOption = "left" | "center" | "right"; + +type ComponentWrapperProps = { + children: React.ReactNode; + heading: string; + bodyText: string; + align?: AlignOption; + noBackground?: boolean; +}; + +export function ComponentWrapper({ + heading, + bodyText, + children, + align = "left", + noBackground = false, +}: ComponentWrapperProps) { + const alignClasses: { [key in AlignOption]: string } = { + left: styles.wrapperLeft, + center: styles.wrapperCenter, + right: styles.wrapperRight, + }; + + return ( +
+
+

{heading}

+

{bodyText}

+
+
{children}
+
+ ); +} diff --git a/components/WordCloud.tsx b/components/WordCloud.tsx index 9591dac..ca295e5 100644 --- a/components/WordCloud.tsx +++ b/components/WordCloud.tsx @@ -197,10 +197,12 @@ const shouldNotRerender = ( nextProps: WordCloudWordsProps ) => { if ( - prevProps.tooltipLeft !== nextProps.tooltipLeft || - prevProps.tooltipTop !== nextProps.tooltipTop || - nextProps.tooltipLeft === undefined || - nextProps.tooltipTop === undefined + // if width changes, rerender, else don't rerender for a tooltip change + prevProps.width === nextProps.width && + (prevProps.tooltipLeft !== nextProps.tooltipLeft || + prevProps.tooltipTop !== nextProps.tooltipTop || + nextProps.tooltipLeft === undefined || + nextProps.tooltipTop === undefined) ) { return true; // do not re-render } diff --git a/package.json b/package.json index 6358840..814c0d7 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,9 @@ "@visx/group": "^2.10.0", "@visx/scale": "^2.2.2", "@visx/shape": "^2.10.0", + "@visx/text": "^2.10.0", "@visx/tooltip": "^2.10.0", "@visx/wordcloud": "^2.10.0", - "@visx/text": "^2.10.0", "next": "12.1.6", "react": "18.1.0", "react-dom": "18.1.0" diff --git a/pages/_app.css b/pages/_app.css index 0c34108..30aea2c 100644 --- a/pages/_app.css +++ b/pages/_app.css @@ -73,7 +73,11 @@ h2 { color: var(--primary-heading); } -h3, +h3 { + color: var(--secondary-heading); + font-size: calc(45rem / 16); +} + h4, h5, h6 { @@ -89,6 +93,10 @@ a:hover { color: var(--link-hover); } +p { + font-size: calc(28rem / 16); +} + @media only screen and (prefers-color-scheme: dark) { body { --primary-background: var(--dark--primary-background); diff --git a/pages/_app.tsx b/pages/_app.tsx index 7803bd0..52d7142 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,9 +1,20 @@ import type { AppProps } from "next/app"; +import Head from "next/head"; import React from "react"; import "./_app.css"; import "./font.css"; export default function App({ Component, pageProps }: AppProps): JSX.Element { - return ; + return ( + <> + + + + + + ); } diff --git a/pages/index.tsx b/pages/index.tsx index 4ff1fb7..58dda94 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -5,6 +5,8 @@ export default function Home() { return (

Click here to visit the playground +
+ Click here to visit a sample page

); } diff --git a/pages/samplePage.module.css b/pages/samplePage.module.css new file mode 100644 index 0000000..b50d07b --- /dev/null +++ b/pages/samplePage.module.css @@ -0,0 +1,5 @@ +.page { + display: flex; + flex-direction: column; + justify-content: center; +} \ No newline at end of file diff --git a/pages/samplePage.tsx b/pages/samplePage.tsx new file mode 100644 index 0000000..f067888 --- /dev/null +++ b/pages/samplePage.tsx @@ -0,0 +1,127 @@ +import { BarGraphHorizontal, BarGraphVertical } from "components/BarGraph"; +import { mockCategoricalData, moreMockCategoricalData } from "data/mocks"; +import React from "react"; +import { useWindowDimensions } from "utils/getWindowDimensions"; +import { useIsMobile } from "utils/isMobile"; + +import { ComponentWrapper } from "@/components/ComponentWrapper"; + +import { WordCloud } from "../components/WordCloud"; + +import styles from "./samplePage.module.css"; + +export default function SamplePage() { + const { width } = useWindowDimensions(); + const isMobile = useIsMobile(); + + return ( +
+ + + + + + ({ + text: word.key, + value: word.value, + }))} + // For components that we don't want to match the width necessarily we can provide direct values + width={isMobile ? width / 1.5 : 800} + height={500} + /> + + + + + + + + + + + + + + + + ({ + text: word.key, + value: word.value, + }))} + width={isMobile ? width / 1.5 : width / 2} + height={500} + /> + +
+ ); +} diff --git a/utils/getWindowDimensions.ts b/utils/getWindowDimensions.ts new file mode 100644 index 0000000..427568b --- /dev/null +++ b/utils/getWindowDimensions.ts @@ -0,0 +1,35 @@ +// Attribution: https://stackoverflow.com/questions/36862334/get-viewport-window-height-in-reactjs +import { useState, useEffect } from "react"; + +type WindowDimensions = { + width: number; + height: number; +}; + +const getWindowDimensions = (): WindowDimensions => { + const { innerWidth, innerHeight } = window; + + return { + width: innerWidth, + height: innerHeight, + }; +}; + +export const useWindowDimensions = (): WindowDimensions => { + const [windowDimensions, setWindowDimensions] = useState({ + width: 0, + height: 0, + }); + + const handleResize = () => { + setWindowDimensions(getWindowDimensions()); + }; + + useEffect(() => { + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return windowDimensions; +}; diff --git a/utils/isMobile.ts b/utils/isMobile.ts new file mode 100644 index 0000000..4022df1 --- /dev/null +++ b/utils/isMobile.ts @@ -0,0 +1,3 @@ +import { useWindowDimensions } from "./getWindowDimensions"; + +export const useIsMobile = () => useWindowDimensions().width <= 768; From a2dbcb90c6b7400eff033cb0975e724ec6b353c6 Mon Sep 17 00:00:00 2001 From: Amy Date: Fri, 2 Sep 2022 21:53:05 -0400 Subject: [PATCH 2/4] Add Quotation Carousel (#36) Made without visx, because that was easier. Closes #10. Co-authored-by: Amy Wang Reviewed-on: https://git.csclub.uwaterloo.ca/www/cs-2022-class-profile/pulls/36 Reviewed-by: j285he --- components/QuotationCarousel.module.css | 124 ++++++++++++++++++++++ components/QuotationCarousel.tsx | 130 ++++++++++++++++++++++++ data/mocks.ts | 12 +++ pages/_app.css | 5 +- pages/playground.module.css | 8 ++ pages/playground.tsx | 22 +++- 6 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 components/QuotationCarousel.module.css create mode 100644 components/QuotationCarousel.tsx diff --git a/components/QuotationCarousel.module.css b/components/QuotationCarousel.module.css new file mode 100644 index 0000000..40241da --- /dev/null +++ b/components/QuotationCarousel.module.css @@ -0,0 +1,124 @@ +.carousel { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + gap: calc(8rem / 16); +} + +.circle { + position: absolute; + top: 30%; + right: 52%; + z-index: -1; + + background-color: var(--tertiary-background); + clip-path: circle(); +} + +.right.circle { + top: unset; + right: unset; + bottom: 30%; + left: 52%; +} + +.carouselButton { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + padding: calc(16rem / 16); + height: min-content; + + background: none; + border: none; + + cursor: pointer; +} + +.arrow { + position: relative; + width: calc(20rem / 16); + height: calc(40rem / 16); + + transition: 0.2s; +} + +.previous.arrow { + transform: rotate(180deg); +} + +.carouselButton:hover > .arrow { + translate: calc(4rem / 16); +} + +.carouselButton:hover > .previous.arrow { + translate: calc(-4rem / 16); +} + +.card { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + gap: calc(16rem / 16); + + min-height: inherit; + height: 100%; + width: 100%; + padding: calc(30rem / 16); + + background-color: var(--translucent-accent); + border: calc(2rem / 16) solid var(--primary-text); + border-radius: calc(12rem / 16); + box-shadow: 0 calc(1rem / 16) calc(10rem / 16) var(--primary-accent); +} + +.card ul { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + position: relative; + width: 100%; + margin: 0; + padding: 0; + flex-grow: 1; +} + +.card li { + position: absolute; + left: 0; + right: 0; + margin: 0; + padding: 0; + list-style: none; + + visibility: visible; + opacity: 1; + transition: 0.1s; +} + +.card li.hidden { + visibility: hidden; + opacity: 0; +} + +.card p { + margin: 0 calc(16rem / 16); + font-weight: bold; + text-align: center; +} + +.quotationMark { + width: calc(20rem / 16); + height: calc(20rem / 16); +} + +.right.quotationMark { + transform: rotate(180deg); + align-self: end; +} diff --git a/components/QuotationCarousel.tsx b/components/QuotationCarousel.tsx new file mode 100644 index 0000000..fed81a7 --- /dev/null +++ b/components/QuotationCarousel.tsx @@ -0,0 +1,130 @@ +import React, { useState } from "react"; +import { Color } from "utils/Color"; + +import styles from "./QuotationCarousel.module.css"; + +interface QuotationCarouselProps { + data: string[]; + /** Width of the entire carousel including the buttons, in px. */ + width?: number; + /** Minimum height of the carousel, in px. */ + height?: number; + /** Diameter of the background circles, in px. Set to 0 for no circles. */ + circleDiameter?: number; + className?: string; +} + +interface CarouselButtonProps { + onClick: () => void; + isPrevious?: boolean; +} + +export function QuotationCarousel(props: QuotationCarouselProps) { + const { + data, + width = 600, + height = 100, + circleDiameter = 120, + className, + } = props; + + const [activeIdx, setActiveIdx] = useState(0); + + function showNextCard() { + setActiveIdx((activeIdx + 1) % data.length); + } + + function showPreviousCard() { + setActiveIdx((activeIdx - 1 + data.length) % data.length); + } + + return ( +
+ + + +
+ +
    + {data.map((quote, idx) => ( +
  • +

    {quote}

    +
  • + ))} +
+ +
+ +
+ ); +} + +function Circle({ + className, + diameter, +}: { + className: string; + diameter: number; +}) { + return ( +
+ ); +} + +function CarouselButton({ isPrevious, onClick }: CarouselButtonProps) { + return ( + + ); +} + +function QuotationMark({ className }: { className: string }) { + return ( + + + + ); +} diff --git a/data/mocks.ts b/data/mocks.ts index 162f5aa..3631cd6 100644 --- a/data/mocks.ts +++ b/data/mocks.ts @@ -64,3 +64,15 @@ export const moreMockCategoricalData = [ { key: "Ada", value: 2.21 }, { key: "Dart", value: 2.21 }, ]; + +export const mockQuoteData = [ + "The quick brown fox jumps over the lazy dog.", + "Sphinx of black quartz, judge my vow!", + "Pack my box with five dozen liquor jugs.", +]; + +export const mockQuoteDataLong = [ + "Here, have some quotes of varying lengths, and see how they look.", + "Hello, world!", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla in enim neque. Sed sit amet convallis tellus. Integer condimentum a felis id gravida. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nullam metus libero, sagittis in consectetur in, scelerisque sed sapien. Nullam ut feugiat sapien. Praesent dictum ac ipsum ac lacinia.", +]; diff --git a/pages/_app.css b/pages/_app.css index 30aea2c..8c5d52f 100644 --- a/pages/_app.css +++ b/pages/_app.css @@ -20,6 +20,7 @@ body { --light--primary-accent-light: var(--orange); --light--primary-accent-lighter: #FFBC9F; --light--secondary-accent: #E55F98; + --light--translucent-accent: rgba(255, 231, 231, 0.75); --light--secondary-accent-light: #FEA0C8; --light--primary-heading: #D02B53; --light--primary-text: #483B35; @@ -37,6 +38,7 @@ body { --dark--primary-accent-lighter: var(--lighter-pink); --dark--secondary-accent: var(--orange); --dark--secondary-accent-light: var(--light-orange); + --dark--translucent-accent: rgba(239, 131, 157, 0.75); --dark--primary-heading: #FFC48D; --dark--secondary-heading: var(--pink); --dark--link: var(--pink); @@ -54,6 +56,7 @@ body { --primary-accent-lighter: var(--dark--primary-accent-lighter); --secondary-accent: var(--dark--secondary-accent); --secondary-accent-light: var(--dark--secondary-accent-light); + --translucent-accent: var(--dark--translucent-accent); --primary-heading: var(--dark--primary-heading); --secondary-heading: var(--dark--secondary-heading); --link: var(--dark--link); @@ -117,4 +120,4 @@ p { --card-background: var(--dark--card-background); --label: var(--dark--label); } -} \ No newline at end of file +} diff --git a/pages/playground.module.css b/pages/playground.module.css index a7aefd8..97f5eac 100644 --- a/pages/playground.module.css +++ b/pages/playground.module.css @@ -5,3 +5,11 @@ .barGraphDemo { border: calc(1rem / 16) solid black; } + +.quotationCarouselDemo { + display: flex; + flex-direction: column; + align-items: center; + gap: calc(48rem / 16); + margin: calc(32rem / 16); +} diff --git a/pages/playground.tsx b/pages/playground.tsx index 5707edb..1ff00bb 100644 --- a/pages/playground.tsx +++ b/pages/playground.tsx @@ -1,7 +1,14 @@ import { BarGraphHorizontal, BarGraphVertical } from "components/BarGraph"; -import { mockCategoricalData, moreMockCategoricalData } from "data/mocks"; +import { + mockCategoricalData, + moreMockCategoricalData, + mockQuoteData, + mockQuoteDataLong, +} from "data/mocks"; import React from "react"; +import { QuotationCarousel } from "@/components/QuotationCarousel"; + import { ColorPalette } from "../components/ColorPalette"; import { WordCloud } from "../components/WordCloud"; @@ -59,6 +66,19 @@ export default function Home() { value: word.value, }))} /> + +

+ {""} +

+
+ + +
); } From 9526f1b0f5bdf5646096de52183789a9c7ab3f7c Mon Sep 17 00:00:00 2001 From: Emily Chiu Date: Sat, 3 Sep 2022 11:13:58 -0400 Subject: [PATCH 3/4] Add BoxPlot component (Closes #6) (#34) Done: - [x] Display boxplot with left and bottom axis in `playground.tsx` - [x] Add mock data for boxplot - [x] Hovering over boxplot displays a Tool Tip box. My version: ![image](/attachments/6c8c4499-a1bd-4434-9230-7117266691ea) Note: - No percentage displayed next to value axis labels. - We can add outlier points although it could complicate the graph. - Still displays the following error: - Error: Hydration failed because the initial UI does not match what was rendered on the server. - Error: Text content does not match server-rendered HTML. - Error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering. Staging: https://boxplot-component-csc-class-profile-staging-snedadah.k8s.csclub.cloud/ Co-authored-by: Miniapple8888 Co-authored-by: Emily Chiu Co-authored-by: e26chiu Reviewed-on: https://git.csclub.uwaterloo.ca/www/cs-2022-class-profile/pulls/34 Reviewed-by: Amy --- components/Boxplot.module.css | 38 ++++ components/Boxplot.tsx | 361 ++++++++++++++++++++++++++++++++++ data/mocks.ts | 30 +++ package-lock.json | 72 +++++++ package.json | 2 + pages/playground.tsx | 17 +- 6 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 components/Boxplot.module.css create mode 100644 components/Boxplot.tsx 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() { }))} /> +

+ {""} +

+ +

{""}

From 9200e5f491da3cc6537ec09baaa8ccb83242d340 Mon Sep 17 00:00:00 2001 From: j285he Date: Wed, 7 Sep 2022 22:20:30 -0400 Subject: [PATCH 4/4] Pie chart component (#19) Closes #5. To fix: - [x] Inner pie slice text not perfectly centered - [x] Create optional props for `padRadius` and `innerRadius` - [x] Change colors to global colors and merge from main to get fonts Also, outer labels can get cut off if they are long enough, but the `labelWidth` is provided as a prop for the user to adjust. Staging url: https://j285he-pie-chart-csc-class-profile-staging-snedadah.k8s.csclub.cloud Co-authored-by: Jared He <66887902+jaredjhe@users.noreply.github.com> Reviewed-on: https://git.csclub.uwaterloo.ca/www/cs-2022-class-profile/pulls/19 Reviewed-by: Amy --- components/PieChart.module.css | 26 +++++ components/PieChart.tsx | 180 +++++++++++++++++++++++++++++++++ data/mocks.ts | 15 +++ package-lock.json | 1 - pages/playground.tsx | 11 +- 5 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 components/PieChart.module.css create mode 100644 components/PieChart.tsx diff --git a/components/PieChart.module.css b/components/PieChart.module.css new file mode 100644 index 0000000..29bb6dc --- /dev/null +++ b/components/PieChart.module.css @@ -0,0 +1,26 @@ +.piePath { + fill: var(--tertiary-background); +} + +.labelPath { + fill-opacity: 0; +} + +.pieText, +.labelText { + fill: var(--label); + font-weight: 800; +} + +.pieText { + display: none; +} + +.group:hover > .piePath { + fill: var(--primary-accent); + filter: drop-shadow(0px 0px calc(6rem / 16) var(--primary-accent)); +} + +.group:hover .pieText { + display: inline; +} \ No newline at end of file diff --git a/components/PieChart.tsx b/components/PieChart.tsx new file mode 100644 index 0000000..536ccfc --- /dev/null +++ b/components/PieChart.tsx @@ -0,0 +1,180 @@ +import { Group } from "@visx/group"; +import Pie, { ProvidedProps } from "@visx/shape/lib/shapes/Pie"; +import { Text } from "@visx/text"; +import React from "react"; + +import styles from "./PieChart.module.css"; + +interface PieChartProps { + data: PieChartData[]; + /** Width of the entire graph, including labels, in pixels. */ + width: number; + /** Width of the outer ring of labels, in pixels. Label text may be cut off if specified value is too small. */ + labelWidth: number; + /** Distance between pie slices, in pixels. */ + padRadius?: number; + /** Distance of gap in center of pie graph, in pixels. */ + innerRadius?: number; + /** Font size of text inside the pie, in pixels. */ + pieTextSize?: number; + /** X-axis offset of the pie text, in pixels. */ + pieTextXOffset?: number; + /** Y-axis offset of the pie text, in pixels. */ + pieTextYOffset?: number; + /** Accessor function to get value to display as pie text from datum. */ + getPieDisplayValueFromDatum?: (datum: PieChartData) => string; + /** Font size of labels outside the pie, in pixels. */ + labelTextSize?: number; + /** X-axis offset of the label text, in pixels. */ + labelTextXOffset?: number; + /** Y-axis offset of the label text, in pixels. */ + labelTextYOffset?: number; + /** Accessor function to get value to display as label text from datum. */ + getLabelDisplayValueFromDatum?: (datum: PieChartData) => string; + className?: string; +} + +interface PieChartData { + category: string; + value: number; +} + +export function PieChart({ + data, + width, + labelWidth, + padRadius = width * 0.35, + innerRadius = width * 0.015, + pieTextSize = 40, + pieTextXOffset = 0, + pieTextYOffset = 10, + getPieDisplayValueFromDatum = (datum: PieChartData) => `${datum.value}%`, + labelTextSize = 40, + labelTextXOffset = 0, + labelTextYOffset = 0, + getLabelDisplayValueFromDatum = (datum: PieChartData) => `${datum.category}`, + className, +}: PieChartProps) { + const pieWidth = width * 0.5 - labelWidth; + return ( + + + d.value} + cornerRadius={10} + padAngle={0.075} + padRadius={padRadius} + innerRadius={innerRadius} + outerRadius={pieWidth} + > + {(pie) => ( + + )} + + d.value} + innerRadius={pieWidth} + outerRadius={width * 0.5} + > + {(pie) => ( + + )} + + + + ); +} + +type PieSliceProps = ProvidedProps & { + pieTextSize: number; + pieTextXOffset: number; + pieTextYOffset: number; + getPieDisplayValueFromDatum: (datum: PieChartData) => string; +}; + +export function PieSlice({ + path, + arcs, + pieTextSize, + pieTextXOffset, + pieTextYOffset, + getPieDisplayValueFromDatum, +}: PieSliceProps) { + return ( + <> + {arcs.map((arc) => { + const [centroidX, centroidY] = path.centroid(arc); + const pathArc = path(arc) as string; + + return ( + + + + {`${getPieDisplayValueFromDatum(arc.data)}`} + + + ); + })} + + ); +} + +type PieSliceLabelProps = ProvidedProps & { + labelTextSize: number; + labelTextXOffset: number; + labelTextYOffset: number; + getLabelDisplayValueFromDatum: (datum: PieChartData) => string; +}; + +export function PieSliceLabel({ + path, + arcs, + labelTextSize, + labelTextXOffset, + labelTextYOffset, + getLabelDisplayValueFromDatum, +}: PieSliceLabelProps) { + return ( + <> + {arcs.map((arc) => { + const [centroidX, centroidY] = path.centroid(arc); + const pathArc = path(arc) as string; + + return ( + + + + {`${getLabelDisplayValueFromDatum(arc.data)}`} + + + ); + })} + + ); +} diff --git a/data/mocks.ts b/data/mocks.ts index 8a35df7..7d0252b 100644 --- a/data/mocks.ts +++ b/data/mocks.ts @@ -36,6 +36,21 @@ export const mockCategoricalData = [ }, ]; +export const mockPieData = [ + { + category: "Nightingale", + value: 42, + }, + { + category: "Quail", + value: 48, + }, + { + category: "Cuckoo", + value: 10, + }, +]; + export const moreMockCategoricalData = [ { key: "Python", value: 29.53 }, { key: "Java", value: 17.06 }, diff --git a/package-lock.json b/package-lock.json index f5a74dc..1e58472 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "requires": true, "packages": { "": { - "name": "cs-2022-class-profile", "version": "0.1.0", "dependencies": { "@visx/axis": "^2.10.0", diff --git a/pages/playground.tsx b/pages/playground.tsx index 5a9fd37..426da06 100644 --- a/pages/playground.tsx +++ b/pages/playground.tsx @@ -6,9 +6,11 @@ import { mockBoxPlotData, mockQuoteData, mockQuoteDataLong, + mockPieData, } from "data/mocks"; import React from "react"; +import { PieChart } from "@/components/PieChart"; import { QuotationCarousel } from "@/components/QuotationCarousel"; import { ColorPalette } from "../components/ColorPalette"; @@ -21,8 +23,13 @@ export default function Home() {

Playground

Show off your components here!

+

+ {""} +

+
+ +
-

{""}

@@ -38,7 +45,6 @@ export default function Home() { right: 20, }} /> -

{""}

@@ -58,7 +64,6 @@ export default function Home() { right: 20, }} /> -

{""}