nitin
2026-01-08 14:36:44 +05:30
committed by GitHub
parent e0107f67fd
commit d653b0a168
15 changed files with 496 additions and 124 deletions
@@ -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>
@@ -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>
@@ -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>
);
};
@@ -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}
@@ -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}
/>
@@ -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>
);
@@ -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,
@@ -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,
@@ -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,
});
@@ -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;
};
@@ -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,
};
};
@@ -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;
};
@@ -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,
);
};
@@ -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,
};
};
@@ -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,
};
};