Change bar chart tooltips interaction from bar to slice (#16938)
https://github.com/user-attachments/assets/90baca7e-2c32-448d-a458-e95ced00fdf4 https://github.com/user-attachments/assets/53dd5f8b-1c22-4600-b582-46ba594b966a https://github.com/user-attachments/assets/0b0b15c1-3ff8-4059-bc10-969d49852545
This commit is contained in:
+30
-23
@@ -96,6 +96,11 @@ const StyledTooltipLabel = styled.span<{ isHighlighted?: boolean }>`
|
||||
isHighlighted ? theme.font.weight.medium : theme.font.weight.regular};
|
||||
`;
|
||||
|
||||
const StyledNoDataMessage = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
`;
|
||||
|
||||
const StyledTooltipValue = styled.span<{ isHighlighted?: boolean }>`
|
||||
color: ${({ theme, isHighlighted }) =>
|
||||
isHighlighted ? theme.font.color.tertiary : theme.font.color.extraLight};
|
||||
@@ -142,41 +147,43 @@ export const GraphWidgetTooltip = ({
|
||||
(item) => item.value !== 0 && isNonEmptyString(item.formattedValue),
|
||||
);
|
||||
|
||||
const hasData = filteredItems.length > 0;
|
||||
const shouldHighlight = filteredItems.length > 1;
|
||||
const hasGraphWidgetTooltipClick = isDefined(onGraphWidgetTooltipClick);
|
||||
const shouldShowClickFooter = hasData && isDefined(onGraphWidgetTooltipClick);
|
||||
|
||||
return (
|
||||
<StyledTooltip>
|
||||
<StyledHorizontalSectionPadding
|
||||
addTop
|
||||
addBottom={!hasGraphWidgetTooltipClick}
|
||||
>
|
||||
<StyledHorizontalSectionPadding addTop addBottom={!shouldShowClickFooter}>
|
||||
<StyledTooltipContent>
|
||||
{indexLabel && (
|
||||
<StyledTooltipHeader>{indexLabel}</StyledTooltipHeader>
|
||||
)}
|
||||
<StyledTooltipRowContainer>
|
||||
{filteredItems.map((item) => {
|
||||
const isHighlighted =
|
||||
shouldHighlight && highlightedKey === item.key;
|
||||
return (
|
||||
<StyledTooltipRow key={item.key}>
|
||||
<GraphWidgetLegendDot color={item.dotColor} />
|
||||
<StyledTooltipRowRightContent>
|
||||
<StyledTooltipLabel isHighlighted={isHighlighted}>
|
||||
{item.label}
|
||||
</StyledTooltipLabel>
|
||||
<StyledTooltipValue isHighlighted={isHighlighted}>
|
||||
{item.formattedValue}
|
||||
</StyledTooltipValue>
|
||||
</StyledTooltipRowRightContent>
|
||||
</StyledTooltipRow>
|
||||
);
|
||||
})}
|
||||
{filteredItems.length === 0 ? (
|
||||
<StyledNoDataMessage>{t`No data`}</StyledNoDataMessage>
|
||||
) : (
|
||||
filteredItems.map((item) => {
|
||||
const isHighlighted =
|
||||
shouldHighlight && highlightedKey === item.key;
|
||||
return (
|
||||
<StyledTooltipRow key={item.key}>
|
||||
<GraphWidgetLegendDot color={item.dotColor} />
|
||||
<StyledTooltipRowRightContent>
|
||||
<StyledTooltipLabel isHighlighted={isHighlighted}>
|
||||
{item.label}
|
||||
</StyledTooltipLabel>
|
||||
<StyledTooltipValue isHighlighted={isHighlighted}>
|
||||
{item.formattedValue}
|
||||
</StyledTooltipValue>
|
||||
</StyledTooltipRowRightContent>
|
||||
</StyledTooltipRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</StyledTooltipRowContainer>
|
||||
</StyledTooltipContent>
|
||||
</StyledHorizontalSectionPadding>
|
||||
{hasGraphWidgetTooltipClick && (
|
||||
{shouldShowClickFooter && (
|
||||
<>
|
||||
<StyledTooltipSeparator />
|
||||
<StyledHorizontalSectionPadding addBottom>
|
||||
|
||||
+19
-37
@@ -1,11 +1,12 @@
|
||||
import { LEGEND_HIGHLIGHT_DIMMED_OPACITY } from '@/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant';
|
||||
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
|
||||
import { graphWidgetHoveredSliceIndexComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetHoveredSliceIndexComponentState';
|
||||
import { graphWidgetHighlightedLegendIdComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { type BarDatum, type BarItemProps } from '@nivo/bar';
|
||||
import { animated, to } from '@react-spring/web';
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
import { useCallback, useMemo, type MouseEvent } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { BarChartLayout } from '~/generated/graphql';
|
||||
@@ -22,32 +23,28 @@ type CustomBarItemProps<D extends BarDatum> = BarItemProps<D> & {
|
||||
const StyledBarRect = styled(animated.rect)<{
|
||||
$isInteractive?: boolean;
|
||||
$isDimmed?: boolean;
|
||||
$isSliceHovered?: boolean;
|
||||
}>`
|
||||
cursor: ${({ $isInteractive }) => ($isInteractive ? 'pointer' : 'default')};
|
||||
filter: ${({ $isSliceHovered, $isInteractive }) =>
|
||||
$isSliceHovered && $isInteractive
|
||||
? `brightness(${BAR_CHART_CONSTANTS.HOVER_BRIGHTNESS})`
|
||||
: 'none'};
|
||||
opacity: ${({ $isDimmed }) =>
|
||||
$isDimmed ? LEGEND_HIGHLIGHT_DIMMED_OPACITY : 1};
|
||||
pointer-events: none;
|
||||
transition:
|
||||
filter 0.15s ease-in-out,
|
||||
opacity 0.15s ease-in-out;
|
||||
opacity: ${({ $isDimmed }) =>
|
||||
$isDimmed ? LEGEND_HIGHLIGHT_DIMMED_OPACITY : 1};
|
||||
|
||||
&:hover {
|
||||
filter: ${({ $isInteractive }) =>
|
||||
$isInteractive
|
||||
? `brightness(${BAR_CHART_CONSTANTS.HOVER_BRIGHTNESS})`
|
||||
: 'none'};
|
||||
}
|
||||
`;
|
||||
|
||||
// This is a copy of the BarItem component from @nivo/bar with some design modifications
|
||||
export const CustomBarItem = <D extends BarDatum>({
|
||||
bar: { data: barData, ...bar },
|
||||
bar: { data: barData },
|
||||
style: { borderColor, color, height, transform, width },
|
||||
borderRadius,
|
||||
borderWidth,
|
||||
isInteractive,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
isFocusable,
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
@@ -65,30 +62,17 @@ export const CustomBarItem = <D extends BarDatum>({
|
||||
graphWidgetHighlightedLegendIdComponentState,
|
||||
);
|
||||
|
||||
const hoveredSliceIndex = useRecoilComponentValue(
|
||||
graphWidgetHoveredSliceIndexComponentState,
|
||||
);
|
||||
|
||||
const isDimmed =
|
||||
isDefined(highlightedLegendId) &&
|
||||
String(highlightedLegendId) !== String(barData.id);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<SVGRectElement>) => {
|
||||
onClick?.({ color: bar.color, ...barData }, event);
|
||||
},
|
||||
[bar, barData, onClick],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(event: MouseEvent<SVGRectElement>) => {
|
||||
onMouseEnter?.(barData, event);
|
||||
},
|
||||
[barData, onMouseEnter],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(event: MouseEvent<SVGRectElement>) => {
|
||||
onMouseLeave?.(barData, event);
|
||||
},
|
||||
[barData, onMouseLeave],
|
||||
);
|
||||
const isSliceHovered =
|
||||
isDefined(hoveredSliceIndex) &&
|
||||
String(barData.indexValue) === hoveredSliceIndex;
|
||||
|
||||
const isNegativeValue = useMemo(
|
||||
() => isNumber(barData.value) && barData.value < 0,
|
||||
@@ -217,6 +201,7 @@ export const CustomBarItem = <D extends BarDatum>({
|
||||
<StyledBarRect
|
||||
$isInteractive={isInteractive}
|
||||
$isDimmed={isDimmed}
|
||||
$isSliceHovered={isSliceHovered}
|
||||
clipPath={shouldRoundFreeEnd ? `url(#${clipPathId})` : undefined}
|
||||
width={to(finalBarWidthDimension, (value) => Math.max(value, 0))}
|
||||
height={to(finalBarHeightDimension, (value) => Math.max(value, 0))}
|
||||
@@ -232,9 +217,6 @@ export const CustomBarItem = <D extends BarDatum>({
|
||||
}
|
||||
aria-disabled={ariaDisabled ? ariaDisabled(barData) : undefined}
|
||||
aria-hidden={ariaHidden ? ariaHidden(barData) : undefined}
|
||||
onMouseEnter={isInteractive ? handleMouseEnter : undefined}
|
||||
onMouseLeave={isInteractive ? handleMouseLeave : undefined}
|
||||
onClick={isInteractive ? handleClick : undefined}
|
||||
data-testid={`bar.item.${barData.id}.${barData.index}`}
|
||||
/>
|
||||
</animated.g>
|
||||
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import { graphWidgetHoveredSliceIndexComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetHoveredSliceIndexComponentState';
|
||||
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
|
||||
import { computeSliceHighlightPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSliceHighlightPosition';
|
||||
import { computeSlicesFromBars } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSlicesFromBars';
|
||||
import { findSliceAtPosition } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/findSliceAtPosition';
|
||||
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { type BarDatum, type ComputedBarDatum } from '@nivo/bar';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import { useMemo, type MouseEvent } from 'react';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { BarChartLayout } from '~/generated/graphql';
|
||||
|
||||
type SliceHoverCallbackData = {
|
||||
slice: BarChartSlice;
|
||||
offsetLeft: number;
|
||||
offsetTop: number;
|
||||
};
|
||||
|
||||
type CustomSliceHoverLayerProps = {
|
||||
bars: readonly ComputedBarDatum<BarDatum>[];
|
||||
innerWidth: number;
|
||||
innerHeight: number;
|
||||
marginLeft: number;
|
||||
marginTop: number;
|
||||
layout: BarChartLayout;
|
||||
onSliceHover: (data: SliceHoverCallbackData | null) => void;
|
||||
onSliceClick?: (slice: BarChartSlice) => void;
|
||||
onSliceLeave: () => void;
|
||||
};
|
||||
|
||||
export const CustomSliceHoverLayer = ({
|
||||
bars,
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
marginLeft,
|
||||
marginTop,
|
||||
layout,
|
||||
onSliceHover,
|
||||
onSliceClick,
|
||||
onSliceLeave,
|
||||
}: CustomSliceHoverLayerProps) => {
|
||||
const theme = useTheme();
|
||||
const hoveredSliceIndex = useRecoilComponentValue(
|
||||
graphWidgetHoveredSliceIndexComponentState,
|
||||
);
|
||||
const hoveredSliceIndexState = useRecoilComponentCallbackState(
|
||||
graphWidgetHoveredSliceIndexComponentState,
|
||||
);
|
||||
const setHoveredSliceIndex = useSetRecoilComponentState(
|
||||
graphWidgetHoveredSliceIndexComponentState,
|
||||
);
|
||||
|
||||
const isVerticalLayout = layout === BarChartLayout.VERTICAL;
|
||||
|
||||
const slices = useMemo(
|
||||
() => computeSlicesFromBars({ bars, isVerticalLayout }),
|
||||
[bars, isVerticalLayout],
|
||||
);
|
||||
|
||||
const handleMouseMove = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
(event: MouseEvent<SVGRectElement>) => {
|
||||
const sliceData = findSliceAtPosition({
|
||||
event,
|
||||
slices,
|
||||
marginLeft,
|
||||
marginTop,
|
||||
isVerticalLayout,
|
||||
});
|
||||
const currentHoveredSliceIndex = snapshot
|
||||
.getLoadable(hoveredSliceIndexState)
|
||||
.getValue();
|
||||
|
||||
if (!isDefined(sliceData)) {
|
||||
if (isDefined(currentHoveredSliceIndex)) {
|
||||
setHoveredSliceIndex(null);
|
||||
onSliceHover(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sliceData.slice.indexValue === currentHoveredSliceIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHoveredSliceIndex(sliceData.slice.indexValue);
|
||||
onSliceHover(sliceData);
|
||||
},
|
||||
[
|
||||
hoveredSliceIndexState,
|
||||
setHoveredSliceIndex,
|
||||
onSliceHover,
|
||||
slices,
|
||||
marginLeft,
|
||||
marginTop,
|
||||
isVerticalLayout,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
onSliceLeave();
|
||||
};
|
||||
|
||||
const handleClick = (event: MouseEvent<SVGRectElement>) => {
|
||||
if (!isDefined(onSliceClick)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sliceData = findSliceAtPosition({
|
||||
event,
|
||||
slices,
|
||||
marginLeft,
|
||||
marginTop,
|
||||
isVerticalLayout,
|
||||
});
|
||||
|
||||
if (!isDefined(sliceData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSliceClick(sliceData.slice);
|
||||
};
|
||||
|
||||
const hoveredSlice = useMemo(() => {
|
||||
if (!isDefined(hoveredSliceIndex)) {
|
||||
return null;
|
||||
}
|
||||
return slices.find((slice) => slice.indexValue === hoveredSliceIndex);
|
||||
}, [slices, hoveredSliceIndex]);
|
||||
|
||||
const highlightPosition = computeSliceHighlightPosition({
|
||||
sliceCenter: hoveredSlice?.sliceCenter ?? null,
|
||||
isVerticalLayout,
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
});
|
||||
|
||||
const { opacity } = useSpring({
|
||||
opacity: isDefined(hoveredSlice) ? 1 : 0,
|
||||
config: {
|
||||
tension: 300,
|
||||
friction: 30,
|
||||
},
|
||||
});
|
||||
|
||||
if (bars.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<g>
|
||||
<animated.g
|
||||
transform={`translate(${highlightPosition.x}, ${highlightPosition.y})`}
|
||||
opacity={opacity}
|
||||
>
|
||||
<rect
|
||||
width={highlightPosition.width}
|
||||
height={highlightPosition.height}
|
||||
fill={theme.background.transparent.medium}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
</animated.g>
|
||||
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={innerWidth}
|
||||
height={innerHeight}
|
||||
fill="transparent"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={handleMouseMove}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
+14
-11
@@ -2,10 +2,11 @@ import { GraphWidgetFloatingTooltip } from '@/page-layout/widgets/graph/componen
|
||||
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
|
||||
import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState';
|
||||
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
|
||||
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
|
||||
import { getBarChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData';
|
||||
import { createVirtualElementFromContainerOffset } from '@/page-layout/widgets/graph/utils/createVirtualElementFromContainerOffset';
|
||||
import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
|
||||
import { type RefObject } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@@ -13,9 +14,8 @@ type GraphBarChartTooltipProps = {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
enrichedKeys: BarChartEnrichedKey[];
|
||||
formatOptions: GraphValueFormatOptions;
|
||||
enableGroupTooltip?: boolean;
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
onBarClick?: (datum: ComputedDatum<BarDatum>) => void;
|
||||
onSliceClick?: (slice: BarChartSlice) => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
};
|
||||
@@ -24,9 +24,8 @@ export const GraphBarChartTooltip = ({
|
||||
containerRef,
|
||||
enrichedKeys,
|
||||
formatOptions,
|
||||
enableGroupTooltip = true,
|
||||
layout = 'vertical',
|
||||
onBarClick,
|
||||
onSliceClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: GraphBarChartTooltipProps) => {
|
||||
@@ -39,10 +38,10 @@ export const GraphBarChartTooltip = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleTooltipClick: (() => void) | undefined = isDefined(onBarClick)
|
||||
const handleTooltipClick: (() => void) | undefined = isDefined(onSliceClick)
|
||||
? () => {
|
||||
if (isDefined(tooltipState)) {
|
||||
onBarClick(tooltipState.datum);
|
||||
onSliceClick(tooltipState.slice);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
@@ -50,14 +49,19 @@ export const GraphBarChartTooltip = ({
|
||||
const tooltipData = !isDefined(tooltipState)
|
||||
? null
|
||||
: getBarChartTooltipData({
|
||||
datum: tooltipState.datum,
|
||||
slice: tooltipState.slice,
|
||||
enrichedKeys,
|
||||
formatOptions,
|
||||
enableGroupTooltip,
|
||||
layout,
|
||||
});
|
||||
|
||||
const reference = isDefined(tooltipState) ? tooltipState.anchorElement : null;
|
||||
const reference = !isDefined(tooltipState)
|
||||
? null
|
||||
: createVirtualElementFromContainerOffset(
|
||||
containerElement,
|
||||
tooltipState.offsetLeft,
|
||||
tooltipState.offsetTop,
|
||||
);
|
||||
|
||||
return (
|
||||
<GraphWidgetFloatingTooltip
|
||||
@@ -66,7 +70,6 @@ export const GraphBarChartTooltip = ({
|
||||
tooltipOffsetFromAnchorInPx={BAR_CHART_CONSTANTS.TOOLTIP_OFFSET_PX}
|
||||
items={tooltipData?.tooltipItems ?? []}
|
||||
indexLabel={tooltipData?.indexLabel}
|
||||
highlightedKey={tooltipData?.hoveredKey}
|
||||
onGraphWidgetTooltipClick={handleTooltipClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
|
||||
+51
-20
@@ -2,13 +2,16 @@ import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/component
|
||||
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
|
||||
import { NoDataLayer } from '@/page-layout/widgets/graph/components/NoDataLayer';
|
||||
import { CustomBarItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem';
|
||||
import { CustomSliceHoverLayer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomSliceHoverLayer';
|
||||
import { CustomTotalsLayer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomTotalsLayer';
|
||||
import { GraphBarChartTooltip } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip';
|
||||
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
|
||||
import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData';
|
||||
import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme';
|
||||
import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState';
|
||||
import { graphWidgetHoveredSliceIndexComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetHoveredSliceIndexComponentState';
|
||||
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
|
||||
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
|
||||
import { calculateStackedBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange';
|
||||
import { calculateValueRangeFromBarChartKeys } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys';
|
||||
import { getBarChartAxisConfigs } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs';
|
||||
@@ -32,14 +35,14 @@ import {
|
||||
type BarDatum,
|
||||
type BarItemProps,
|
||||
type ComputedBarDatum,
|
||||
type ComputedDatum,
|
||||
} from '@nivo/bar';
|
||||
import { useCallback, useMemo, useRef, useState, type MouseEvent } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { BarChartLayout } from '~/generated/graphql';
|
||||
|
||||
type NoDataLayerWrapperProps = BarCustomLayerProps<BarDatum>;
|
||||
type SliceHoverLayerWrapperProps = BarCustomLayerProps<BarDatum>;
|
||||
|
||||
type GraphWidgetBarChartProps = {
|
||||
data: BarDatum[];
|
||||
@@ -58,7 +61,7 @@ type GraphWidgetBarChartProps = {
|
||||
rangeMin?: number;
|
||||
rangeMax?: number;
|
||||
omitNullValues?: boolean;
|
||||
onBarClick?: (datum: ComputedDatum<BarDatum>) => void;
|
||||
onSliceClick?: (slice: BarChartSlice) => void;
|
||||
} & GraphValueFormatOptions;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@@ -92,7 +95,7 @@ export const GraphWidgetBarChart = ({
|
||||
prefix,
|
||||
suffix,
|
||||
customFormatter,
|
||||
onBarClick,
|
||||
onSliceClick,
|
||||
}: GraphWidgetBarChartProps) => {
|
||||
const theme = useTheme();
|
||||
const colorRegistry = createGraphColorRegistry(theme);
|
||||
@@ -105,6 +108,10 @@ export const GraphWidgetBarChart = ({
|
||||
graphWidgetBarTooltipComponentState,
|
||||
);
|
||||
|
||||
const setHoveredSliceIndex = useSetRecoilComponentState(
|
||||
graphWidgetHoveredSliceIndexComponentState,
|
||||
);
|
||||
|
||||
const formatOptions: GraphValueFormatOptions = {
|
||||
displayType,
|
||||
decimals,
|
||||
@@ -158,9 +165,13 @@ export const GraphWidgetBarChart = ({
|
||||
tickCount: tickConfig.numberOfValueTicks,
|
||||
});
|
||||
|
||||
const hasClickableItems = isDefined(onBarClick);
|
||||
const hasClickableItems = isDefined(onSliceClick);
|
||||
|
||||
const hideTooltip = () => {
|
||||
setActiveBarTooltip(null);
|
||||
setHoveredSliceIndex(null);
|
||||
};
|
||||
|
||||
const hideTooltip = () => setActiveBarTooltip(null);
|
||||
const debouncedHideTooltip = useDebouncedCallback(hideTooltip, 300);
|
||||
|
||||
const handleTooltipMouseEnter = () => {
|
||||
@@ -169,20 +180,28 @@ export const GraphWidgetBarChart = ({
|
||||
|
||||
const handleTooltipMouseLeave = debouncedHideTooltip;
|
||||
|
||||
const handleBarEnter = useCallback(
|
||||
(datum: ComputedDatum<BarDatum>, event: MouseEvent<SVGRectElement>) => {
|
||||
const handleSliceHover = (
|
||||
sliceData: {
|
||||
slice: BarChartSlice;
|
||||
offsetLeft: number;
|
||||
offsetTop: number;
|
||||
} | null,
|
||||
) => {
|
||||
if (isDefined(sliceData)) {
|
||||
debouncedHideTooltip.cancel();
|
||||
setActiveBarTooltip({
|
||||
datum,
|
||||
anchorElement: event.currentTarget,
|
||||
slice: sliceData.slice,
|
||||
offsetLeft: sliceData.offsetLeft,
|
||||
offsetTop: sliceData.offsetTop,
|
||||
});
|
||||
},
|
||||
[debouncedHideTooltip, setActiveBarTooltip],
|
||||
);
|
||||
} else {
|
||||
debouncedHideTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBarLeave = useCallback(() => {
|
||||
const handleSliceLeave = () => {
|
||||
debouncedHideTooltip();
|
||||
}, [debouncedHideTooltip]);
|
||||
};
|
||||
|
||||
const {
|
||||
axisBottom: axisBottomConfig,
|
||||
@@ -243,6 +262,21 @@ export const GraphWidgetBarChart = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const SliceHoverLayerWrapper = (layerProps: SliceHoverLayerWrapperProps) =>
|
||||
hasNoData ? null : (
|
||||
<CustomSliceHoverLayer
|
||||
bars={layerProps.bars}
|
||||
innerWidth={layerProps.innerWidth}
|
||||
innerHeight={layerProps.innerHeight}
|
||||
marginLeft={margins.left}
|
||||
marginTop={margins.top}
|
||||
layout={layout}
|
||||
onSliceHover={handleSliceHover}
|
||||
onSliceClick={onSliceClick}
|
||||
onSliceLeave={handleSliceLeave}
|
||||
/>
|
||||
);
|
||||
|
||||
const hasNegativeValues = calculatedValueRange.minimum < 0;
|
||||
const zeroMarker = hasNegativeValues
|
||||
? [
|
||||
@@ -294,6 +328,7 @@ export const GraphWidgetBarChart = ({
|
||||
'grid',
|
||||
'markers',
|
||||
'axes',
|
||||
SliceHoverLayerWrapper,
|
||||
'bars',
|
||||
'legends',
|
||||
TotalsLayer,
|
||||
@@ -332,9 +367,6 @@ export const GraphWidgetBarChart = ({
|
||||
formatGraphValue(Number(barDatumCandidate.value), formatOptions)
|
||||
}
|
||||
tooltip={() => null}
|
||||
onMouseEnter={hasNoData ? undefined : handleBarEnter}
|
||||
onMouseLeave={hasNoData ? undefined : handleBarLeave}
|
||||
onClick={hasNoData ? undefined : onBarClick}
|
||||
theme={chartTheme}
|
||||
borderRadius={parseInt(theme.border.radius.sm)}
|
||||
/>
|
||||
@@ -344,9 +376,8 @@ export const GraphWidgetBarChart = ({
|
||||
containerRef={containerRef}
|
||||
enrichedKeys={enrichedKeys}
|
||||
formatOptions={formatOptions}
|
||||
enableGroupTooltip={groupMode === 'stacked'}
|
||||
layout={layout === BarChartLayout.VERTICAL ? 'vertical' : 'horizontal'}
|
||||
onBarClick={onBarClick}
|
||||
onSliceClick={onSliceClick}
|
||||
onMouseEnter={handleTooltipMouseEnter}
|
||||
onMouseLeave={handleTooltipMouseLeave}
|
||||
/>
|
||||
|
||||
+5
-5
@@ -2,6 +2,7 @@ import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPag
|
||||
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
|
||||
import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect';
|
||||
import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData';
|
||||
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
|
||||
import { getEffectiveGroupMode } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getEffectiveGroupMode';
|
||||
import { assertBarChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/assertBarChartWidget';
|
||||
import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams';
|
||||
@@ -11,7 +12,6 @@ import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/ho
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector';
|
||||
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
@@ -78,9 +78,9 @@ export const GraphWidgetBarChartRenderer = () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const handleBarClick = (datum: ComputedDatum<BarDatum>) => {
|
||||
const displayValue = datum.data[indexBy];
|
||||
const rawValue = formattedToRawLookup.get(displayValue as string) ?? null;
|
||||
const handleSliceClick = (slice: BarChartSlice) => {
|
||||
const displayValue = slice.indexValue;
|
||||
const rawValue = formattedToRawLookup.get(displayValue) ?? null;
|
||||
|
||||
const queryParams = buildChartDrilldownQueryParams({
|
||||
objectMetadataItem,
|
||||
@@ -128,7 +128,7 @@ export const GraphWidgetBarChartRenderer = () => {
|
||||
rangeMin={configuration.rangeMin ?? undefined}
|
||||
rangeMax={configuration.rangeMax ?? undefined}
|
||||
omitNullValues={configuration.omitNullValues ?? false}
|
||||
onBarClick={isPageLayoutInEditMode ? undefined : handleBarClick}
|
||||
onSliceClick={isPageLayoutInEditMode ? undefined : handleSliceClick}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ export const BAR_CHART_CONSTANTS = {
|
||||
TOOLTIP_OFFSET_PX: 2,
|
||||
TOOLTIP_SCROLLABLE_ITEM_THRESHOLD: 5,
|
||||
HOVER_BRIGHTNESS: 0.85,
|
||||
SLICE_HIGHLIGHT_THICKNESS: 1,
|
||||
MINIMUM_BAR_WIDTH: 2,
|
||||
DATE_GRANULARITIES_WITHOUT_GAP_FILLING: new Set([
|
||||
ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK,
|
||||
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
|
||||
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
|
||||
|
||||
export const graphWidgetBarTooltipComponentState = createComponentState<{
|
||||
datum: ComputedDatum<BarDatum>;
|
||||
anchorElement: Element;
|
||||
slice: BarChartSlice;
|
||||
offsetLeft: number;
|
||||
offsetTop: number;
|
||||
} | null>({
|
||||
key: 'graphWidgetBarTooltipComponentState',
|
||||
defaultValue: null,
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
|
||||
export const graphWidgetHoveredSliceIndexComponentState = createComponentState<
|
||||
string | null
|
||||
>({
|
||||
key: 'graphWidgetHoveredSliceIndexComponentState',
|
||||
defaultValue: null,
|
||||
componentInstanceContext: WidgetComponentInstanceContext,
|
||||
});
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { type BarDatum, type ComputedBarDatum } from '@nivo/bar';
|
||||
|
||||
export type BarChartSlice = {
|
||||
indexValue: string;
|
||||
bars: ComputedBarDatum<BarDatum>[];
|
||||
sliceLeft: number;
|
||||
sliceRight: number;
|
||||
sliceCenter: number;
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
|
||||
|
||||
type ComputeSliceHighlightPositionParams = {
|
||||
sliceCenter: number | null;
|
||||
isVerticalLayout: boolean;
|
||||
innerWidth: number;
|
||||
innerHeight: number;
|
||||
};
|
||||
|
||||
type SliceHighlightPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const computeSliceHighlightPosition = ({
|
||||
sliceCenter,
|
||||
isVerticalLayout,
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
}: ComputeSliceHighlightPositionParams): SliceHighlightPosition => {
|
||||
const center = sliceCenter ?? 0;
|
||||
const halfThickness = BAR_CHART_CONSTANTS.SLICE_HIGHLIGHT_THICKNESS / 2;
|
||||
|
||||
return {
|
||||
x: isVerticalLayout ? center - halfThickness : 0,
|
||||
y: isVerticalLayout ? 0 : center - halfThickness,
|
||||
width: isVerticalLayout
|
||||
? BAR_CHART_CONSTANTS.SLICE_HIGHLIGHT_THICKNESS
|
||||
: innerWidth,
|
||||
height: isVerticalLayout
|
||||
? innerHeight
|
||||
: BAR_CHART_CONSTANTS.SLICE_HIGHLIGHT_THICKNESS,
|
||||
};
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
|
||||
import { type BarDatum, type ComputedBarDatum } from '@nivo/bar';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type ComputeSlicesFromBarsParams = {
|
||||
bars: readonly ComputedBarDatum<BarDatum>[];
|
||||
isVerticalLayout: boolean;
|
||||
};
|
||||
|
||||
export const computeSlicesFromBars = ({
|
||||
bars,
|
||||
isVerticalLayout,
|
||||
}: ComputeSlicesFromBarsParams): BarChartSlice[] => {
|
||||
if (bars.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupedBarsByIndex = new Map<string, ComputedBarDatum<BarDatum>[]>();
|
||||
|
||||
for (const bar of bars) {
|
||||
const indexKey = String(bar.data.indexValue);
|
||||
const existingBars = groupedBarsByIndex.get(indexKey);
|
||||
if (isDefined(existingBars)) {
|
||||
existingBars.push(bar);
|
||||
} else {
|
||||
groupedBarsByIndex.set(indexKey, [bar]);
|
||||
}
|
||||
}
|
||||
|
||||
const computedSlices: BarChartSlice[] = [];
|
||||
|
||||
for (const [indexValue, barsInGroup] of groupedBarsByIndex) {
|
||||
const minPosition = Math.min(
|
||||
...barsInGroup.map((bar) => (isVerticalLayout ? bar.x : bar.y)),
|
||||
);
|
||||
const maxPosition = Math.max(
|
||||
...barsInGroup.map((bar) =>
|
||||
isVerticalLayout ? bar.x + bar.width : bar.y + bar.height,
|
||||
),
|
||||
);
|
||||
|
||||
computedSlices.push({
|
||||
indexValue,
|
||||
bars: [...barsInGroup],
|
||||
sliceLeft: minPosition,
|
||||
sliceRight: maxPosition,
|
||||
sliceCenter: (minPosition + maxPosition) / 2,
|
||||
});
|
||||
}
|
||||
|
||||
computedSlices.sort((sliceA, sliceB) => sliceA.sliceLeft - sliceB.sliceLeft);
|
||||
|
||||
return computedSlices;
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { type BarDatum, type ComputedBarDatum } from '@nivo/bar';
|
||||
|
||||
export const findAnchorBarInSlice = (
|
||||
bars: ComputedBarDatum<BarDatum>[],
|
||||
isVerticalLayout: boolean,
|
||||
): ComputedBarDatum<BarDatum> => {
|
||||
return isVerticalLayout
|
||||
? bars.reduce((topBar, bar) => (bar.y < topBar.y ? bar : topBar))
|
||||
: bars.reduce((rightBar, bar) =>
|
||||
bar.x + bar.width > rightBar.x + rightBar.width ? bar : rightBar,
|
||||
);
|
||||
};
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
|
||||
import { findAnchorBarInSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/findAnchorBarInSlice';
|
||||
import { type MouseEvent } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type FindSliceAtPositionParams = {
|
||||
event: MouseEvent<SVGRectElement>;
|
||||
slices: BarChartSlice[];
|
||||
marginLeft: number;
|
||||
marginTop: number;
|
||||
isVerticalLayout: boolean;
|
||||
};
|
||||
|
||||
type SliceAtPositionResult = {
|
||||
slice: BarChartSlice;
|
||||
offsetLeft: number;
|
||||
offsetTop: number;
|
||||
};
|
||||
|
||||
export const findSliceAtPosition = ({
|
||||
event,
|
||||
slices,
|
||||
marginLeft,
|
||||
marginTop,
|
||||
isVerticalLayout,
|
||||
}: FindSliceAtPositionParams): SliceAtPositionResult | null => {
|
||||
const svgBoundingRectangle =
|
||||
event.currentTarget.ownerSVGElement?.getBoundingClientRect();
|
||||
|
||||
if (!isDefined(svgBoundingRectangle) || slices.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mousePositionX = event.clientX - svgBoundingRectangle.left - marginLeft;
|
||||
const mousePositionY = event.clientY - svgBoundingRectangle.top - marginTop;
|
||||
|
||||
const positionAlongAxis = isVerticalLayout ? mousePositionX : mousePositionY;
|
||||
|
||||
const nearestSlice = slices.reduce((nearest, slice) => {
|
||||
const currentDistance = Math.abs(slice.sliceCenter - positionAlongAxis);
|
||||
const nearestDistance = Math.abs(nearest.sliceCenter - positionAlongAxis);
|
||||
return currentDistance < nearestDistance ? slice : nearest;
|
||||
});
|
||||
|
||||
const anchorBar = findAnchorBarInSlice(nearestSlice.bars, isVerticalLayout);
|
||||
|
||||
const offsetLeft = isVerticalLayout
|
||||
? nearestSlice.sliceCenter + marginLeft
|
||||
: anchorBar.absX + anchorBar.width + marginLeft;
|
||||
const offsetTop = isVerticalLayout
|
||||
? anchorBar.absY + marginTop
|
||||
: nearestSlice.sliceCenter + marginTop;
|
||||
|
||||
return {
|
||||
slice: nearestSlice,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
};
|
||||
};
|
||||
+10
-25
@@ -1,54 +1,40 @@
|
||||
import { type GraphWidgetTooltipItem } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip';
|
||||
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
|
||||
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type GetBarChartTooltipDataParameters = {
|
||||
datum: ComputedDatum<BarDatum>;
|
||||
slice: BarChartSlice;
|
||||
enrichedKeys: BarChartEnrichedKey[];
|
||||
formatOptions: GraphValueFormatOptions;
|
||||
enableGroupTooltip?: boolean;
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
};
|
||||
|
||||
type BarChartTooltipData = {
|
||||
tooltipItems: GraphWidgetTooltipItem[];
|
||||
indexLabel: string;
|
||||
hoveredKey: string | undefined;
|
||||
};
|
||||
|
||||
export const getBarChartTooltipData = ({
|
||||
datum,
|
||||
slice,
|
||||
enrichedKeys,
|
||||
formatOptions,
|
||||
enableGroupTooltip = true,
|
||||
layout = 'vertical',
|
||||
}: GetBarChartTooltipDataParameters): BarChartTooltipData | null => {
|
||||
let keysToShow: BarChartEnrichedKey[];
|
||||
|
||||
if (enableGroupTooltip) {
|
||||
keysToShow = enrichedKeys;
|
||||
} else {
|
||||
const hoveredKey = datum.id;
|
||||
if (!isDefined(hoveredKey)) return null;
|
||||
|
||||
const enrichedKey = enrichedKeys.find(
|
||||
(item) => item.key === String(hoveredKey),
|
||||
);
|
||||
if (!isDefined(enrichedKey)) return null;
|
||||
|
||||
keysToShow = [enrichedKey];
|
||||
if (slice.bars.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstBar = slice.bars[0];
|
||||
|
||||
const keysToProcess =
|
||||
layout === 'vertical' ? [...keysToShow].reverse() : keysToShow;
|
||||
layout === 'vertical' ? [...enrichedKeys].reverse() : enrichedKeys;
|
||||
|
||||
const tooltipItems = keysToProcess.map((enrichedKey) => {
|
||||
const seriesValue = Number(datum.data[enrichedKey.key] ?? 0);
|
||||
const seriesValue = Number(firstBar.data.data[enrichedKey.key] ?? 0);
|
||||
return {
|
||||
key: enrichedKey.key,
|
||||
label: enrichedKey.label,
|
||||
@@ -60,7 +46,6 @@ export const getBarChartTooltipData = ({
|
||||
|
||||
return {
|
||||
tooltipItems,
|
||||
indexLabel: String(datum.indexValue),
|
||||
hoveredKey: enableGroupTooltip ? String(datum.id) : undefined,
|
||||
indexLabel: slice.indexValue,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user