diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetTooltip.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetTooltip.tsx index 9b5dff001f..5266640e3e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetTooltip.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetTooltip.tsx @@ -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 ( - + {indexLabel && ( {indexLabel} )} - {filteredItems.map((item) => { - const isHighlighted = - shouldHighlight && highlightedKey === item.key; - return ( - - - - - {item.label} - - - {item.formattedValue} - - - - ); - })} + {filteredItems.length === 0 ? ( + {t`No data`} + ) : ( + filteredItems.map((item) => { + const isHighlighted = + shouldHighlight && highlightedKey === item.key; + return ( + + + + + {item.label} + + + {item.formattedValue} + + + + ); + }) + )} - {hasGraphWidgetTooltipClick && ( + {shouldShowClickFooter && ( <> diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx index 7ec4f93cfd..4f503caa50 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx @@ -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 = BarItemProps & { 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 = ({ - 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 = ({ graphWidgetHighlightedLegendIdComponentState, ); + const hoveredSliceIndex = useRecoilComponentValue( + graphWidgetHoveredSliceIndexComponentState, + ); + const isDimmed = isDefined(highlightedLegendId) && String(highlightedLegendId) !== String(barData.id); - const handleClick = useCallback( - (event: MouseEvent) => { - onClick?.({ color: bar.color, ...barData }, event); - }, - [bar, barData, onClick], - ); - - const handleMouseEnter = useCallback( - (event: MouseEvent) => { - onMouseEnter?.(barData, event); - }, - [barData, onMouseEnter], - ); - - const handleMouseLeave = useCallback( - (event: MouseEvent) => { - 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 = ({ Math.max(value, 0))} height={to(finalBarHeightDimension, (value) => Math.max(value, 0))} @@ -232,9 +217,6 @@ export const CustomBarItem = ({ } 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}`} /> diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomSliceHoverLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomSliceHoverLayer.tsx new file mode 100644 index 0000000000..380b9ac692 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomSliceHoverLayer.tsx @@ -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[]; + 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) => { + 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) => { + 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 ( + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx index b0d9346631..1518fbcdaa 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx @@ -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; enrichedKeys: BarChartEnrichedKey[]; formatOptions: GraphValueFormatOptions; - enableGroupTooltip?: boolean; layout?: 'vertical' | 'horizontal'; - onBarClick?: (datum: ComputedDatum) => 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 ( ; +type SliceHoverLayerWrapperProps = BarCustomLayerProps; type GraphWidgetBarChartProps = { data: BarDatum[]; @@ -58,7 +61,7 @@ type GraphWidgetBarChartProps = { rangeMin?: number; rangeMax?: number; omitNullValues?: boolean; - onBarClick?: (datum: ComputedDatum) => 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, event: MouseEvent) => { + 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 : ( + + ); + 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} /> diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx index 378634b74d..8a4bb75fc8 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx @@ -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) => { - 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} /> ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants.ts index 8f919a6784..54bc36f032 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants.ts @@ -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, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState.ts index 95f0530710..cb3492f922 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState.ts @@ -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; - anchorElement: Element; + slice: BarChartSlice; + offsetLeft: number; + offsetTop: number; } | null>({ key: 'graphWidgetBarTooltipComponentState', defaultValue: null, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetHoveredSliceIndexComponentState.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetHoveredSliceIndexComponentState.ts new file mode 100644 index 0000000000..90adbaf021 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetHoveredSliceIndexComponentState.ts @@ -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, +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice.ts new file mode 100644 index 0000000000..2f147071ad --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice.ts @@ -0,0 +1,9 @@ +import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; + +export type BarChartSlice = { + indexValue: string; + bars: ComputedBarDatum[]; + sliceLeft: number; + sliceRight: number; + sliceCenter: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSliceHighlightPosition.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSliceHighlightPosition.ts new file mode 100644 index 0000000000..602a9b4052 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSliceHighlightPosition.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSlicesFromBars.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSlicesFromBars.ts new file mode 100644 index 0000000000..a11b895baf --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeSlicesFromBars.ts @@ -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[]; + isVerticalLayout: boolean; +}; + +export const computeSlicesFromBars = ({ + bars, + isVerticalLayout, +}: ComputeSlicesFromBarsParams): BarChartSlice[] => { + if (bars.length === 0) { + return []; + } + + const groupedBarsByIndex = new Map[]>(); + + 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; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findAnchorBarInSlice.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findAnchorBarInSlice.ts new file mode 100644 index 0000000000..060582fcd3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findAnchorBarInSlice.ts @@ -0,0 +1,12 @@ +import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; + +export const findAnchorBarInSlice = ( + bars: ComputedBarDatum[], + isVerticalLayout: boolean, +): ComputedBarDatum => { + 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, + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findSliceAtPosition.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findSliceAtPosition.ts new file mode 100644 index 0000000000..795886ecb1 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/findSliceAtPosition.ts @@ -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; + 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, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData.ts index 252d41c315..30cd800d55 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData.ts @@ -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; + 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, }; };