diff --git a/packages/gamut/src/BarChart/Bar/elements.tsx b/packages/gamut/src/BarChart/Bar/elements.tsx new file mode 100644 index 0000000000..0d6a408af3 --- /dev/null +++ b/packages/gamut/src/BarChart/Bar/elements.tsx @@ -0,0 +1,50 @@ +import { css } from '@codecademy/gamut-styles'; +import styled from '@emotion/styled'; +import { motion } from 'framer-motion'; + +import { Box } from '../../Box'; + +export const minBarWidth = 8; + +const baseStyles = { + alignItems: 'center', + height: '100%', + display: 'flex', + transitionDelay: '1.5s', + transition: 'width 0.5s', + position: 'absolute', + borderRadius: 'inherit', + borderColor: 'border-primary', +} as const; + +export const Bar = styled(motion.div)( + css({ + borderWidth: '1px', + borderStyle: 'solid', + ...baseStyles, + }) +); + +export const ForegroundBar = styled(Box)( + css({ + ...baseStyles, + bg: 'feedback-warning', + borderLeftColor: 'transparent', + borderLeftStyle: 'solid', + borderLeftWidth: '1px', + borderRightStyle: 'solid', + borderRightWidth: '1px', + height: 'calc(100% - 2px)', + }) +); + +export const BarWrapper = styled(Box)( + css({ + display: 'flex', + overflow: 'hidden', + position: 'relative', + alignItems: 'center', + height: { _: '8px', sm: '18px' }, + borderRadius: { _: 'md', sm: 'xl' }, + }) +); diff --git a/packages/gamut/src/BarChart/Bar/index.tsx b/packages/gamut/src/BarChart/Bar/index.tsx new file mode 100644 index 0000000000..1fb526c54e --- /dev/null +++ b/packages/gamut/src/BarChart/Bar/index.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; + +import { Box } from '../../Box'; +import { GridBoxProps } from '../../Box/props'; +import { calculateBarWidth } from '../utils'; +import { Bar, BarWrapper, ForegroundBar, minBarWidth } from './elements'; + +type BaseSkillsExperienceBarProps = { + startingValue?: number; + endingValue: number; + tickCount: number; +}; + +export const TotalBar: React.FC = ({ + startingValue, + endingValue, + tickCount, +}) => { + const animate = true; + const maxRange = 100; + const minRange = 0; + + const showForegroundBar = Boolean(startingValue); + + const barWidth = calculateBarWidth({ + value: endingValue, + maxRange, + }); + + const foregroundBarWidth = calculateBarWidth({ + value: startingValue ?? 0, + maxRange, + }); + + const initialBarWidth = `${Math.max(minBarWidth, foregroundBarWidth)}%`; + + const endBarWidth = `${Math.max(minBarWidth, barWidth)}%`; + + const animationProps = animate + ? { + initial: { width: initialBarWidth }, + animate: { + width: endBarWidth, + }, + transition: { duration: 0.25, delay: 0.75 * maxRange }, + } + : { width: endBarWidth }; + + return ( + + {/* */} + + {showForegroundBar && ( + + )} + + ); +}; diff --git a/packages/gamut/src/BarChart/ScaleChartHeader.tsx b/packages/gamut/src/BarChart/ScaleChartHeader.tsx new file mode 100644 index 0000000000..be5c4fd08f --- /dev/null +++ b/packages/gamut/src/BarChart/ScaleChartHeader.tsx @@ -0,0 +1,43 @@ +import { ReactElement } from 'react'; + +import { GridBox } from '../Box'; +import { GridBoxProps } from '../Box/props'; +import { Column } from '../Layout'; +import { Text } from '../Typography'; +import { formatNumberUSCompact } from './utils'; + +export const getLabel = ( + labelCount: number, + labelIndex: number, + max: number +) => { + const incrementalDecimal = 100 / (labelCount - 1) / 100; + return Math.floor(incrementalDecimal * labelIndex * max); +}; + +export const ScaleChartHeader: React.FC<{ + min: number; + max: number; + labelCount: number; +}> = ({ labelCount, min, max }) => { + const gridColumns = labelCount; + const scaleLabels: ReactElement[] = []; + console.log(labelCount, min, max); + for (let i = min; i < labelCount; i++) { + scaleLabels.push( + + + {formatNumberUSCompact(getLabel(labelCount, i, max))} + + + ); + } + return ( + + {scaleLabels} + + ); +}; diff --git a/packages/gamut/src/BarChart/index.tsx b/packages/gamut/src/BarChart/index.tsx new file mode 100644 index 0000000000..7ed11e29b8 --- /dev/null +++ b/packages/gamut/src/BarChart/index.tsx @@ -0,0 +1,15 @@ +import { Box, GridBox } from '../Box'; +import { TotalBar } from './Bar'; +import { ScaleChartHeader } from './ScaleChartHeader'; +import { BarChartProps } from './types'; + +export const BarChart: React.FC = () => { + return ( + + + + + + + ); +}; diff --git a/packages/gamut/src/BarChart/types.tsx b/packages/gamut/src/BarChart/types.tsx new file mode 100644 index 0000000000..a3441eafc3 --- /dev/null +++ b/packages/gamut/src/BarChart/types.tsx @@ -0,0 +1,51 @@ +import { GamutIconProps } from '@codecademy/gamut-icons'; +import { HTMLProps } from 'react'; + +import { BoxProps } from '../Box'; +import { ButtonProps } from '../Button'; + +type BarChartAriaLabel = { + 'aria-label': string; + 'aria-labelledby'?: never; +}; + +type BarChartAriaLabelledBy = { + 'aria-label': never; + 'aria-labelledby'?: string; +}; + +type BarChartLabel = BarChartAriaLabel | BarChartAriaLabelledBy; + +type BarChartStyles = { + textColor?: Pick; // text default + foregroundBarColor?: Pick; // text default + backgroundBarColors?: Pick; // primary default +}; + +type BarProps = { + yLabel: string; + // The foreground stacked bar + startingValue: number; + // The background bar + endingValue?: number; + // The actual type is in Gamut + icon?: React.ComponentType; + // onClick + onClick?: ButtonProps['onClick']; + // href + href?: HTMLProps['href']; +}; +export type BarChartProps = BarChartLabel & { + // goes in hook + animate?: boolean; + barValues: BarProps[]; + // goes in hook + maxRange: number; + // goes in hook + minRange: number; + order: 'ascending' | 'descending'; + sortBy: 'label' | 'value' | 'none'; + string: 'XP'; + styleConfig: BarChartStyles; + xScale: number; +}; diff --git a/packages/gamut/src/BarChart/utils/index.tsx b/packages/gamut/src/BarChart/utils/index.tsx new file mode 100644 index 0000000000..c5f1e871f9 --- /dev/null +++ b/packages/gamut/src/BarChart/utils/index.tsx @@ -0,0 +1,80 @@ +export const numDigits = (num: number) => { + return Math.max(Math.floor(Math.log10(Math.abs(num))), 0) + 1; +}; + +export const columnBaseSize = (experience = 3) => { + const digits = numDigits(experience); + return { + sm: digits > 4 ? 5 : 4, + md: digits > 4 ? 5 : 4, + lg: digits > 4 ? 4 : 5, + xl: digits > 4 ? 5 : 4, + }; +}; + +export const calculatePercent = (value: number, total: number) => { + return (value / total) * 100; +}; + +export const calculateBarWidth = ({ + value, + maxRange, +}: { + value: number; + maxRange: number; +}) => { + return Math.floor(calculatePercent(value, maxRange)); +}; + +// Calculate tick spacing and nice minimum and maximum data points on the axis. + +export const calculateTicksAndRange = ( + maxTicks: number, + minPoint: number, + maxPoint: number +): [number, number, number] => { + const range = niceNum(maxPoint - minPoint, false); + const tickSpacing = niceNum(range / (maxTicks - 1), true); + const niceMin = Math.floor(minPoint / tickSpacing) * tickSpacing; + const niceMax = Math.ceil(maxPoint / tickSpacing) * tickSpacing; + const tickCount = range / tickSpacing; + return [tickCount, niceMin, niceMax]; +}; + +/** + * Returns a "nice" number approximately equal to range + * Rounds the number if round = true + * Takes the ceiling if round = false. + * A nice number is a simple decimal number, for example if a number is 1234, a nice number would be 1000 or 2000. + */ +export const niceNum = (range: number, roundDown: boolean): number => { + const exponent = Math.floor(Math.log10(range)); + const fraction = range / 10 ** exponent; + + let niceFraction: number; + + if (roundDown) { + if (fraction < 1.5) niceFraction = 1; + else if (fraction < 3) niceFraction = 2; + else if (fraction < 7) niceFraction = 5; + else niceFraction = 10; + } else if (fraction <= 1) niceFraction = 1; + else if (fraction <= 2) niceFraction = 2; + else if (fraction <= 5) niceFraction = 5; + else niceFraction = 10; + + return niceFraction * 10 ** exponent; +}; + +export const getPercentDiff = (v1: number, v2: number) => { + return (Math.abs(v1 - v2) / ((v1 + v2) / 2)) * 100; +}; + +export const formatNumberUS = (num: number) => + Intl.NumberFormat('en').format(num); + +export const formatNumberUSCompact = (num: number) => + Intl.NumberFormat('en', { + notation: 'compact', + compactDisplay: 'short', + }).format(num); diff --git a/packages/gamut/src/index.tsx b/packages/gamut/src/index.tsx index dd3b53107f..ff0388c58d 100644 --- a/packages/gamut/src/index.tsx +++ b/packages/gamut/src/index.tsx @@ -6,6 +6,7 @@ export * from './Anchor'; export * from './Animation'; export * from './AppWrapper'; export * from './Badge'; +export * from './BarChart'; export * from './BodyPortal'; export * from './Box'; export * from './Breadcrumbs'; diff --git a/packages/styleguide/src/lib/Organisms/BarChart/BarChart.mdx b/packages/styleguide/src/lib/Organisms/BarChart/BarChart.mdx new file mode 100644 index 0000000000..1ee33b3912 --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/BarChart/BarChart.mdx @@ -0,0 +1,71 @@ +import { Canvas, Controls, Meta } from '@storybook/blocks'; + +import { ComponentHeader } from '~styleguide/blocks'; + +import * as BarChartStories from './BarChart.stories'; + +export const parameters = { + subtitle: `Template component`, + design: { + type: 'figma', + url: 'https: //www.figma.com/file/XXX', + }, + status: 'current', + source: { + repo: 'gamut', + githubLink: + 'https: //github.com/Codecademy/gamut/blob/main/packages/gamut/src/Logo', + }, +}; + +; + + + +## Usage + +Use BarChart to [what it should be used for] + +### Best practices: + +- [recommendation / best practice for implementation] +- [recommendation / best practice for implementation] + +When NOT to use + +- [use case]- for [describe the use case], use the [similar component] component. +- [use case]- for [describe the use case], use the [similar component] component + +## Anatomy + +[Insert image exported from Figma] + +1. [Element name] + +- [description including available options and ux writing if relevant] + +## Variants + +### [Variant 1 name] + +Use the [variant 1 name] to [what it should be used for] + + + +## Playground + +If you are using a story named 'Default', you can forgo the `of` prop. + + + + + +## Accessibility considerations + +- [Accessibility guidance] + +## UX writing + +- [content] +- [guidance] +- [guidance] diff --git a/packages/styleguide/src/lib/Organisms/BarChart/BarChart.stories.tsx b/packages/styleguide/src/lib/Organisms/BarChart/BarChart.stories.tsx new file mode 100644 index 0000000000..0ed1b9714d --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/BarChart/BarChart.stories.tsx @@ -0,0 +1,18 @@ +import { BarChart } from '@codecademy/gamut'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + component: BarChart, + args: {}, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Secondary: Story = { + args: {}, +};