From 561297436ec8517e464b8f8e6a3965f1f51f1bc2 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Tue, 11 Nov 2025 21:17:41 +0530 Subject: [PATCH] Anchored tooltip (#15568) Issue - - tooltip gets too long when groupby has too much data points - we need max height on tooltips - we need to be able to scroll inside the tooltip - not possible if the tooltip is following the cursor (which library enforces) - its not easy to customize Nivo's own tooltip to make it work how we want it what we want - - let the tooltip anchor to the bar in such a way -- that the top of the bar aligns with the vertical middle of the tooltip - add max height to the tooltip what I did - - use floating portal from floating-ui/react - get the hover datum (ie hovered bars) dimensions on mouse enter to render the tooltip - anchor the tooltip at the calculated position - floating-ui handles the basic things like flipping/offset/shift - clear the states as required --------- Co-authored-by: Lucas Bordeau --- .../widgets/graph/components/GraphWidget.tsx | 2 +- .../components/GraphWidgetFloatingTooltip.tsx | 77 ++++++++ .../graph/components/GraphWidgetTooltip.tsx | 89 ++++++--- .../GraphWidgetBarChart.stories.tsx | 32 ++-- .../GraphWidgetLineChart.stories.tsx | 11 +- .../GraphWidgetTooltip.stories.tsx | 111 ++++++++++- .../constants/GraphTooltipMaxWidthPx.ts | 1 + .../constants/GraphTooltipMinWidthPx.ts | 1 + .../GraphTooltipScrollMaxHeightPx.ts | 1 + .../GraphTooltipBoundaryPaddingPx.ts | 1 + .../graph/constants/GraphTooltipOffsetPx.ts | 1 + .../components/CustomBarItem.tsx | 108 +++-------- .../components/GraphBarChartTooltip.tsx | 91 +++++++++ .../components/GraphWidgetBarChart.tsx | 124 +++++++----- .../constants/BarChartLegendItemThreshold.ts | 1 + .../BarChartTooltipScrollableItemThreshold.ts | 1 + .../hooks/useBarChartHandlers.ts | 35 ---- .../hooks/useBarChartTooltip.ts | 62 ------ .../utils/getBarChartTooltipData.ts | 77 ++++++++ .../components/GraphWidgetGaugeChart.tsx | 2 +- .../hooks/useGaugeChartTooltip.ts | 5 +- .../components/CustomCrosshairLayer.tsx | 146 +++++++++++++++ .../components/GraphLineChartTooltip.tsx | 73 ++++++++ .../components/GraphWidgetLineChart.tsx | 177 ++++++++++++------ .../constants/LineChartCrosshairDashArray.ts | 1 + .../LineChartCrosshairStrokeOpacity.ts | 1 + .../LineChartCrosshairStrokeWidth.ts | 1 + .../LineChartCrosshairTransitionDamping.ts | 1 + .../LineChartCrosshairTransitionStiffness.ts | 1 + .../constants/LineChartMarginBottom.ts | 1 + .../constants/LineChartMarginLeft.ts | 1 + .../constants/LineChartMarginRight.ts | 1 + .../constants/LineChartMarginTop.ts | 1 + .../hooks/useLineChartTooltip.ts | 99 ---------- .../utils/getLineChartTooltipData.ts | 61 ++++++ .../components/GraphWidgetPieChart.tsx | 2 +- .../hooks/usePieChartTooltip.ts | 11 +- .../hooks/useGraphWidgetTooltipFloating.ts | 48 +++++ .../graph/types/GraphWidgetTooltipContent.ts | 8 + ...reateVirtualElementFromChartCoordinates.ts | 15 ++ ...createVirtualElementFromContainerOffset.ts | 16 ++ .../createVirtualElementFromSVGElement.ts | 23 +++ ...oltipReferenceFromBarChartElementAnchor.ts | 18 ++ ...ooltipReferenceFromLineChartPointAnchor.ts | 27 +++ 44 files changed, 1118 insertions(+), 448 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/components/constants/GraphTooltipMaxWidthPx.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/components/constants/GraphTooltipMinWidthPx.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/components/constants/GraphTooltipScrollMaxHeightPx.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphTooltipBoundaryPaddingPx.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphTooltipOffsetPx.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartLegendItemThreshold.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartTooltipScrollableItemThreshold.ts delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartHandlers.ts delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTooltip.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomCrosshairLayer.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphLineChartTooltip.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairDashArray.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairStrokeOpacity.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairStrokeWidth.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionDamping.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionStiffness.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginBottom.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginLeft.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginRight.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginTop.ts delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartTooltip.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartTooltipData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetTooltipFloating.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/types/GraphWidgetTooltipContent.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromChartCoordinates.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromContainerOffset.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromSVGElement.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getTooltipReferenceFromBarChartElementAnchor.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getTooltipReferenceFromLineChartPointAnchor.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidget.tsx index c1421162bc..afa7f77d90 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidget.tsx @@ -118,7 +118,7 @@ export const GraphWidget = ({ yScale={data.yScale} curve={data.curve} stackedArea={data.stackedArea} - enableSlices={data.enableSlices} + enableSlices={'x'} /> ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip.tsx new file mode 100644 index 0000000000..fd7693fcb2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip.tsx @@ -0,0 +1,77 @@ +import { + GraphWidgetTooltip, + type GraphWidgetTooltipItem, +} from '@/page-layout/widgets/graph/components/GraphWidgetTooltip'; +import { useGraphWidgetTooltipFloating } from '@/page-layout/widgets/graph/hooks/useGraphWidgetTooltipFloating'; +import { useTheme } from '@emotion/react'; +import { FloatingPortal, type VirtualElement } from '@floating-ui/react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { isDefined } from 'twenty-shared/utils'; + +type GraphWidgetFloatingTooltipProps = { + reference: Element | VirtualElement; + boundary: Element; + items: GraphWidgetTooltipItem[]; + indexLabel?: string; + highlightedKey?: string; + linkTo?: string; + onMouseEnter?: () => void; + onMouseLeave?: () => void; +}; + +export const GraphWidgetFloatingTooltip = ({ + reference, + boundary, + items, + indexLabel, + highlightedKey, + linkTo, + onMouseEnter, + onMouseLeave, +}: GraphWidgetFloatingTooltipProps) => { + const theme = useTheme(); + + const { refs, floatingStyles } = useGraphWidgetTooltipFloating( + reference, + boundary, + ); + + if (!isDefined(boundary) || !(boundary instanceof HTMLElement)) { + return null; + } + + return ( + +
+ + + + + +
+
+ ); +}; 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 6e9f7c32fe..d53e774ba7 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 @@ -1,3 +1,6 @@ +import { GRAPH_TOOLTIP_MAX_WIDTH_PX } from '@/page-layout/widgets/graph/components/constants/GraphTooltipMaxWidthPx'; +import { GRAPH_TOOLTIP_MIN_WIDTH_PX } from '@/page-layout/widgets/graph/components/constants/GraphTooltipMinWidthPx'; +import { GRAPH_TOOLTIP_SCROLL_MAX_HEIGHT_PX } from '@/page-layout/widgets/graph/components/constants/GraphTooltipScrollMaxHeightPx'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; @@ -12,9 +15,9 @@ const StyledTooltip = styled.div` display: flex; flex-direction: column; gap: 2px; - max-width: min(300px, calc(100vw - 40px)); - min-width: 160px; - pointer-events: none; + max-width: min(${GRAPH_TOOLTIP_MAX_WIDTH_PX}px, calc(100vw - 40px)); + min-width: ${GRAPH_TOOLTIP_MIN_WIDTH_PX}px; + pointer-events: auto; `; const StyledTooltipContent = styled.div` @@ -35,10 +38,12 @@ const StyledTooltipRowContainer = styled.div` display: flex; flex-direction: column; gap: ${({ theme }) => theme.spacing(2)}; + max-height: ${GRAPH_TOOLTIP_SCROLL_MAX_HEIGHT_PX}px; + overflow-y: auto; `; -const StyledDot = styled.div<{ $color: string }>` - background: ${({ $color }) => $color}; +const StyledDot = styled.div<{ color: string }>` + background: ${({ color }) => color}; border-radius: 50%; height: 6px; width: 6px; @@ -48,7 +53,7 @@ const StyledDot = styled.div<{ $color: string }>` const StyledTooltipLink = styled.div` align-items: center; color: ${({ theme }) => theme.font.color.light}; - cursor: default; + cursor: pointer; display: flex; justify-content: space-between; height: ${({ theme }) => theme.spacing(6)}; @@ -85,32 +90,39 @@ const StyledTooltipRowRightContent = styled.div` width: 100%; `; -const StyledTooltipLabel = styled.span` - color: ${({ theme }) => theme.font.color.tertiary}; +const StyledTooltipLabel = styled.span<{ isHighlighted?: boolean }>` + color: ${({ theme, isHighlighted }) => + isHighlighted ? theme.font.color.secondary : theme.font.color.tertiary}; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-weight: ${({ theme, isHighlighted }) => + isHighlighted ? theme.font.weight.medium : theme.font.weight.regular}; `; -const StyledTooltipValue = styled.span` +const StyledTooltipValue = styled.span<{ isHighlighted?: boolean }>` + color: ${({ theme, isHighlighted }) => + isHighlighted ? theme.font.color.tertiary : theme.font.color.extraLight}; flex-shrink: 0; - font-weight: ${({ theme }) => theme.font.weight.medium}; + font-weight: ${({ theme, isHighlighted }) => + isHighlighted ? theme.font.weight.semiBold : theme.font.weight.medium}; white-space: nowrap; `; const StyledHorizontalSectionPadding = styled.div<{ - $addTop?: boolean; - $addBottom?: boolean; + addTop?: boolean; + addBottom?: boolean; }>` padding-inline: ${({ theme }) => theme.spacing(1)}; - margin-top: ${({ $addTop, theme }) => ($addTop ? theme.spacing(1) : 0)}; - margin-bottom: ${({ $addBottom, theme }) => - $addBottom ? theme.spacing(1) : 0}; + margin-top: ${({ addTop, theme }) => (addTop ? theme.spacing(1) : 0)}; + margin-bottom: ${({ addBottom, theme }) => + addBottom ? theme.spacing(1) : 0}; `; export type GraphWidgetTooltipItem = { + key: string; label: string; formattedValue: string; value: number; @@ -119,14 +131,16 @@ export type GraphWidgetTooltipItem = { type GraphWidgetTooltipProps = { items: GraphWidgetTooltipItem[]; - showClickHint?: boolean; indexLabel?: string; + highlightedKey?: string; + linkTo?: string; }; export const GraphWidgetTooltip = ({ items, - showClickHint = false, indexLabel, + highlightedKey, + linkTo, }: GraphWidgetTooltipProps) => { const theme = useTheme(); @@ -134,31 +148,46 @@ export const GraphWidgetTooltip = ({ (item) => item.value !== 0 && isNonEmptyString(item.formattedValue), ); + const shouldHighlight = filteredItems.length > 1; + const hasLink = isNonEmptyString(linkTo); + return ( - + {indexLabel && ( {indexLabel} )} - {filteredItems.map((item, index) => ( - - - - {item.label} - {item.formattedValue} - - - ))} + {filteredItems.map((item) => { + const isHighlighted = + shouldHighlight && highlightedKey === item.key; + return ( + + + + + {item.label} + + + {item.formattedValue} + + + + ); + })} - {showClickHint && ( + {hasLink && ( <> - - + + { + window.location.href = String(linkTo); + }} + > {t`Click to see data`} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx index b46d9818a1..188da69a67 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx @@ -121,6 +121,7 @@ export const Default: Story = { xAxisLabel: 'Month', yAxisLabel: 'Count', id: 'bar-chart-default', + groupMode: 'stacked', }, render: (args) => ( @@ -133,6 +134,7 @@ export const Default: Story = { xAxisLabel={args.xAxisLabel} yAxisLabel={args.yAxisLabel} id={args.id} + groupMode={args.groupMode} /> ), @@ -184,6 +186,7 @@ export const Revenue: Story = { xAxisLabel: 'Quarter', yAxisLabel: 'Amount ($)', id: 'bar-chart-revenue', + groupMode: 'stacked', }, render: (args) => ( @@ -196,12 +199,13 @@ export const Revenue: Story = { xAxisLabel={args.xAxisLabel} yAxisLabel={args.yAxisLabel} id={args.id} + groupMode={args.groupMode} /> ), }; -export const Stacked: Story = { +export const Grouped: Story = { args: { data: [ { @@ -240,7 +244,7 @@ export const Stacked: Story = { mobile: 'Mobile', tablet: 'Tablet', }, - groupMode: 'stacked', + groupMode: 'grouped', showLegend: true, showGrid: true, xAxisLabel: 'Channel', @@ -333,6 +337,7 @@ export const WithValues: Story = { yAxisLabel: 'Score', suffix: '%', id: 'bar-chart-with-values', + groupMode: 'stacked', }, render: (args) => ( @@ -345,6 +350,7 @@ export const WithValues: Story = { xAxisLabel={args.xAxisLabel} yAxisLabel={args.yAxisLabel} id={args.id} + groupMode={args.groupMode} /> ), @@ -400,6 +406,7 @@ export const WithCustomColors: Story = { showGrid: true, xAxisLabel: 'Month', yAxisLabel: 'Count', + groupMode: 'stacked', id: 'bar-chart-custom-colors', }, render: (args) => ( @@ -414,6 +421,7 @@ export const WithCustomColors: Story = { xAxisLabel={args.xAxisLabel} yAxisLabel={args.yAxisLabel} id={args.id} + groupMode={args.groupMode} /> ), @@ -477,6 +485,7 @@ export const Currency: Story = { xAxisLabel: 'Region', yAxisLabel: 'Amount', id: 'bar-chart-currency', + groupMode: 'grouped', }, render: (args) => ( @@ -489,6 +498,7 @@ export const Currency: Story = { xAxisLabel={args.xAxisLabel} yAxisLabel={args.yAxisLabel} id={args.id} + groupMode={args.groupMode} /> ), @@ -580,6 +590,7 @@ export const MixedPositiveNegative: Story = { xAxisLabel: 'Month', yAxisLabel: 'Amount ($)', id: 'bar-chart-mixed-values', + groupMode: 'grouped', }, render: (args) => ( @@ -594,6 +605,7 @@ export const MixedPositiveNegative: Story = { showGrid={args.showGrid} xAxisLabel={args.xAxisLabel} yAxisLabel={args.yAxisLabel} + groupMode={args.groupMode} id={args.id} /> @@ -635,15 +647,15 @@ export const AllNegative: Story = { export const TemperatureData: Story = { args: { data: [ - { month: 'Jan', temp: -5, to: '/weather/jan' }, - { month: 'Feb', temp: -2, to: '/weather/feb' }, - { month: 'Mar', temp: 5, to: '/weather/mar' }, - { month: 'Apr', temp: 15, to: '/weather/apr' }, - { month: 'May', temp: 22, to: '/weather/may' }, - { month: 'Jun', temp: 28, to: '/weather/jun' }, + { month: 'Jan', temperature: -5, to: '/weather/jan' }, + { month: 'Feb', temperature: -2, to: '/weather/feb' }, + { month: 'Mar', temperature: 5, to: '/weather/mar' }, + { month: 'Apr', temperature: 15, to: '/weather/apr' }, + { month: 'May', temperature: 22, to: '/weather/may' }, + { month: 'Jun', temperature: 28, to: '/weather/jun' }, ], indexBy: 'month', - keys: ['temp'], + keys: ['temperature'], showLegend: false, showGrid: true, xAxisLabel: 'Month', @@ -777,7 +789,6 @@ export const GroupedWithAllBarsTooltip: Story = { yAxisLabel: 'Revenue', groupMode: 'grouped', id: 'bar-chart-grouped-all-tooltip', - enableGroupTooltip: true, }, render: (args) => ( @@ -793,7 +804,6 @@ export const GroupedWithAllBarsTooltip: Story = { yAxisLabel={args.yAxisLabel} groupMode={args.groupMode} id={args.id} - enableGroupTooltip={args.enableGroupTooltip} /> ), diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx index 70519ac387..f421e15aa7 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx @@ -1,8 +1,8 @@ import { type Meta, type StoryObj } from '@storybook/react'; +import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart'; import { type ComponentProps } from 'react'; import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; -import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart'; const meta: Meta = { title: 'Modules/PageLayout/Widgets/GraphWidgetLineChart', @@ -351,7 +351,7 @@ export const InteractiveWithLinks: Story = { showLegend: true, showGrid: true, enablePoints: true, - enableSlices: false, + enableSlices: 'x', xAxisLabel: 'Step', yAxisLabel: 'Progress', displayType: 'percentage', @@ -381,7 +381,10 @@ export const MultiSeriesMixed: Story = { id: 'target', label: 'Target', color: 'orange', - data: generateLinearData(12).map((d) => ({ ...d, y: 75 })), + data: generateLinearData(12).map((dataPoint) => ({ + ...dataPoint, + y: 75, + })), enableArea: false, }, ], @@ -739,7 +742,7 @@ export const PointTooltipDemo: Story = { showLegend: true, showGrid: true, enablePoints: true, - enableSlices: false, + enableSlices: 'x', xScale: { type: 'point' }, xAxisLabel: 'Month', yAxisLabel: 'Amount ($)', diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetTooltip.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetTooltip.stories.tsx index 76b80f631b..8468f4bdfd 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetTooltip.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetTooltip.stories.tsx @@ -19,13 +19,13 @@ export const Default: Story = { args: { items: [ { + key: 'revenue', label: 'Revenue', formattedValue: '$45,231', value: 45231, dotColor: 'blue', }, ], - showClickHint: false, indexLabel: 'March 09, 2024', }, }; @@ -34,13 +34,14 @@ export const WithClickHint: Story = { args: { items: [ { + key: 'sales', label: 'Sales', formattedValue: '1,234 units', value: 1234, dotColor: 'green', }, ], - showClickHint: true, + linkTo: '/sales/details', }, }; @@ -48,19 +49,21 @@ export const MultipleItems: Story = { args: { items: [ { + key: 'lastYear', label: 'Last year', formattedValue: '20k', value: 20000, dotColor: 'blue', }, { + key: 'thisYear', label: 'This year', formattedValue: '20k', value: 20000, dotColor: 'purple', }, ], - showClickHint: true, + linkTo: '/comparison/details', indexLabel: 'February 2', }, }; @@ -69,6 +72,7 @@ export const SuperLongText: Story = { args: { items: [ { + key: 'arr', label: 'Total Annual Recurring Revenue (North America Region including Canada)', formattedValue: '$2,450,000', @@ -76,13 +80,14 @@ export const SuperLongText: Story = { dotColor: 'blue', }, { + key: 'cac', label: 'Customer Acquisition Cost (Marketing & Sales Combined)', formattedValue: '$125,500', value: 125500, dotColor: 'purple', }, ], - showClickHint: true, + linkTo: '/financials/q4-2024', indexLabel: 'Q4 2024 Financial Year End (October - December) - North America Regional Performance Summary', }, @@ -92,31 +97,127 @@ export const WithZeroValues: Story = { args: { items: [ { + key: 'revenue', label: 'Revenue', formattedValue: '$0.00', value: 0, dotColor: 'blue', }, { + key: 'sales', label: 'Sales', formattedValue: '0%', value: 0, dotColor: 'green', }, { + key: 'activeUsers', label: 'Active Users', formattedValue: '0', value: 0, dotColor: 'purple', }, { + key: 'conversions', label: 'Conversions', formattedValue: '$45,231', value: 45231, dotColor: 'orange', }, ], - showClickHint: false, indexLabel: 'March 09, 2024', }, }; + +export const ManyItemsWithScroll: Story = { + args: { + items: [ + { + key: 'january', + label: 'January', + formattedValue: '$12,450', + value: 12450, + dotColor: 'blue', + }, + { + key: 'february', + label: 'February', + formattedValue: '$15,230', + value: 15230, + dotColor: 'green', + }, + { + key: 'march', + label: 'March', + formattedValue: '$18,920', + value: 18920, + dotColor: 'purple', + }, + { + key: 'april', + label: 'April', + formattedValue: '$14,560', + value: 14560, + dotColor: 'orange', + }, + { + key: 'may', + label: 'May', + formattedValue: '$21,340', + value: 21340, + dotColor: 'red', + }, + { + key: 'june', + label: 'June', + formattedValue: '$19,780', + value: 19780, + dotColor: 'pink', + }, + { + key: 'july', + label: 'July', + formattedValue: '$23,150', + value: 23150, + dotColor: 'yellow', + }, + { + key: 'august', + label: 'August', + formattedValue: '$20,890', + value: 20890, + dotColor: 'cyan', + }, + { + key: 'september', + label: 'September', + formattedValue: '$25,670', + value: 25670, + dotColor: 'teal', + }, + { + key: 'october', + label: 'October', + formattedValue: '$22,340', + value: 22340, + dotColor: 'indigo', + }, + { + key: 'november', + label: 'November', + formattedValue: '$27,890', + value: 27890, + dotColor: 'violet', + }, + { + key: 'december', + label: 'December', + formattedValue: '$31,220', + value: 31220, + dotColor: 'lime', + }, + ], + linkTo: '/annual-report/2024', + indexLabel: 'Annual Report 2024', + }, +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/constants/GraphTooltipMaxWidthPx.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/constants/GraphTooltipMaxWidthPx.ts new file mode 100644 index 0000000000..4f6f897ce2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/constants/GraphTooltipMaxWidthPx.ts @@ -0,0 +1 @@ +export const GRAPH_TOOLTIP_MAX_WIDTH_PX = 300; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/constants/GraphTooltipMinWidthPx.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/constants/GraphTooltipMinWidthPx.ts new file mode 100644 index 0000000000..a9ec42ae03 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/constants/GraphTooltipMinWidthPx.ts @@ -0,0 +1 @@ +export const GRAPH_TOOLTIP_MIN_WIDTH_PX = 160; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/constants/GraphTooltipScrollMaxHeightPx.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/constants/GraphTooltipScrollMaxHeightPx.ts new file mode 100644 index 0000000000..d0eff905e4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/constants/GraphTooltipScrollMaxHeightPx.ts @@ -0,0 +1 @@ +export const GRAPH_TOOLTIP_SCROLL_MAX_HEIGHT_PX = 120; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphTooltipBoundaryPaddingPx.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphTooltipBoundaryPaddingPx.ts new file mode 100644 index 0000000000..6d6efe86cf --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphTooltipBoundaryPaddingPx.ts @@ -0,0 +1 @@ +export const GRAPH_TOOLTIP_BOUNDARY_PADDING_PX = 8; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphTooltipOffsetPx.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphTooltipOffsetPx.ts new file mode 100644 index 0000000000..8ab8b96dc2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphTooltipOffsetPx.ts @@ -0,0 +1 @@ +export const GRAPH_TOOLTIP_OFFSET_PX = 2; 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 1f5658b44a..1ebe4cd86d 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,12 +1,9 @@ import { BAR_CHART_HOVER_BRIGHTNESS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartHoverBrightness'; import { BAR_CHART_MAXIMUM_WIDTH } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/MaximumBarWidth'; import { type BarDatum, type BarItemProps } from '@nivo/bar'; -import { Text } from '@nivo/text'; -import { useTheme } from '@nivo/theming'; -import { useTooltip } from '@nivo/tooltip'; import { animated, to } from '@react-spring/web'; import { isNumber } from '@sniptt/guards'; -import { createElement, useCallback, useMemo, type MouseEvent } from 'react'; +import { useCallback, useMemo, type MouseEvent } from 'react'; import styled from 'styled-components'; import { isDefined } from 'twenty-shared/utils'; @@ -32,27 +29,13 @@ const StyledBarRect = styled(animated.rect)<{ $isInteractive?: boolean }>` // This is a copy of the BarItem component from @nivo/bar with some design modifications export const CustomBarItem = ({ bar: { data: barData, ...bar }, - style: { - borderColor, - color, - height, - labelColor, - labelOpacity, - labelX, - labelY, - transform, - width, - textAnchor, - }, + style: { borderColor, color, height, transform, width }, borderRadius, borderWidth, - label, - shouldRenderLabel, isInteractive, onClick, onMouseEnter, onMouseLeave, - tooltip, isFocusable, ariaLabel, ariaLabelledBy, @@ -66,55 +49,37 @@ export const CustomBarItem = ({ layout = 'vertical', chartId, }: CustomBarItemProps) => { - const theme = useTheme(); - const { showTooltipFromEvent, showTooltipAt, hideTooltip } = useTooltip(); - - const renderTooltip = useMemo( - () => () => createElement(tooltip, { ...bar, ...barData }), - [tooltip, bar, barData], - ); - const handleClick = useCallback( (event: MouseEvent) => { onClick?.({ color: bar.color, ...barData }, event); }, [bar, barData, onClick], ); - const handleTooltip = useCallback( - (event: MouseEvent) => - showTooltipFromEvent(renderTooltip(), event), - [showTooltipFromEvent, renderTooltip], - ); + const handleMouseEnter = useCallback( (event: MouseEvent) => { onMouseEnter?.(barData, event); - showTooltipFromEvent(renderTooltip(), event); }, - [barData, onMouseEnter, showTooltipFromEvent, renderTooltip], + [barData, onMouseEnter], ); + const handleMouseLeave = useCallback( (event: MouseEvent) => { onMouseLeave?.(barData, event); - hideTooltip(); }, - [barData, hideTooltip, onMouseLeave], + [barData, onMouseLeave], ); - const handleFocus = useCallback(() => { - showTooltipAt(renderTooltip(), [bar.absX + bar.width / 2, bar.absY]); - }, [showTooltipAt, renderTooltip, bar]); - - const handleBlur = useCallback(() => { - hideTooltip(); - }, [hideTooltip]); - const isNegativeValue = useMemo( () => isNumber(barData.value) && barData.value < 0, [barData.value], ); const seriesIndex = useMemo( - () => (isDefined(keys) ? keys.findIndex((k) => k === barData.id) : -1), + () => + isDefined(keys) + ? keys.findIndex((currentKey) => currentKey === barData.id) + : -1, [keys, barData.id], ); @@ -131,7 +96,7 @@ export const CustomBarItem = ({ } const dataPoint = chartData.find( - (data) => data[indexBy] === barData.indexValue, + (chartDataItem) => chartDataItem[indexBy] === barData.indexValue, ); if (!isDefined(dataPoint)) { @@ -192,21 +157,23 @@ export const CustomBarItem = ({ const clipPathX = !isHorizontal ? 0 : isNegativeValue ? 0 : -borderRadius; const clipPathY = isHorizontal ? 0 : isNegativeValue ? -borderRadius : 0; - const widthWithOffset = (v: number) => - Math.max(v + (isHorizontal ? borderRadius : 0), 0); - const heightWithOffset = (v: number) => - Math.max(v + (isHorizontal ? 0 : borderRadius), 0); - const clampRadius = (v: number) => Math.min(borderRadius, v / 2); + const widthWithOffset = (value: number) => + Math.max(value + (isHorizontal ? borderRadius : 0), 0); + const heightWithOffset = (value: number) => + Math.max(value + (isHorizontal ? 0 : borderRadius), 0); + const clampRadius = (value: number) => Math.min(borderRadius, value / 2); - const clipRectWidth = to(finalBarWidthDimension, (v) => widthWithOffset(v)); - const clipRectHeight = to(finalBarHeightDimension, (v) => - heightWithOffset(v), + const clipRectWidth = to(finalBarWidthDimension, (value) => + widthWithOffset(value), ); - const clipRx = to(finalBarWidthDimension, (v) => - clampRadius(widthWithOffset(v)), + const clipRectHeight = to(finalBarHeightDimension, (value) => + heightWithOffset(value), ); - const clipRy = to(finalBarHeightDimension, (v) => - clampRadius(heightWithOffset(v)), + const clipBorderRadiusX = to(finalBarWidthDimension, (value) => + clampRadius(widthWithOffset(value)), + ); + const clipBorderRadiusY = to(finalBarHeightDimension, (value) => + clampRadius(heightWithOffset(value)), ); return ( @@ -218,8 +185,8 @@ export const CustomBarItem = ({ @@ -245,31 +212,10 @@ export const CustomBarItem = ({ aria-disabled={ariaDisabled ? ariaDisabled(barData) : undefined} aria-hidden={ariaHidden ? ariaHidden(barData) : undefined} onMouseEnter={isInteractive ? handleMouseEnter : undefined} - onMouseMove={isInteractive ? handleTooltip : undefined} onMouseLeave={isInteractive ? handleMouseLeave : undefined} onClick={isInteractive ? handleClick : undefined} - onFocus={isInteractive && isFocusable ? handleFocus : undefined} - onBlur={isInteractive && isFocusable ? handleBlur : undefined} data-testid={`bar.item.${barData.id}.${barData.index}`} /> - - {shouldRenderLabel && ( - - {label} - - )} ); 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 new file mode 100644 index 0000000000..a5604fe152 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx @@ -0,0 +1,91 @@ +import { GraphWidgetFloatingTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip'; +import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; +import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; +import { getBarChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData'; +import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { getTooltipReferenceFromBarChartElementAnchor } from '@/page-layout/widgets/graph/utils/getTooltipReferenceFromBarChartElementAnchor'; +import { type BarDatum, type ComputedDatum } from '@nivo/bar'; +import { useMemo } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +type GraphBarChartTooltipProps = { + datum: ComputedDatum; + anchorElement: Element; + containerId: string; + enrichedKeys: BarChartEnrichedKey[]; + data: BarChartDataItem[]; + indexBy: string; + formatOptions: GraphValueFormatOptions; + enableGroupTooltip?: boolean; + layout?: 'vertical' | 'horizontal'; + onMouseEnter?: () => void; + onMouseLeave?: () => void; +}; + +export const GraphBarChartTooltip = ({ + datum, + anchorElement, + containerId, + enrichedKeys, + data, + indexBy, + formatOptions, + enableGroupTooltip = true, + layout = 'vertical', + onMouseEnter, + onMouseLeave, +}: GraphBarChartTooltipProps) => { + const tooltipData = useMemo( + () => + getBarChartTooltipData({ + datum, + enrichedKeys, + data, + indexBy, + formatOptions, + enableGroupTooltip, + layout, + }), + [ + datum, + enrichedKeys, + data, + indexBy, + formatOptions, + enableGroupTooltip, + layout, + ], + ); + + const { reference, boundary } = useMemo(() => { + try { + return getTooltipReferenceFromBarChartElementAnchor( + anchorElement, + containerId, + ); + } catch { + return { reference: null, boundary: null }; + } + }, [anchorElement, containerId]); + + if ( + !isDefined(tooltipData) || + !isDefined(reference) || + !isDefined(boundary) + ) { + return null; + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx index 7c03c8c713..488e6c1bbd 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx @@ -1,13 +1,11 @@ import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer'; import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend'; -import { GraphWidgetTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip'; import { CustomBarItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem'; import { CustomTotalsLayer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomTotalsLayer'; +import { GraphBarChartTooltip } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip'; import { BAR_CHART_MINIMUM_INNER_PADDING } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMinimumInnerPadding'; import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData'; -import { useBarChartHandlers } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartHandlers'; import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme'; -import { useBarChartTooltip } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTooltip'; import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; import { calculateBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartValueRange'; @@ -20,14 +18,21 @@ import { formatGraphValue, type GraphValueFormatOptions, } from '@/page-layout/widgets/graph/utils/graphFormatters'; + import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { type ComputedBarDatum, ResponsiveBar } from '@nivo/bar'; -import { useMemo, useRef, useState } from 'react'; +import { + ResponsiveBar, + type BarItemProps, + type ComputedBarDatum, + type ComputedDatum, +} from '@nivo/bar'; +import { useCallback, useMemo, useRef, useState, type MouseEvent } from 'react'; import { isDefined } from 'twenty-shared/utils'; +import { useDebouncedCallback } from 'use-debounce'; -const LEGEND_THRESHOLD = 10; +import { BAR_CHART_LEGEND_ITEM_THRESHOLD } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartLegendItemThreshold'; type GraphWidgetBarChartProps = { data: BarChartDataItem[]; @@ -45,7 +50,6 @@ type GraphWidgetBarChartProps = { seriesLabels?: Record; rangeMin?: number; rangeMax?: number; - enableGroupTooltip?: boolean; omitNullValues?: boolean; } & GraphValueFormatOptions; @@ -74,7 +78,6 @@ export const GraphWidgetBarChart = ({ seriesLabels, rangeMin, rangeMax, - enableGroupTooltip, omitNullValues = false, displayType, decimals, @@ -84,12 +87,16 @@ export const GraphWidgetBarChart = ({ }: GraphWidgetBarChartProps) => { const theme = useTheme(); const colorRegistry = createGraphColorRegistry(theme); + + // Chart dimensions const [chartWidth, setChartWidth] = useState(0); const [chartHeight, setChartHeight] = useState(0); const containerRef = useRef(null); - const shouldEnableGroupTooltip = - enableGroupTooltip ?? groupMode === 'stacked'; + const [activeBarTooltip, setActiveBarTooltip] = useState<{ + datum: ComputedDatum; + anchorElement: Element; + } | null>(null); const formatOptions: GraphValueFormatOptions = { displayType, @@ -99,12 +106,6 @@ export const GraphWidgetBarChart = ({ customFormatter, }; - const { hoveredBar, setHoveredBar, handleBarClick, hasClickableItems } = - useBarChartHandlers({ - data, - indexBy, - }); - const chartTheme = useBarChartTheme(); const { barConfigs, enrichedKeys } = useBarChartData({ @@ -116,16 +117,36 @@ export const GraphWidgetBarChart = ({ seriesLabels, }); - const { renderTooltip: getTooltipData } = useBarChartTooltip({ - hoveredBar, - enrichedKeys, - data, - indexBy, - formatOptions, - enableGroupTooltip: shouldEnableGroupTooltip, - }); + const hasClickableItems = data.some((item) => isDefined(item.to)); - const areThereTooManyKeys = keys.length > LEGEND_THRESHOLD; + const hideTooltip = useCallback(() => setActiveBarTooltip(null), []); + const debouncedHideTooltip = useDebouncedCallback(hideTooltip, 300); + + const handleTooltipMouseEnter = () => { + debouncedHideTooltip.cancel(); + }; + + const handleTooltipMouseLeave = debouncedHideTooltip; + + const handleBarEnter = useCallback( + ( + datum: ComputedDatum, + event: MouseEvent, + ) => { + debouncedHideTooltip.cancel(); + setActiveBarTooltip({ + datum, + anchorElement: event.currentTarget, + }); + }, + [debouncedHideTooltip], + ); + + const handleBarLeave = useCallback(() => { + debouncedHideTooltip(); + }, [debouncedHideTooltip]); + + const areThereTooManyKeys = keys.length > BAR_CHART_LEGEND_ITEM_THRESHOLD; const shouldShowLegend = showLegend && !areThereTooManyKeys; @@ -142,21 +163,8 @@ export const GraphWidgetBarChart = ({ axisFontSize: chartTheme.axis.ticks.text.fontSize, }); - const renderTooltip = (datum: Parameters[0]) => { - const tooltipData = getTooltipData(datum); - if (!isDefined(tooltipData)) return null; - - return ( - - ); - }; - const BarItemWithContext = useMemo( - () => (props: any) => ( + () => (props: BarItemProps) => ( formatGraphValue(Number(value), formatOptions) } labelTextColor={theme.font.color.primary} - label={(d) => formatGraphValue(Number(d.value), formatOptions)} - tooltip={(props) => renderTooltip(props)} - onClick={handleBarClick} - onMouseEnter={(datum) => { - if (isDefined(datum.id) && isDefined(datum.indexValue)) { - setHoveredBar({ - key: String(datum.id), - indexValue: datum.indexValue, - }); - } - }} - onMouseLeave={() => setHoveredBar(null)} + label={(barDatumCandidate) => + formatGraphValue(Number(barDatumCandidate.value), formatOptions) + } + tooltip={() => null} + onMouseEnter={handleBarEnter} + onMouseLeave={handleBarLeave} theme={chartTheme} borderRadius={parseInt(theme.border.radius.sm)} /> + + {shouldShowBarChartTooltip && ( + + )} { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartLegendItemThreshold.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartLegendItemThreshold.ts new file mode 100644 index 0000000000..624401649b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartLegendItemThreshold.ts @@ -0,0 +1 @@ +export const BAR_CHART_LEGEND_ITEM_THRESHOLD = 10; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartTooltipScrollableItemThreshold.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartTooltipScrollableItemThreshold.ts new file mode 100644 index 0000000000..46048bbdeb --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartTooltipScrollableItemThreshold.ts @@ -0,0 +1 @@ +export const BAR_CHART_TOOLTIP_SCROLLABLE_ITEM_THRESHOLD = 5; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartHandlers.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartHandlers.ts deleted file mode 100644 index 66943982f3..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartHandlers.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; -import { type BarDatum, type ComputedDatum } from '@nivo/bar'; -import { useState } from 'react'; -import { isDefined } from 'twenty-shared/utils'; - -type UseBarChartHandlersProps = { - data: BarChartDataItem[]; - indexBy: string; -}; - -export const useBarChartHandlers = ({ - data, - indexBy, -}: UseBarChartHandlersProps) => { - const [hoveredBar, setHoveredBar] = useState<{ - key: string; - indexValue: string | number; - } | null>(null); - - const handleBarClick = (datum: ComputedDatum) => { - const dataItem = data.find((d) => d[indexBy] === datum.indexValue); - if (isDefined(dataItem?.to)) { - window.location.href = dataItem.to; - } - }; - - const hasClickableItems = data.some((item) => isDefined(item.to)); - - return { - hoveredBar, - setHoveredBar, - handleBarClick, - hasClickableItems, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTooltip.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTooltip.ts deleted file mode 100644 index 838b7b1a94..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTooltip.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; -import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; -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 UseBarChartTooltipProps = { - hoveredBar: { key: string; indexValue: string | number } | null; - enrichedKeys: BarChartEnrichedKey[]; - data: BarChartDataItem[]; - indexBy: string; - formatOptions: GraphValueFormatOptions; - enableGroupTooltip?: boolean; -}; - -export const useBarChartTooltip = ({ - hoveredBar, - enrichedKeys, - data, - indexBy, - formatOptions, - enableGroupTooltip = true, -}: UseBarChartTooltipProps) => { - const renderTooltip = (datum: ComputedDatum) => { - const dataItem = data.find((d) => d[indexBy] === datum.indexValue); - - let keysToShow: BarChartEnrichedKey[]; - - if (enableGroupTooltip) { - keysToShow = enrichedKeys; - } else { - const hoveredKey = hoveredBar?.key; - if (!isDefined(hoveredKey)) return null; - - const enrichedKey = enrichedKeys.find((item) => item.key === hoveredKey); - if (!isDefined(enrichedKey)) return null; - - keysToShow = [enrichedKey]; - } - - const tooltipItems = keysToShow.map((enrichedKey) => { - const seriesValue = Number(datum.data[enrichedKey.key] ?? 0); - return { - label: enrichedKey.label, - formattedValue: formatGraphValue(seriesValue, formatOptions), - value: seriesValue, - dotColor: enrichedKey.colorScheme.solid, - }; - }); - - return { - tooltipItems, - showClickHint: isDefined(dataItem?.to), - indexLabel: String(datum.indexValue), - }; - }; - - return { renderTooltip }; -}; 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 new file mode 100644 index 0000000000..167011bb47 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData.ts @@ -0,0 +1,77 @@ +import { type GraphWidgetTooltipItem } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip'; +import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; +import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; +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; + enrichedKeys: BarChartEnrichedKey[]; + data: BarChartDataItem[]; + indexBy: string; + formatOptions: GraphValueFormatOptions; + enableGroupTooltip?: boolean; + layout?: 'vertical' | 'horizontal'; +}; + +type BarChartTooltipData = { + tooltipItems: GraphWidgetTooltipItem[]; + indexLabel: string; + hoveredKey: string | undefined; + linkTo: string | undefined; +}; + +export const getBarChartTooltipData = ({ + datum, + enrichedKeys, + data, + indexBy, + formatOptions, + enableGroupTooltip = true, + layout = 'vertical', +}: GetBarChartTooltipDataParameters): BarChartTooltipData | null => { + const dataItem = data.find( + (dataRow) => dataRow[indexBy] === datum.indexValue, + ); + + 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]; + } + + const keysToProcess = + layout === 'vertical' ? [...keysToShow].reverse() : keysToShow; + + const tooltipItems = keysToProcess.map((enrichedKey) => { + const seriesValue = Number(datum.data[enrichedKey.key] ?? 0); + return { + key: enrichedKey.key, + label: enrichedKey.label, + formattedValue: formatGraphValue(seriesValue, formatOptions), + value: seriesValue, + dotColor: enrichedKey.colorScheme.solid, + }; + }); + + return { + tooltipItems, + indexLabel: String(datum.indexValue), + hoveredKey: enableGroupTooltip ? String(datum.id) : undefined, + linkTo: isDefined(dataItem?.to) ? String(dataItem.to) : undefined, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart.tsx index 2d27caf1c7..fa7f403de4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart.tsx @@ -123,7 +123,7 @@ export const GraphWidgetGaugeChart = ({ return ( ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip.ts index 3b9f21f7fe..7ff6728730 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip.ts @@ -3,7 +3,6 @@ import { formatGraphValue, type GraphValueFormatOptions, } from '@/page-layout/widgets/graph/utils/graphFormatters'; -import { isDefined } from 'twenty-shared/utils'; type UseGaugeChartTooltipProps = { value: number; @@ -31,12 +30,14 @@ export const useGaugeChartTooltip = ({ return { tooltipItem: { + // TODO: temporary use label as key, ideally key should be unique id -- change when we work on gauge + key: label, label: label, formattedValue, value, dotColor: colorScheme.solid, }, - showClickHint: isDefined(to), + linkTo: to, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomCrosshairLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomCrosshairLayer.tsx new file mode 100644 index 0000000000..16977a9dd2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomCrosshairLayer.tsx @@ -0,0 +1,146 @@ +import { LINE_CHART_CROSSHAIR_DASH_ARRAY } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairDashArray'; +import { LINE_CHART_CROSSHAIR_STROKE_OPACITY } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairStrokeOpacity'; +import { LINE_CHART_CROSSHAIR_STROKE_WIDTH } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairStrokeWidth'; +import { LINE_CHART_CROSSHAIR_TRANSITION_DAMPING } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionDamping'; +import { LINE_CHART_CROSSHAIR_TRANSITION_STIFFNESS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionStiffness'; +import { LINE_CHART_MARGIN_LEFT } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginLeft'; +import { LINE_CHART_MARGIN_TOP } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginTop'; +import { useTheme } from '@emotion/react'; +import { type LineSeries, type Point } from '@nivo/line'; +import { motion } from 'framer-motion'; +import { useCallback, useMemo, type MouseEvent } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +export type SliceHoverData = { + sliceX: number; + mouseY: number; + nearestSlice: { + xValue: string; + points: Point[]; + x: number; + }; + closestPoint: Point; + svgRect: DOMRect; +}; + +type CustomCrosshairLayerProps = { + points: readonly Point[]; + innerHeight: number; + innerWidth: number; + onSliceHover: (data: SliceHoverData) => void; + crosshairX: number | null; + onRectLeave: (relatedTarget: EventTarget | null) => void; +}; + +export const CustomCrosshairLayer = ({ + points, + innerHeight, + innerWidth, + onSliceHover, + crosshairX, + onRectLeave, +}: CustomCrosshairLayerProps) => { + const theme = useTheme(); + + const slices = useMemo(() => { + const sliceMap = new Map[]>(); + + points.forEach((point) => { + const key = String(point.data.x ?? ''); + if (!sliceMap.has(key)) { + sliceMap.set(key, []); + } + sliceMap.get(key)?.push(point); + }); + + return Array.from(sliceMap.entries()) + .map(([xValue, slicePoints]) => ({ + xValue, + points: slicePoints, + x: slicePoints[0]?.x ?? 0, + })) + .sort((sliceA, sliceB) => sliceA.x - sliceB.x); + }, [points]); + + const handleMouseMove = useCallback( + (event: MouseEvent) => { + const svgRect = + event.currentTarget.ownerSVGElement?.getBoundingClientRect(); + if (!isDefined(svgRect)) { + return; + } + + const mouseX = event.clientX - svgRect.left - LINE_CHART_MARGIN_LEFT; + const mouseY = event.clientY - svgRect.top - LINE_CHART_MARGIN_TOP; + + const nearestSlice = slices.reduce((nearest, slice) => { + const currentDistance = Math.abs(slice.x - mouseX); + const nearestDistance = Math.abs(nearest.x - mouseX); + return currentDistance < nearestDistance ? slice : nearest; + }); + + if (nearestSlice.x === crosshairX) { + return; + } + + const closestPoint = nearestSlice.points.reduce( + (closestPointCandidate, pointCandidate) => { + const currentDistance = Math.abs(pointCandidate.y - mouseY); + const closestDistance = Math.abs(closestPointCandidate.y - mouseY); + return currentDistance < closestDistance + ? pointCandidate + : closestPointCandidate; + }, + ); + + onSliceHover({ + sliceX: nearestSlice.x, + mouseY, + nearestSlice, + closestPoint, + svgRect, + }); + }, + [slices, crosshairX, onSliceHover], + ); + + const transition = { + type: 'spring', + stiffness: LINE_CHART_CROSSHAIR_TRANSITION_STIFFNESS, + damping: LINE_CHART_CROSSHAIR_TRANSITION_DAMPING, + } as const; + + return ( + + {isDefined(crosshairX) && ( + + )} + + onRectLeave(event.relatedTarget)} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphLineChartTooltip.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphLineChartTooltip.tsx new file mode 100644 index 0000000000..c7b4691c01 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphLineChartTooltip.tsx @@ -0,0 +1,73 @@ +import { GraphWidgetFloatingTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip'; +import { type LineChartEnrichedSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries'; +import { getLineChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartTooltipData'; +import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { getTooltipReferenceFromLineChartPointAnchor } from '@/page-layout/widgets/graph/utils/getTooltipReferenceFromLineChartPointAnchor'; +import { type LineSeries, type SliceTooltipProps } from '@nivo/line'; +import { useMemo } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +type GraphLineChartTooltipProps = { + slice: SliceTooltipProps['slice']; + offsetLeft: number; + offsetTop: number; + containerId: string; + enrichedSeries: LineChartEnrichedSeries[]; + formatOptions: GraphValueFormatOptions; + highlightedSeriesId?: string; + linkTo?: string; + onMouseEnter?: () => void; + onMouseLeave?: () => void; +}; + +export const GraphLineChartTooltip = ({ + slice, + offsetLeft, + offsetTop, + containerId, + enrichedSeries, + formatOptions, + highlightedSeriesId, + linkTo, + onMouseEnter, + onMouseLeave, +}: GraphLineChartTooltipProps) => { + const tooltipData = useMemo( + () => + getLineChartTooltipData({ + slice, + enrichedSeries, + formatOptions, + }), + [slice, enrichedSeries, formatOptions], + ); + + const { reference, boundary } = useMemo(() => { + try { + return getTooltipReferenceFromLineChartPointAnchor( + containerId, + offsetLeft, + offsetTop, + ); + } catch { + return { reference: null, boundary: null }; + } + }, [containerId, offsetLeft, offsetTop]); + + if (!isDefined(reference) || !isDefined(boundary)) { + return null; + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx index dac8cac99b..8594057f40 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx @@ -1,21 +1,32 @@ import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer'; import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend'; -import { GraphWidgetTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip'; +import { + CustomCrosshairLayer, + type SliceHoverData, +} from '@/page-layout/widgets/graph/graphWidgetLineChart/components/CustomCrosshairLayer'; +import { GraphLineChartTooltip } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphLineChartTooltip'; +import { LINE_CHART_MARGIN_BOTTOM } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginBottom'; +import { LINE_CHART_MARGIN_LEFT } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginLeft'; +import { LINE_CHART_MARGIN_RIGHT } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginRight'; +import { LINE_CHART_MARGIN_TOP } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginTop'; import { useLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData'; import { useLineChartTheme } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartTheme'; -import { useLineChartTooltip } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartTooltip'; import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; import { getLineChartAxisBottomConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig'; import { getLineChartAxisLeftConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisLeftConfig'; -import { handleLineChartPointClick } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/handleLineChartPointClick'; import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry'; import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { ResponsiveLine } from '@nivo/line'; +import { + ResponsiveLine, + type LineSeries, + type SliceTooltipProps, +} from '@nivo/line'; import { type ScaleLinearSpec, type ScaleSpec } from '@nivo/scales'; -import { useId } from 'react'; +import { useCallback, useId, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; +import { useDebouncedCallback } from 'use-debounce'; type GraphWidgetLineChartProps = { data: LineChartSeries[]; @@ -35,7 +46,7 @@ type GraphWidgetLineChartProps = { | 'stepAfter' | 'natural'; lineWidth?: number; - enableSlices?: 'x' | 'y' | false; + enableSlices?: 'x' | 'y'; xScale?: ScaleSpec; yScale?: ScaleSpec; } & GraphValueFormatOptions; @@ -119,58 +130,80 @@ export const GraphWidgetLineChart = ({ theme, }); - const { createSliceTooltipData, createPointTooltipData } = - useLineChartTooltip({ - dataMap, - enrichedSeries, - formatOptions, - }); + const [activeLineTooltip, setActiveLineTooltip] = useState<{ + slice: SliceTooltipProps['slice']; + offsetLeft: number; + offsetTop: number; + highlightedSeriesId: string; + linkTo: string | undefined; + } | null>(null); + const [crosshairX, setCrosshairX] = useState(null); + + const hideTooltip = useCallback(() => { + setActiveLineTooltip(null); + setCrosshairX(null); + }, []); + + const debouncedHideTooltip = useDebouncedCallback(hideTooltip, 300); + + const handleTooltipMouseEnter = () => { + debouncedHideTooltip.cancel(); + }; + + const handleTooltipMouseLeave = debouncedHideTooltip; + + const handleSliceHover = useCallback( + (sliceData: SliceHoverData) => { + const slice: SliceTooltipProps['slice'] = { + id: String(sliceData.nearestSlice.xValue ?? ''), + x: sliceData.nearestSlice.x, + y: sliceData.mouseY, + x0: sliceData.nearestSlice.x, + y0: 0, + width: 0, + height: 0, + points: sliceData.nearestSlice.points, + }; + + const offsetLeft = sliceData.nearestSlice.x + LINE_CHART_MARGIN_LEFT; + const offsetTop = sliceData.mouseY + LINE_CHART_MARGIN_TOP; + + const seriesForLink = dataMap[String(sliceData.closestPoint.seriesId)]; + const linkTo = + seriesForLink?.data?.[sliceData.closestPoint.indexInSeries]?.to; + + debouncedHideTooltip.cancel(); + setCrosshairX(sliceData.sliceX); + setActiveLineTooltip({ + slice, + offsetLeft, + offsetTop, + highlightedSeriesId: String(sliceData.closestPoint.seriesId), + linkTo, + }); + }, + [dataMap, debouncedHideTooltip], + ); const axisBottomConfig = getLineChartAxisBottomConfig(xAxisLabel); const axisLeftConfig = getLineChartAxisLeftConfig(yAxisLabel, formatOptions); - const onPointClick = ( - point: Parameters[0], - ) => { - handleLineChartPointClick(point, dataMap); - }; - - const renderSliceTooltip = ( - props: Parameters[0], - ) => { - const tooltipData = createSliceTooltipData(props); - return ( - - ); - }; - - const renderPointTooltip = ( - point: Parameters[0], - ) => { - const tooltipData = createPointTooltipData(point); - if (!isDefined(tooltipData)) return null; - return ( - - ); - }; + const shouldShowLineChartTooltip = isDefined(activeLineTooltip); return ( debouncedHideTooltip()} > renderPointTooltip(point) - : undefined - } - onClick={(datum) => { - if ('seriesId' in datum) { - onPointClick( - datum as Parameters[0], - ); - } - }} + sliceTooltip={() => null} + tooltip={() => null} + layers={[ + 'grid', + 'markers', + 'axes', + 'areas', + 'lines', + (layerProps) => ( + debouncedHideTooltip()} + /> + ), + 'points', + 'legends', + ]} useMesh={true} crosshairType="cross" theme={chartTheme} /> + {shouldShowLineChartTooltip && ( + + )} ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairDashArray.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairDashArray.ts new file mode 100644 index 0000000000..06cd0a069b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairDashArray.ts @@ -0,0 +1 @@ +export const LINE_CHART_CROSSHAIR_DASH_ARRAY = '4 4'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairStrokeOpacity.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairStrokeOpacity.ts new file mode 100644 index 0000000000..865719cce1 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairStrokeOpacity.ts @@ -0,0 +1 @@ +export const LINE_CHART_CROSSHAIR_STROKE_OPACITY = 0.5; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairStrokeWidth.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairStrokeWidth.ts new file mode 100644 index 0000000000..d7370e2230 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairStrokeWidth.ts @@ -0,0 +1 @@ +export const LINE_CHART_CROSSHAIR_STROKE_WIDTH = 1; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionDamping.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionDamping.ts new file mode 100644 index 0000000000..38a94a54ad --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionDamping.ts @@ -0,0 +1 @@ +export const LINE_CHART_CROSSHAIR_TRANSITION_DAMPING = 20; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionStiffness.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionStiffness.ts new file mode 100644 index 0000000000..84e8933b75 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionStiffness.ts @@ -0,0 +1 @@ +export const LINE_CHART_CROSSHAIR_TRANSITION_STIFFNESS = 500; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginBottom.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginBottom.ts new file mode 100644 index 0000000000..7408227b79 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginBottom.ts @@ -0,0 +1 @@ +export const LINE_CHART_MARGIN_BOTTOM = 60; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginLeft.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginLeft.ts new file mode 100644 index 0000000000..4f29117c72 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginLeft.ts @@ -0,0 +1 @@ +export const LINE_CHART_MARGIN_LEFT = 70; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginRight.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginRight.ts new file mode 100644 index 0000000000..08a483c34e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginRight.ts @@ -0,0 +1 @@ +export const LINE_CHART_MARGIN_RIGHT = 20; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginTop.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginTop.ts new file mode 100644 index 0000000000..8ba792cf17 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginTop.ts @@ -0,0 +1 @@ +export const LINE_CHART_MARGIN_TOP = 20; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartTooltip.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartTooltip.ts deleted file mode 100644 index 5bd5208aed..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartTooltip.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { type LineChartEnrichedSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; -import { - formatGraphValue, - type GraphValueFormatOptions, -} from '@/page-layout/widgets/graph/utils/graphFormatters'; -import { - type LineSeries, - type Point, - type SliceTooltipProps, -} from '@nivo/line'; -import { isDefined } from 'twenty-shared/utils'; - -type UseLineChartTooltipProps = { - dataMap: Record; - enrichedSeries: LineChartEnrichedSeries[]; - formatOptions: GraphValueFormatOptions; -}; - -export const useLineChartTooltip = ({ - dataMap, - enrichedSeries, - formatOptions, -}: UseLineChartTooltipProps) => { - const enrichedSeriesMap = new Map( - enrichedSeries.map((series) => [series.id, series]), - ); - - const createSliceTooltipData = ({ slice }: SliceTooltipProps) => { - if (!isDefined(slice.points) || slice.points.length === 0) { - return { - items: [], - showClickHint: false, - indexLabel: undefined, - }; - } - - const tooltipItems = slice.points - .map((point) => { - const enrichedSeriesItem = enrichedSeriesMap.get( - String(point.seriesId), - ); - if (!enrichedSeriesItem) return null; - - const value = Number(point.data.y || 0); - return { - label: enrichedSeriesItem.label, - formattedValue: formatGraphValue(value, formatOptions), - value, - dotColor: enrichedSeriesItem.colorScheme.solid, - }; - }) - .filter(isDefined); - - const hasClickablePoint = slice.points.some((point) => { - const series = dataMap[point.seriesId]; - if (isDefined(series)) { - const dataPoint = series.data[point.indexInSeries]; - return isDefined(dataPoint?.to); - } - return false; - }); - - const xValue = slice.points[0]?.data?.x; - - return { - items: tooltipItems, - showClickHint: hasClickablePoint, - indexLabel: isDefined(xValue) ? String(xValue) : undefined, - }; - }; - - const createPointTooltipData = (point: Point) => { - const enrichedSeriesItem = enrichedSeriesMap.get(String(point.seriesId)); - if (!enrichedSeriesItem) return null; - - const series = dataMap[point.seriesId]; - const dataPoint = series?.data[point.indexInSeries]; - - const value = Number(point.data.y || 0); - return { - items: [ - { - label: enrichedSeriesItem.label, - formattedValue: formatGraphValue(value, formatOptions), - value, - dotColor: enrichedSeriesItem.colorScheme.solid, - }, - ], - showClickHint: isDefined(dataPoint?.to), - indexLabel: isDefined(point.data.x) ? String(point.data.x) : undefined, - }; - }; - - return { - createSliceTooltipData, - createPointTooltipData, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartTooltipData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartTooltipData.ts new file mode 100644 index 0000000000..6b86ace363 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartTooltipData.ts @@ -0,0 +1,61 @@ +import { type GraphWidgetTooltipItem } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip'; +import { type LineChartEnrichedSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries'; +import { + formatGraphValue, + type GraphValueFormatOptions, +} from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { type LineSeries, type SliceTooltipProps } from '@nivo/line'; +import { isDefined } from 'twenty-shared/utils'; + +type GetLineChartTooltipDataParameters = { + slice: SliceTooltipProps['slice']; + enrichedSeries: LineChartEnrichedSeries[]; + formatOptions: GraphValueFormatOptions; +}; + +type LineChartTooltipData = { + items: GraphWidgetTooltipItem[]; + indexLabel: string | undefined; +}; + +export const getLineChartTooltipData = ({ + slice, + enrichedSeries, + formatOptions, +}: GetLineChartTooltipDataParameters): LineChartTooltipData => { + const enrichedSeriesMap = new Map( + enrichedSeries.map((series) => [series.id, series]), + ); + + if (!isDefined(slice.points) || slice.points.length === 0) { + return { + items: [], + indexLabel: undefined, + }; + } + + const sortedPoints = [...slice.points].sort((a, b) => a.y - b.y); + + const tooltipItems = sortedPoints + .map((point) => { + const enrichedSeriesItem = enrichedSeriesMap.get(String(point.seriesId)); + if (!enrichedSeriesItem) return null; + + const value = Number(point.data.y || 0); + return { + key: enrichedSeriesItem.id, + label: enrichedSeriesItem.label, + formattedValue: formatGraphValue(value, formatOptions), + value, + dotColor: enrichedSeriesItem.colorScheme.solid, + }; + }) + .filter(isDefined); + + const xValue = slice.points[0]?.data?.x; + + return { + items: tooltipItems, + indexLabel: isDefined(xValue) ? String(xValue) : undefined, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx index fe17ccafcd..71423086f0 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx @@ -94,7 +94,7 @@ export const GraphWidgetPieChart = ({ return ( ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartTooltip.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartTooltip.ts index 9ffb0eacf4..11d40bb84d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartTooltip.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartTooltip.ts @@ -23,10 +23,14 @@ export const usePieChartTooltip = ({ const createTooltipData = ( datum: ComputedDatum<{ id: string; value: number; label?: string }>, ) => { - const item = enrichedData.find((d) => d.id === datum.id); + const item = enrichedData.find( + (enrichedDataItem) => enrichedDataItem.id === datum.id, + ); if (!isDefined(item)) return null; - const dataItem = data.find((d) => d.id === datum.id); + const dataItem = data.find( + (dataItemCandidate) => dataItemCandidate.id === datum.id, + ); const formattedValue = displayType === 'percentage' @@ -35,12 +39,13 @@ export const usePieChartTooltip = ({ return { tooltipItem: { + key: item.id, label: item.label || item.id, formattedValue, value: item.value, dotColor: item.colorScheme.solid, }, - showClickHint: isDefined(dataItem?.to), + linkTo: dataItem?.to, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetTooltipFloating.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetTooltipFloating.ts new file mode 100644 index 0000000000..246de2748d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetTooltipFloating.ts @@ -0,0 +1,48 @@ +import { GRAPH_TOOLTIP_BOUNDARY_PADDING_PX } from '@/page-layout/widgets/graph/constants/GraphTooltipBoundaryPaddingPx'; +import { GRAPH_TOOLTIP_OFFSET_PX } from '@/page-layout/widgets/graph/constants/GraphTooltipOffsetPx'; +import { createVirtualElementFromSVGElement } from '@/page-layout/widgets/graph/utils/createVirtualElementFromSVGElement'; +import { + autoUpdate, + flip, + offset, + shift, + useFloating, + type VirtualElement, +} from '@floating-ui/react'; +import { useMemo } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +export const useGraphWidgetTooltipFloating = ( + element: Element | VirtualElement | null, + boundaryElement?: Element | null, +) => { + const virtualElement = useMemo(() => { + if (!isDefined(element)) return null; + if (element instanceof Element) { + return createVirtualElementFromSVGElement(element); + } + return element; + }, [element]); + + return useFloating({ + elements: { + reference: virtualElement, + }, + placement: 'left', + strategy: 'fixed', + middleware: [ + offset(GRAPH_TOOLTIP_OFFSET_PX), + flip({ + fallbackPlacements: ['right', 'top', 'bottom'], + boundary: + boundaryElement ?? document.querySelector('#root') ?? undefined, + }), + shift({ + boundary: + boundaryElement ?? document.querySelector('#root') ?? undefined, + padding: GRAPH_TOOLTIP_BOUNDARY_PADDING_PX, + }), + ], + whileElementsMounted: autoUpdate, + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GraphWidgetTooltipContent.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GraphWidgetTooltipContent.ts new file mode 100644 index 0000000000..c31df04ad1 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GraphWidgetTooltipContent.ts @@ -0,0 +1,8 @@ +import { type GraphWidgetTooltipItem } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip'; + +export type GraphWidgetTooltipContent = { + items: GraphWidgetTooltipItem[]; + indexLabel?: string; + highlightedKey?: string; + linkTo?: string; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromChartCoordinates.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromChartCoordinates.ts new file mode 100644 index 0000000000..50c9c261eb --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromChartCoordinates.ts @@ -0,0 +1,15 @@ +import { type VirtualElement } from '@floating-ui/react'; + +type ChartCoordinates = { + left: number; + top: number; +}; + +export const createVirtualElementFromChartCoordinates = ( + chartCoordinates: ChartCoordinates, +): VirtualElement => { + const { left, top } = chartCoordinates; + return { + getBoundingClientRect: () => new DOMRect(left, top, 1, 1), + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromContainerOffset.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromContainerOffset.ts new file mode 100644 index 0000000000..92bf218f7c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromContainerOffset.ts @@ -0,0 +1,16 @@ +import { type VirtualElement } from '@floating-ui/react'; + +export const createVirtualElementFromContainerOffset = ( + container: Element, + offsetLeft: number, + offsetTop: number, +): VirtualElement => { + return { + getBoundingClientRect: () => { + const rect = container.getBoundingClientRect(); + const left = rect.left + offsetLeft; + const top = rect.top + offsetTop; + return new DOMRect(left, top, 1, 1); + }, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromSVGElement.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromSVGElement.ts new file mode 100644 index 0000000000..b8f20f9574 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromSVGElement.ts @@ -0,0 +1,23 @@ +import { type VirtualElement } from '@floating-ui/react'; +import { isDefined } from 'twenty-shared/utils'; + +export const createVirtualElementFromSVGElement = ( + svgElement: Element, +): VirtualElement | null => { + const svgContainer = svgElement.closest('svg'); + if (!isDefined(svgContainer)) { + return null; + } + + return { + getBoundingClientRect: () => { + const elementRect = svgElement.getBoundingClientRect(); + return new DOMRect( + elementRect.left, + elementRect.top, + elementRect.width, + 1, + ); + }, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getTooltipReferenceFromBarChartElementAnchor.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getTooltipReferenceFromBarChartElementAnchor.ts new file mode 100644 index 0000000000..f47ffe189f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getTooltipReferenceFromBarChartElementAnchor.ts @@ -0,0 +1,18 @@ +import { type VirtualElement } from '@floating-ui/react'; +import { isDefined } from 'twenty-shared/utils'; + +export const getTooltipReferenceFromBarChartElementAnchor = ( + anchorElement: Element, + containerId: string, +): { + reference: Element | VirtualElement; + boundary: Element; +} => { + const containerElement = document.getElementById(containerId); + + if (!isDefined(containerElement)) { + throw new Error(`Bar chart container not found: ${containerId}`); + } + + return { reference: anchorElement, boundary: containerElement }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getTooltipReferenceFromLineChartPointAnchor.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getTooltipReferenceFromLineChartPointAnchor.ts new file mode 100644 index 0000000000..e4a257d02f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getTooltipReferenceFromLineChartPointAnchor.ts @@ -0,0 +1,27 @@ +import { createVirtualElementFromContainerOffset } from '@/page-layout/widgets/graph/utils/createVirtualElementFromContainerOffset'; +import { type VirtualElement } from '@floating-ui/react'; +import { isDefined } from 'twenty-shared/utils'; + +export const getTooltipReferenceFromLineChartPointAnchor = ( + containerId: string, + offsetLeft: number, + offsetTop: number, +): { + reference: VirtualElement; + boundary: Element; +} => { + const containerElement = document.getElementById(containerId); + + if (!isDefined(containerElement)) { + throw new Error(`Chart container not found: ${containerId}`); + } + + return { + reference: createVirtualElementFromContainerOffset( + containerElement, + offsetLeft, + offsetTop, + ), + boundary: containerElement, + }; +};