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,
+ };
+};