diff --git a/.changeset/pink-flowers-clap.md b/.changeset/pink-flowers-clap.md new file mode 100644 index 00000000..5ef37ad1 --- /dev/null +++ b/.changeset/pink-flowers-clap.md @@ -0,0 +1,6 @@ +--- +"example": patch +"victory-native": patch +--- + +Fix stacked bar rounded corners for non-uniform datasets and add support for positive and negative values in the same column. diff --git a/example/app/consts/routes.ts b/example/app/consts/routes.ts index 4904f55b..d130a604 100644 --- a/example/app/consts/routes.ts +++ b/example/app/consts/routes.ts @@ -36,7 +36,7 @@ export const ChartRoutes: { { title: "Negative Bar Charts", description: - "These charts demonstrate how negative values look with Bar and Bar Group charts.", + "These charts demonstrate how negative values look with Bar, Bar Group and Stacked Bar charts.", path: "/negative-bar-charts", }, { diff --git a/example/app/negative-bar-charts.tsx b/example/app/negative-bar-charts.tsx index 566285aa..1f29579f 100644 --- a/example/app/negative-bar-charts.tsx +++ b/example/app/negative-bar-charts.tsx @@ -1,13 +1,26 @@ import { LinearGradient, useFont, vec } from "@shopify/react-native-skia"; import React, { useState } from "react"; import { SafeAreaView, ScrollView, StyleSheet, View } from "react-native"; -import { Bar, BarGroup, CartesianChart } from "victory-native"; +import { Bar, BarGroup, CartesianChart, StackedBar } from "victory-native"; import { useDarkMode } from "react-native-dark"; import inter from "../assets/inter-medium.ttf"; import { appColors } from "./consts/colors"; import { Button } from "../components/Button"; import { InfoCard } from "../components/InfoCard"; +import { Text } from "../components/Text"; import { descriptionForRoute } from "./consts/routes"; +import { Checkbox } from "../components/Checkbox"; + +const STACKED_DATA = (length: number = 5) => + Array.from({ length }, (_, index) => { + const y = Math.random() < 0.4 ? 0 : Math.floor(Math.random() * 9); + const z = Math.random() < 0.4 ? 0 : Math.floor(Math.random() * -9); + return { + x: index + 1, + y: y === 0 && z === 0 ? Math.floor(Math.random() * 9) : y, + z: z === 0 && y === 0 ? Math.floor(Math.random() * -9) : z, + }; + }); const GROUP_DATA = (length: number = 10) => Array.from({ length }, (_, index) => ({ @@ -29,12 +42,19 @@ export default function NegativeBarChartsPage(props: { segment: string }) { const isDark = useDarkMode(); const [data, setData] = useState(DATA(10)); const [groupData, setGroupData] = useState(GROUP_DATA(5)); + const [stackedData, setStackedData] = useState(STACKED_DATA(5)); const [innerPadding] = useState(0.33); + const [roundedCorner] = useState(5); + const [topLeft, setTopLeft] = useState(true); + const [topRight, setTopRight] = useState(true); + const [bottomRight, setBottomRight] = useState(false); + const [bottomLeft, setBottomLeft] = useState(false); function shuffleData() { setData(DATA(10)); setGroupData(GROUP_DATA(5)); + setStackedData(STACKED_DATA(5)); } return ( @@ -62,8 +82,10 @@ export default function NegativeBarChartsPage(props: { segment: string }) { animate={{ type: "spring" }} innerPadding={innerPadding} roundedCorners={{ - topLeft: roundedCorner, - topRight: roundedCorner, + topLeft: topLeft ? roundedCorner : 0, + topRight: topRight ? roundedCorner : 0, + bottomLeft: bottomLeft ? roundedCorner : 0, + bottomRight: bottomRight ? roundedCorner : 0, }} > @@ -125,6 +149,46 @@ export default function NegativeBarChartsPage(props: { segment: string }) { )} + + { + const date = new Date(2023, value - 1); + return date.toLocaleString("default", { month: "short" }); + }, + lineColor: isDark ? "#71717a" : "#d4d4d8", + labelColor: isDark ? appColors.text.dark : appColors.text.light, + }} + data={stackedData} + > + {({ points, chartBounds }) => { + return ( + ({ + roundedCorners: { + topLeft: topLeft && isTop ? roundedCorner : 0, + topRight: topRight && isTop ? roundedCorner : 0, + bottomLeft: bottomLeft && isBottom ? roundedCorner : 0, + bottomRight: bottomRight && isBottom ? roundedCorner : 0, + }, + })} + /> + ); + }} + + + Rounded corners + + + setTopLeft(!topLeft)} + /> + setBottomLeft(!bottomLeft)} + /> + + + setTopRight(!topRight)} + /> + setBottomRight(!bottomRight)} + /> + + @@ -160,7 +258,7 @@ const styles = StyleSheet.create({ }, }, chart: { - flex: 1.5, + flex: 1, }, optionsScrollView: { flex: 1, diff --git a/example/app/stacked-bar-charts-complex.tsx b/example/app/stacked-bar-charts-complex.tsx index e1901a04..1c465e0d 100644 --- a/example/app/stacked-bar-charts-complex.tsx +++ b/example/app/stacked-bar-charts-complex.tsx @@ -219,6 +219,61 @@ const CustomChildrenChart = () => { ); }; +const NonUniformDataSet = () => { + const font = useFont(inter, 12); + const isDark = useDarkMode(); + const data = [ + { month: 1, favouriteCount: 50, listenCount: 50, sales: 50 }, + { month: 2, listenCount: 50, sales: 50 }, + { month: 3, sales: 50 }, + { month: 4, listenCount: 50, sales: 0 }, + { month: 5, favouriteCount: 50, listenCount: 50, sales: 0 }, + ]; + const roundedCorner = 5; + + return ( + + { + const date = new Date(2023, value - 1); + return date.toLocaleString("default", { month: "short" }); + }, + lineColor: isDark ? "#71717a" : "#d4d4d8", + labelColor: isDark ? appColors.text.dark : appColors.text.light, + }} + data={data} + > + {({ points, chartBounds }) => { + return ( + ({ + roundedCorners: { + topLeft: isTop ? roundedCorner : 0, + topRight: isTop ? roundedCorner : 0, + bottomRight: isBottom ? roundedCorner : 0, + bottomLeft: isBottom ? roundedCorner : 0, + }, + })} + /> + ); + }} + + + ); +}; + export default function StackedBarChartsComplexPage() { return ( <> @@ -238,6 +293,13 @@ export default function StackedBarChartsComplexPage() { Stacked chart with custom children + + + Rounded corners where some data points are missing or values are + zero + + + diff --git a/example/components/Checkbox.tsx b/example/components/Checkbox.tsx new file mode 100644 index 00000000..1c641205 --- /dev/null +++ b/example/components/Checkbox.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; +import { Pressable, StyleSheet, View } from "react-native"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { Text } from "./Text"; + +type Props = { + label?: string; + checked: boolean; + onChange(): void; +}; + +export const Checkbox = ({ label, checked, onChange }: Props) => { + return ( + + + {checked && } + + {label ? {label} : null} + + ); +}; + +const styles = StyleSheet.create({ + checkboxBase: { + width: 24, + height: 24, + justifyContent: "center", + alignItems: "center", + borderRadius: 4, + borderWidth: 2, + borderColor: "coral", + backgroundColor: "transparent", + }, + checkboxChecked: { + backgroundColor: "coral", + }, +}); diff --git a/lib/src/cartesian/hooks/useStackedBarPaths.ts b/lib/src/cartesian/hooks/useStackedBarPaths.ts index d68a17bf..1e47bbff 100644 --- a/lib/src/cartesian/hooks/useStackedBarPaths.ts +++ b/lib/src/cartesian/hooks/useStackedBarPaths.ts @@ -46,6 +46,7 @@ type Props = { rowIndex: number; }) => CustomizablePathProps & { roundedCorners?: RoundedCorners }; }; + export const useStackedBarPaths = ({ points, chartBounds, @@ -68,49 +69,44 @@ export const useStackedBarPaths = ({ // so that we know where to start drawing the next bar for each x value const barYPositionOffsetTracker = points.reduce( (acc, points) => { - points.map((point) => { - const xValue = point.xValue; - if (acc[xValue]) { - acc[xValue] += 0 as number; - } else { - acc[xValue] = 0 as number; - } - }); - + points.map((point) => (acc[point.xValue] = [0, 0])); return acc; }, - {} as Record, + {} as Record, ); const paths = React.useMemo(() => { const bars: StackedBarPath[] = []; + const xToBottomTopIndexMap = getXToPointIndexMap(points); points.forEach((pointsArray, i) => { - const isTop = i === points.length - 1; // the top "row" of the stack of bars - const isBottom = i === 0; // the bottom "row" of the stack bars - pointsArray.forEach((point, j) => { + const isBottom = xToBottomTopIndexMap.get(point.x)?.[0] === i; + const isTop = xToBottomTopIndexMap.get(point.x)?.[1] === i; const { yValue, x, y } = point; if (typeof y !== "number") return; + const isPositive = (yValue ?? 0) > 0; // call for any additional bar options per bar const options = barOptions({ columnIndex: i, rowIndex: j, - isBottom, - isTop, + isBottom: isPositive ? isBottom : isTop, + isTop: isPositive ? isTop : isBottom, }); const { roundedCorners, color, ...ops } = options; const path = Skia.Path.Make(); - const barHeight = yScale(0) - y; - const offset = barYPositionOffsetTracker?.[point.xValue!] ?? 0; + + const offset = isPositive + ? barYPositionOffsetTracker?.[point.xValue!]?.[0] + : barYPositionOffsetTracker?.[point.xValue!]?.[1]; if (roundedCorners) { const nonUniformRoundedRect = createRoundedRectPath( x, - y - offset, + y - (offset ?? 0), barWidth, barHeight, roundedCorners, @@ -121,14 +117,20 @@ export const useStackedBarPaths = ({ path.addRect( Skia.XYWHRect( point.x - barWidth / 2, - y - offset, + y - (offset ?? 0), barWidth, barHeight, ), ); } - barYPositionOffsetTracker[point.xValue!] = barHeight + offset; // accumulate the heights as we loop + if (notNullAndUndefined(offset)) { + if (isPositive) { + barYPositionOffsetTracker[point.xValue!]![0] = barHeight + offset; // accumulate the positive heights as we loop + } else { + barYPositionOffsetTracker[point.xValue!]![1] = barHeight + offset; // accumulate the negative heights as we loop + } + } const bar = { path, @@ -145,3 +147,33 @@ export const useStackedBarPaths = ({ return paths; }; + +/** + * Returns a map of x values to a two value array where the first number is the index of + * the bottom bar and the second is the index of the top bar. + */ +const getXToPointIndexMap = ( + points: PointsArray[], +): Map => { + const xToIndexMap = new Map(); + points.forEach((pointsArray, i) => { + pointsArray.forEach(({ x, y, yValue }) => { + if ( + notNullAndUndefined(y) && + notNullAndUndefined(yValue) && + yValue !== 0 + ) { + const current = xToIndexMap.get(x); + if (!current) { + xToIndexMap.set(x, [i, i]); + } else { + yValue > 0 ? (current[1] = i) : (current[0] = i); + } + } + }); + }); + return xToIndexMap; +}; + +const notNullAndUndefined = (value: T | null | undefined): value is T => + value !== null && value !== undefined;