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 <bordeau.lucas@gmail.com>
This commit is contained in:
nitin
2025-11-11 21:17:41 +05:30
committed by GitHub
parent 652c8673e0
commit 561297436e
44 changed files with 1118 additions and 448 deletions
@@ -118,7 +118,7 @@ export const GraphWidget = ({
yScale={data.yScale}
curve={data.curve}
stackedArea={data.stackedArea}
enableSlices={data.enableSlices}
enableSlices={'x'}
/>
</Suspense>
);
@@ -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 (
<FloatingPortal root={boundary}>
<div
ref={refs.setFloating}
style={{ ...floatingStyles, zIndex: theme.lastLayerZIndex }}
role="tooltip"
aria-live="polite"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<AnimatePresence>
<motion.div
initial={{
opacity: 0,
scale: 0.95,
}}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
}}
>
<GraphWidgetTooltip
items={items}
indexLabel={indexLabel}
highlightedKey={highlightedKey}
linkTo={linkTo}
/>
</motion.div>
</AnimatePresence>
</div>
</FloatingPortal>
);
};
@@ -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 (
<StyledTooltip>
<StyledHorizontalSectionPadding $addTop $addBottom={!showClickHint}>
<StyledHorizontalSectionPadding addTop addBottom={!hasLink}>
<StyledTooltipContent>
{indexLabel && (
<StyledTooltipHeader>{indexLabel}</StyledTooltipHeader>
)}
<StyledTooltipRowContainer>
{filteredItems.map((item, index) => (
<StyledTooltipRow key={index}>
<StyledDot $color={item.dotColor} />
<StyledTooltipRowRightContent>
<StyledTooltipLabel>{item.label}</StyledTooltipLabel>
<StyledTooltipValue>{item.formattedValue}</StyledTooltipValue>
</StyledTooltipRowRightContent>
</StyledTooltipRow>
))}
{filteredItems.map((item) => {
const isHighlighted =
shouldHighlight && highlightedKey === item.key;
return (
<StyledTooltipRow key={item.key}>
<StyledDot color={item.dotColor} />
<StyledTooltipRowRightContent>
<StyledTooltipLabel isHighlighted={isHighlighted}>
{item.label}
</StyledTooltipLabel>
<StyledTooltipValue isHighlighted={isHighlighted}>
{item.formattedValue}
</StyledTooltipValue>
</StyledTooltipRowRightContent>
</StyledTooltipRow>
);
})}
</StyledTooltipRowContainer>
</StyledTooltipContent>
</StyledHorizontalSectionPadding>
{showClickHint && (
{hasLink && (
<>
<StyledTooltipSeparator />
<StyledHorizontalSectionPadding $addBottom>
<StyledTooltipLink>
<StyledHorizontalSectionPadding addBottom>
<StyledTooltipLink
onClick={() => {
window.location.href = String(linkTo);
}}
>
<span>{t`Click to see data`}</span>
<IconArrowUpRight size={theme.icon.size.sm} />
</StyledTooltipLink>
@@ -121,6 +121,7 @@ export const Default: Story = {
xAxisLabel: 'Month',
yAxisLabel: 'Count',
id: 'bar-chart-default',
groupMode: 'stacked',
},
render: (args) => (
<Container>
@@ -133,6 +134,7 @@ export const Default: Story = {
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
id={args.id}
groupMode={args.groupMode}
/>
</Container>
),
@@ -184,6 +186,7 @@ export const Revenue: Story = {
xAxisLabel: 'Quarter',
yAxisLabel: 'Amount ($)',
id: 'bar-chart-revenue',
groupMode: 'stacked',
},
render: (args) => (
<Container>
@@ -196,12 +199,13 @@ export const Revenue: Story = {
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
id={args.id}
groupMode={args.groupMode}
/>
</Container>
),
};
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) => (
<Container>
@@ -345,6 +350,7 @@ export const WithValues: Story = {
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
id={args.id}
groupMode={args.groupMode}
/>
</Container>
),
@@ -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}
/>
</Container>
),
@@ -477,6 +485,7 @@ export const Currency: Story = {
xAxisLabel: 'Region',
yAxisLabel: 'Amount',
id: 'bar-chart-currency',
groupMode: 'grouped',
},
render: (args) => (
<Container>
@@ -489,6 +498,7 @@ export const Currency: Story = {
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
id={args.id}
groupMode={args.groupMode}
/>
</Container>
),
@@ -580,6 +590,7 @@ export const MixedPositiveNegative: Story = {
xAxisLabel: 'Month',
yAxisLabel: 'Amount ($)',
id: 'bar-chart-mixed-values',
groupMode: 'grouped',
},
render: (args) => (
<Container>
@@ -594,6 +605,7 @@ export const MixedPositiveNegative: Story = {
showGrid={args.showGrid}
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
groupMode={args.groupMode}
id={args.id}
/>
</Container>
@@ -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) => (
<Container>
@@ -793,7 +804,6 @@ export const GroupedWithAllBarsTooltip: Story = {
yAxisLabel={args.yAxisLabel}
groupMode={args.groupMode}
id={args.id}
enableGroupTooltip={args.enableGroupTooltip}
/>
</Container>
),
@@ -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<typeof GraphWidgetLineChart> = {
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 ($)',
@@ -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',
},
};
@@ -0,0 +1 @@
export const GRAPH_TOOLTIP_MAX_WIDTH_PX = 300;
@@ -0,0 +1 @@
export const GRAPH_TOOLTIP_MIN_WIDTH_PX = 160;
@@ -0,0 +1 @@
export const GRAPH_TOOLTIP_SCROLL_MAX_HEIGHT_PX = 120;
@@ -0,0 +1 @@
export const GRAPH_TOOLTIP_BOUNDARY_PADDING_PX = 8;
@@ -0,0 +1 @@
export const GRAPH_TOOLTIP_OFFSET_PX = 2;
@@ -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 = <D extends BarDatum>({
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 = <D extends BarDatum>({
layout = 'vertical',
chartId,
}: CustomBarItemProps<D>) => {
const theme = useTheme();
const { showTooltipFromEvent, showTooltipAt, hideTooltip } = useTooltip();
const renderTooltip = useMemo(
() => () => createElement(tooltip, { ...bar, ...barData }),
[tooltip, bar, barData],
);
const handleClick = useCallback(
(event: MouseEvent<SVGRectElement>) => {
onClick?.({ color: bar.color, ...barData }, event);
},
[bar, barData, onClick],
);
const handleTooltip = useCallback(
(event: MouseEvent<SVGRectElement>) =>
showTooltipFromEvent(renderTooltip(), event),
[showTooltipFromEvent, renderTooltip],
);
const handleMouseEnter = useCallback(
(event: MouseEvent<SVGRectElement>) => {
onMouseEnter?.(barData, event);
showTooltipFromEvent(renderTooltip(), event);
},
[barData, onMouseEnter, showTooltipFromEvent, renderTooltip],
[barData, onMouseEnter],
);
const handleMouseLeave = useCallback(
(event: MouseEvent<SVGRectElement>) => {
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 = <D extends BarDatum>({
}
const dataPoint = chartData.find(
(data) => data[indexBy] === barData.indexValue,
(chartDataItem) => chartDataItem[indexBy] === barData.indexValue,
);
if (!isDefined(dataPoint)) {
@@ -192,21 +157,23 @@ export const CustomBarItem = <D extends BarDatum>({
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 = <D extends BarDatum>({
<animated.rect
x={clipPathX}
y={clipPathY}
rx={clipRx}
ry={clipRy}
rx={clipBorderRadiusX}
ry={clipBorderRadiusY}
width={clipRectWidth}
height={clipRectHeight}
/>
@@ -245,31 +212,10 @@ export const CustomBarItem = <D extends BarDatum>({
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 && (
<Text
x={labelX}
y={labelY}
textAnchor={textAnchor}
dominantBaseline="central"
fillOpacity={labelOpacity}
style={{
...theme.labels.text,
// We don't want the label to intercept mouse events
pointerEvents: 'none',
fill: labelColor,
}}
>
{label}
</Text>
)}
</animated.g>
</animated.g>
);
@@ -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<BarDatum>;
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 (
<GraphWidgetFloatingTooltip
reference={reference}
boundary={boundary}
items={tooltipData.tooltipItems}
indexLabel={tooltipData.indexLabel}
highlightedKey={tooltipData.hoveredKey}
linkTo={tooltipData.linkTo}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
);
};
@@ -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<string, string>;
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<number>(0);
const [chartHeight, setChartHeight] = useState<number>(0);
const containerRef = useRef<HTMLDivElement>(null);
const shouldEnableGroupTooltip =
enableGroupTooltip ?? groupMode === 'stacked';
const [activeBarTooltip, setActiveBarTooltip] = useState<{
datum: ComputedDatum<BarChartDataItem>;
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<BarChartDataItem>,
event: MouseEvent<SVGRectElement>,
) => {
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<typeof getTooltipData>[0]) => {
const tooltipData = getTooltipData(datum);
if (!isDefined(tooltipData)) return null;
return (
<GraphWidgetTooltip
items={tooltipData.tooltipItems}
showClickHint={tooltipData.showClickHint}
indexLabel={tooltipData.indexLabel}
/>
);
};
const BarItemWithContext = useMemo(
() => (props: any) => (
() => (props: BarItemProps<BarChartDataItem>) => (
<CustomBarItem
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
@@ -213,6 +221,8 @@ export const GraphWidgetBarChart = ({
const margins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout });
const shouldShowBarChartTooltip = isDefined(activeBarTooltip);
return (
<StyledContainer id={id}>
<GraphWidgetChartContainer
@@ -257,29 +267,39 @@ export const GraphWidgetBarChart = ({
enableLabel={false}
labelSkipWidth={12}
innerPadding={
groupMode !== 'stacked' ? BAR_CHART_MINIMUM_INNER_PADDING : 0
groupMode === 'grouped' ? BAR_CHART_MINIMUM_INNER_PADDING : 0
}
labelSkipHeight={12}
valueFormat={(value) =>
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)}
/>
</GraphWidgetChartContainer>
{shouldShowBarChartTooltip && (
<GraphBarChartTooltip
datum={activeBarTooltip.datum}
anchorElement={activeBarTooltip.anchorElement}
containerId={id}
enrichedKeys={enrichedKeys}
data={data}
indexBy={indexBy}
formatOptions={formatOptions}
enableGroupTooltip={groupMode === 'stacked'}
layout={layout}
onMouseEnter={handleTooltipMouseEnter}
onMouseLeave={handleTooltipMouseLeave}
/>
)}
<GraphWidgetLegend
show={shouldShowLegend}
items={enrichedKeys.map((item) => {
@@ -0,0 +1 @@
export const BAR_CHART_LEGEND_ITEM_THRESHOLD = 10;
@@ -0,0 +1 @@
export const BAR_CHART_TOOLTIP_SCROLLABLE_ITEM_THRESHOLD = 5;
@@ -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<BarDatum>) => {
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,
};
};
@@ -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<BarDatum>) => {
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 };
};
@@ -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<BarDatum>;
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,
};
};
@@ -123,7 +123,7 @@ export const GraphWidgetGaugeChart = ({
return (
<GraphWidgetTooltip
items={[tooltipData.tooltipItem]}
showClickHint={tooltipData.showClickHint}
linkTo={tooltipData.linkTo}
/>
);
};
@@ -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,
};
};
@@ -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<LineSeries>[];
x: number;
};
closestPoint: Point<LineSeries>;
svgRect: DOMRect;
};
type CustomCrosshairLayerProps = {
points: readonly Point<LineSeries>[];
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<string, Point<LineSeries>[]>();
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<SVGRectElement>) => {
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 (
<g>
{isDefined(crosshairX) && (
<motion.line
x1={crosshairX}
x2={crosshairX}
y1={0}
y2={innerHeight}
stroke={theme.font.color.primary}
strokeWidth={LINE_CHART_CROSSHAIR_STROKE_WIDTH}
strokeOpacity={LINE_CHART_CROSSHAIR_STROKE_OPACITY}
strokeDasharray={LINE_CHART_CROSSHAIR_DASH_ARRAY}
initial={{ x1: crosshairX, x2: crosshairX, opacity: 0 }}
animate={{ x1: crosshairX, x2: crosshairX, opacity: 0.5 }}
exit={{ opacity: 0 }}
transition={transition}
pointerEvents="none"
/>
)}
<rect
x={0}
y={0}
width={innerWidth}
height={innerHeight}
fill="transparent"
style={{ cursor: 'pointer' }}
onMouseEnter={handleMouseMove}
onMouseMove={handleMouseMove}
onMouseLeave={(event) => onRectLeave(event.relatedTarget)}
/>
</g>
);
};
@@ -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<LineSeries>['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 (
<GraphWidgetFloatingTooltip
reference={reference}
boundary={boundary}
items={tooltipData.items}
indexLabel={tooltipData.indexLabel}
highlightedKey={highlightedSeriesId}
linkTo={linkTo}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
);
};
@@ -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<LineSeries>['slice'];
offsetLeft: number;
offsetTop: number;
highlightedSeriesId: string;
linkTo: string | undefined;
} | null>(null);
const [crosshairX, setCrosshairX] = useState<number | null>(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<LineSeries>['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<typeof handleLineChartPointClick>[0],
) => {
handleLineChartPointClick(point, dataMap);
};
const renderSliceTooltip = (
props: Parameters<typeof createSliceTooltipData>[0],
) => {
const tooltipData = createSliceTooltipData(props);
return (
<GraphWidgetTooltip
items={tooltipData.items}
showClickHint={tooltipData.showClickHint}
indexLabel={tooltipData.indexLabel}
/>
);
};
const renderPointTooltip = (
point: Parameters<typeof createPointTooltipData>[0],
) => {
const tooltipData = createPointTooltipData(point);
if (!isDefined(tooltipData)) return null;
return (
<GraphWidgetTooltip
items={tooltipData.items}
showClickHint={tooltipData.showClickHint}
indexLabel={tooltipData.indexLabel}
/>
);
};
const shouldShowLineChartTooltip = isDefined(activeLineTooltip);
return (
<StyledContainer id={id}>
<GraphWidgetChartContainer
$isClickable={hasClickableItems}
$cursorSelector="svg g circle"
onMouseLeave={() => debouncedHideTooltip()}
>
<ResponsiveLine
data={nivoData}
margin={{ top: 20, right: 20, bottom: 60, left: 70 }}
margin={{
top: LINE_CHART_MARGIN_TOP,
right: LINE_CHART_MARGIN_RIGHT,
bottom: LINE_CHART_MARGIN_BOTTOM,
left: LINE_CHART_MARGIN_LEFT,
}}
xScale={xScale}
yScale={getYScaleWithStacking(yScale, stackedArea)}
curve={curve}
@@ -180,9 +213,8 @@ export const GraphWidgetLineChart = ({
enablePoints={enablePoints}
pointSize={6}
pointBorderWidth={0}
areaOpacity={theme.name === 'dark' ? 0.8 : 1}
colors={colors}
areaBlendMode={theme.name === 'dark' ? 'screen' : 'multiply'}
areaBlendMode={'normal'}
defs={defs}
fill={fill}
axisTop={null}
@@ -192,24 +224,47 @@ export const GraphWidgetLineChart = ({
enableGridX={showGrid}
enableGridY={showGrid}
enableSlices={enableSlices}
sliceTooltip={enableSlices === 'x' ? renderSliceTooltip : undefined}
tooltip={
enableSlices === false
? ({ point }) => renderPointTooltip(point)
: undefined
}
onClick={(datum) => {
if ('seriesId' in datum) {
onPointClick(
datum as Parameters<typeof handleLineChartPointClick>[0],
);
}
}}
sliceTooltip={() => null}
tooltip={() => null}
layers={[
'grid',
'markers',
'axes',
'areas',
'lines',
(layerProps) => (
<CustomCrosshairLayer
key="custom-crosshair-layer"
points={layerProps.points}
innerHeight={layerProps.innerHeight}
innerWidth={layerProps.innerWidth}
onSliceHover={handleSliceHover}
crosshairX={crosshairX}
onRectLeave={() => debouncedHideTooltip()}
/>
),
'points',
'legends',
]}
useMesh={true}
crosshairType="cross"
theme={chartTheme}
/>
</GraphWidgetChartContainer>
{shouldShowLineChartTooltip && (
<GraphLineChartTooltip
slice={activeLineTooltip.slice}
offsetLeft={activeLineTooltip.offsetLeft}
offsetTop={activeLineTooltip.offsetTop}
containerId={id}
enrichedSeries={enrichedSeries}
formatOptions={formatOptions}
highlightedSeriesId={activeLineTooltip.highlightedSeriesId}
linkTo={activeLineTooltip.linkTo}
onMouseEnter={handleTooltipMouseEnter}
onMouseLeave={handleTooltipMouseLeave}
/>
)}
<GraphWidgetLegend show={showLegend} items={legendItems} />
</StyledContainer>
);
@@ -0,0 +1 @@
export const LINE_CHART_CROSSHAIR_DASH_ARRAY = '4 4';
@@ -0,0 +1 @@
export const LINE_CHART_CROSSHAIR_STROKE_OPACITY = 0.5;
@@ -0,0 +1 @@
export const LINE_CHART_CROSSHAIR_STROKE_WIDTH = 1;
@@ -0,0 +1 @@
export const LINE_CHART_CROSSHAIR_TRANSITION_DAMPING = 20;
@@ -0,0 +1 @@
export const LINE_CHART_CROSSHAIR_TRANSITION_STIFFNESS = 500;
@@ -0,0 +1 @@
export const LINE_CHART_MARGIN_BOTTOM = 60;
@@ -0,0 +1 @@
export const LINE_CHART_MARGIN_LEFT = 70;
@@ -0,0 +1 @@
export const LINE_CHART_MARGIN_RIGHT = 20;
@@ -0,0 +1 @@
export const LINE_CHART_MARGIN_TOP = 20;
@@ -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<string, LineChartSeries>;
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<LineSeries>) => {
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<LineSeries>) => {
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,
};
};
@@ -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<LineSeries>['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,
};
};
@@ -94,7 +94,7 @@ export const GraphWidgetPieChart = ({
return (
<GraphWidgetTooltip
items={[tooltipData.tooltipItem]}
showClickHint={tooltipData.showClickHint}
linkTo={tooltipData.linkTo}
/>
);
};
@@ -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,
};
};
@@ -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,
});
};
@@ -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;
};
@@ -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),
};
};
@@ -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);
},
};
};
@@ -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,
);
},
};
};
@@ -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 };
};
@@ -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,
};
};