Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 26 additions & 15 deletions src/components/Charts/BarChart/VerticalBarChartContent.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ActivityIndicator from '@components/ActivityIndicator';
import {BAR_CORNER_RADIUS, BAR_HIT_GAP_RATIO, VERTICAL_BAR_DOMAIN_PADDING} from '@components/Charts/barChartConstants';
import {BAR_CORNER_RADIUS, BAR_GROUP_INNER_GAP, BAR_HIT_GAP_RATIO, VERTICAL_BAR_DOMAIN_PADDING} from '@components/Charts/barChartConstants';
import ChartGridLines from '@components/Charts/components/ChartGridLines';
import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer';
import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels';
Expand All @@ -15,7 +15,8 @@ import {
useSyncPointPositions,
useReadyCartesianCanvas,
} from '@components/Charts/hooks';
import {getBarLayout, getCartesianPlotBounds, getPrimaryValue, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils';
import type {ChartSeries} from '@components/Charts/types';
import {getBarLayout, getCartesianPlotBounds, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils';
import VictoryTheme, {CARTESIAN_Y_AXIS, CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme';

import useStyleUtils from '@hooks/useStyleUtils';
Expand All @@ -24,6 +25,7 @@ import useThemeStyles from '@hooks/useThemeStyles';

import variables from '@styles/variables';

import type {TupleToUnion} from 'type-fest';
import type {CartesianChartRenderArg, ChartBounds, PointsArray, Scale} from 'victory-native';

import {View} from 'react-native';
Expand All @@ -41,10 +43,10 @@ function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'l
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();

const [{key: primarySeriesKey}] = series;
const chartData = data.map((point, index) => ({
const seriesKeys = series.map((seriesItem) => seriesItem.key);
const chartData: Array<{x: number} & Record<string, number | undefined>> = data.map((point, index) => ({
x: index,
y: getPrimaryValue(point, primarySeriesKey),
...Object.fromEntries(seriesKeys.map((key) => [key, point.values[key]])),
}));

const yAxisDomain = useDynamicYDomain(data);
Expand Down Expand Up @@ -73,6 +75,7 @@ function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'l
const chartPaddingRight = yAxisLabelWidth + GLYPH_PADDING + VictoryTheme.axis.labelGap;
const plotBounds = getCartesianPlotBounds(chartWidth, chartPaddingRight);
const barLayout = getBarLayout(plotBounds.width, data.length);
const groupedBarWidth = (barLayout.barWidth - BAR_GROUP_INNER_GAP * (series.length - 1)) / series.length;

const measurements = useChartLabelMeasurements(labelData, fontManager, variables.iconSizeExtraSmall);

Expand Down Expand Up @@ -143,24 +146,31 @@ function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'l
cursor: isCursorOverClickable.get() ? 'pointer' : 'auto',
}));

const renderBar = (point: PointsArray[number], chartBounds: ChartBounds) => {
const dataIndex = Number(point.xValue);
const dataPoint = data.at(dataIndex);
const renderBar = (point: PointsArray[number], chartBounds: ChartBounds, seriesItem: ChartSeries, seriesIndex: number) => {
const dataPoint = data.at(Number(point.xValue));
if (!dataPoint || typeof point.y !== 'number') {
return null;
}
Comment thread
mhawryluk marked this conversation as resolved.
Resolved

const pointColor = seriesIndex === 0 ? dataPoint.color : undefined;
const color = dataPoint.seriesColors?.[seriesItem.key] ?? pointColor ?? seriesItem.color ?? VictoryTheme.colors.default;
// Moves the bar from the group's center to the middle of its series' slot, counted from the group's left edge, so the series sit side by side
const offset = -barLayout.barWidth / 2 + seriesIndex * (groupedBarWidth + BAR_GROUP_INNER_GAP) + groupedBarWidth / 2;

return (
<Bar
key={`bar-${dataPoint?.label}`}
points={[point]}
key={`bar-${seriesItem.key}-${dataPoint.label}`}
points={[{...point, x: point.x + offset}]}
chartBounds={chartBounds}
color={dataPoint?.color ?? VictoryTheme.colors.default}
barWidth={barLayout.barWidth}
color={color}
barWidth={groupedBarWidth}
barCount={data.length}
roundedCorners={{topLeft: BAR_CORNER_RADIUS, topRight: BAR_CORNER_RADIUS, bottomLeft: BAR_CORNER_RADIUS, bottomRight: BAR_CORNER_RADIUS}}
/>
);
};

const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => {
const renderOutside = (args: CartesianChartRenderArg<TupleToUnion<typeof chartData>, string>) => {
if (xAxisLabelHeight === undefined) {
return null;
}
Expand Down Expand Up @@ -217,7 +227,7 @@ function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'l
explicitSize={{width: chartWidth, height: chartHeight}}
xKey="x"
padding={chartPadding}
yKeys={['y']}
yKeys={seriesKeys}
domain={{x: barLayout.xDomain}}
domainPadding={VERTICAL_BAR_DOMAIN_PADDING}
onChartBoundsChange={handleChartBoundsChange}
Expand All @@ -242,7 +252,8 @@ function VerticalBarChartCanvas({data, series, yAxisUnit, yAxisUnitPosition = 'l
chartBounds={chartBounds}
color={theme.border}
/>
{points.y.map((point) => renderBar(point, chartBounds))}
{groupedBarWidth > 0 &&
series.flatMap((seriesItem, seriesIndex) => (points[seriesItem.key] ?? []).map((point) => renderBar(point, chartBounds, seriesItem, seriesIndex)))}
</>
)}
</CartesianChart>
Expand Down
5 changes: 4 additions & 1 deletion src/components/Charts/barChartConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ const BAR_GAP = 16;
/** Widest a bar gets (px), so one or two bars don't stretch across the whole plot. */
const BAR_MAX_WIDTH = 320;

/** Gap (px) between the bars of different series at the same point */
const BAR_GROUP_INNER_GAP = 4;

const BAR_CORNER_RADIUS = 4;

/** Share of the gap on each side of a bar that still counts as hovering it, so neighbors never overlap. */
Expand All @@ -16,4 +19,4 @@ const BAR_HIT_GAP_RATIO = 0.25;
const VERTICAL_BAR_DOMAIN_PADDING = {top: 32, bottom: 0, left: 0, right: 0};

export default BAR_INNER_PADDING;
export {BAR_GAP, BAR_MAX_WIDTH, BAR_CORNER_RADIUS, BAR_HIT_GAP_RATIO, VERTICAL_BAR_DOMAIN_PADDING};
export {BAR_GAP, BAR_GROUP_INNER_GAP, BAR_MAX_WIDTH, BAR_CORNER_RADIUS, BAR_HIT_GAP_RATIO, VERTICAL_BAR_DOMAIN_PADDING};
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import Text from '@components/Text';

import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import variables from '@styles/variables';
Expand All @@ -24,10 +23,9 @@ type ChartTooltipComparisonProps = {
/** Renders each compared series' amount in the chart tooltip, followed by the change between them */
function ChartTooltipComparison({rows, change}: ChartTooltipComparisonProps) {
const styles = useThemeStyles();
const theme = useTheme();
const StyleUtils = useStyleUtils();
const icons = useMemoizedLazyExpensifyIcons(['ArrowUpLong', 'ArrowDownLong']);
const changeColor = change?.isIncrease ? theme.danger : theme.successHover;
const changeColor = StyleUtils.getSpendChangeColor(!!change?.isIncrease);

return (
<>
Expand Down
3 changes: 3 additions & 0 deletions src/components/Charts/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ type ChartDataPoint = {
/** Color of the point's bar or slice. Left out, the chart's default color. */
color?: string;

/** Color of each series' bar at the point, keyed by the series' key. Left out, `color` for the first series and each other series' own color. */
seriesColors?: Partial<Record<string, string>>;
Comment thread
mhawryluk marked this conversation as resolved.
Resolved

/** Number of expenses in this point */
count?: number;

Expand Down
19 changes: 15 additions & 4 deletions src/components/Charts/utils/formatTooltipComparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ type TooltipChange = {
/** The difference, without its sign */
amount: string;

/** The difference relative to the compared amount, absent when that amount is zero */
percentage?: string;
/** The difference relative to the compared amount */
percentage: string;

isIncrease: boolean;
};
Expand All @@ -41,6 +41,16 @@ type TooltipComparison = {
change?: TooltipChange;
};

/** Formats how much the primary value rose or fell relative to the compared one, as a percentage */
function formatChangePercentage(primaryValue: number, comparedValue: number, locale: Locale | undefined): string {
if (comparedValue === 0) {
return '—';
}

const difference = primaryValue - comparedValue;
return formatPercentOfTotal((Math.abs(difference) / Math.abs(comparedValue)) * 100, difference, locale);
}

/** Formats how much the primary value rose or fell against the compared one. */
function formatChange(primaryValue: number, comparedValue: number, formatAmount: (value: number) => string, locale: Locale | undefined): TooltipChange | undefined {
if (primaryValue === comparedValue) {
Expand All @@ -50,7 +60,7 @@ function formatChange(primaryValue: number, comparedValue: number, formatAmount:
const difference = primaryValue - comparedValue;
return {
amount: formatAmount(Math.abs(difference)),
percentage: comparedValue === 0 ? undefined : formatPercentOfTotal((Math.abs(difference) / Math.abs(comparedValue)) * 100, difference, locale),
percentage: formatChangePercentage(primaryValue, comparedValue, locale),
isIncrease: difference > 0,
};
}
Expand All @@ -77,7 +87,7 @@ function formatTooltipComparison(
const isPrimary = key === primarySeriesKey;
return {
key,
color,
color: dataPoint.seriesColors?.[key] ?? color,
label,
amount: formatAmount(value),
percentage: isPrimary && dataPoint.percentOfTotal !== undefined ? formatPercentOfTotal(dataPoint.percentOfTotal, value, locale) : undefined,
Expand All @@ -88,4 +98,5 @@ function formatTooltipComparison(
}

export default formatTooltipComparison;
export {formatChangePercentage};
export type {TooltipChange, TooltipRow};
49 changes: 27 additions & 22 deletions src/components/Search/SearchChartView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {ChartDataPoint} from '@components/Charts';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';

import {sanitizeCurrencyCode} from '@libs/CurrencyUtils';
import Navigation from '@libs/Navigation/Navigation';
Expand Down Expand Up @@ -78,6 +79,7 @@ function SearchChartView({
renderDetails,
chartContainerStyle,
}: SearchChartViewProps) {
const theme = useTheme();
const {preferredLocale, translate, dateFnsLocale} = useLocalize();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions();
Expand All @@ -87,15 +89,17 @@ function SearchChartView({

const today = format(new Date(), CONST.DATE.FNS_FORMAT_STRING);
const dateFilterRange = queryJSON ? getDateFilterRange(queryJSON) : {};
const isSinglePointBar = shouldShowSinglePointAsBar && data.length === 1;
const {series, rows} = buildChartSeries({
data,
comparison: isSinglePointBar ? undefined : comparison,
comparison,
groupBy,
view,
getLabel,
getShortLabel: view === CONST.SEARCH.VIEW.LINE ? (getLineChartLabel ?? getShortLabel) : getShortLabel,
getCurrencyDecimals,
getInProgressLabel: (item) => getInProgressBucketLabel({groupBy, item, today, dateFnsLocale, dateFilterRange, translate}),
dateFnsLocale,
colorScheme: theme.colorScheme,
});
const points = rows.map((row) => row.point);

Expand Down Expand Up @@ -139,26 +143,27 @@ function SearchChartView({
shouldShowLabels={shouldShowGroupLabels}
/>
),
[CONST.SEARCH.VIEW.LINE]: isSinglePointBar ? (
<BarChart
data={points}
series={series}
isLoading={isLoading}
onBarPress={onItemPress}
yAxisUnit={unit}
yAxisUnitPosition={unitPosition}
/>
) : (
<LineChart
data={points}
series={series}
isLoading={isLoading}
onPointPress={onItemPress}
labelSteps={labelSteps}
yAxisUnit={unit}
yAxisUnitPosition={unitPosition}
/>
),
[CONST.SEARCH.VIEW.LINE]:
shouldShowSinglePointAsBar && points.length === 1 ? (
<BarChart
data={points}
series={series}
isLoading={isLoading}
onBarPress={onItemPress}
yAxisUnit={unit}
yAxisUnitPosition={unitPosition}
/>
) : (
<LineChart
data={points}
series={series}
isLoading={isLoading}
onPointPress={onItemPress}
labelSteps={labelSteps}
yAxisUnit={unit}
yAxisUnitPosition={unitPosition}
/>
),
[CONST.SEARCH.VIEW.PIE]: (
<PieChart
data={points}
Expand Down
Loading
Loading