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:
+1
-1
@@ -118,7 +118,7 @@ export const GraphWidget = ({
|
||||
yScale={data.yScale}
|
||||
curve={data.curve}
|
||||
stackedArea={data.stackedArea}
|
||||
enableSlices={data.enableSlices}
|
||||
enableSlices={'x'}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
+77
@@ -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>
|
||||
);
|
||||
};
|
||||
+59
-30
@@ -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>
|
||||
|
||||
+21
-11
@@ -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>
|
||||
),
|
||||
|
||||
+7
-4
@@ -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 ($)',
|
||||
|
||||
+106
-5
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const GRAPH_TOOLTIP_MAX_WIDTH_PX = 300;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const GRAPH_TOOLTIP_MIN_WIDTH_PX = 160;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const GRAPH_TOOLTIP_SCROLL_MAX_HEIGHT_PX = 120;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const GRAPH_TOOLTIP_BOUNDARY_PADDING_PX = 8;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const GRAPH_TOOLTIP_OFFSET_PX = 2;
|
||||
+27
-81
@@ -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>
|
||||
);
|
||||
|
||||
+91
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+72
-52
@@ -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) => {
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const BAR_CHART_LEGEND_ITEM_THRESHOLD = 10;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const BAR_CHART_TOOLTIP_SCROLLABLE_ITEM_THRESHOLD = 5;
|
||||
-35
@@ -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,
|
||||
};
|
||||
};
|
||||
-62
@@ -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 };
|
||||
};
|
||||
+77
@@ -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,
|
||||
};
|
||||
};
|
||||
+1
-1
@@ -123,7 +123,7 @@ export const GraphWidgetGaugeChart = ({
|
||||
return (
|
||||
<GraphWidgetTooltip
|
||||
items={[tooltipData.tooltipItem]}
|
||||
showClickHint={tooltipData.showClickHint}
|
||||
linkTo={tooltipData.linkTo}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+3
-2
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+146
@@ -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>
|
||||
);
|
||||
};
|
||||
+73
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+116
-61
@@ -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>
|
||||
);
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_CROSSHAIR_DASH_ARRAY = '4 4';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_CROSSHAIR_STROKE_OPACITY = 0.5;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_CROSSHAIR_STROKE_WIDTH = 1;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_CROSSHAIR_TRANSITION_DAMPING = 20;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_CROSSHAIR_TRANSITION_STIFFNESS = 500;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_MARGIN_BOTTOM = 60;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_MARGIN_LEFT = 70;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_MARGIN_RIGHT = 20;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_MARGIN_TOP = 20;
|
||||
-99
@@ -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,
|
||||
};
|
||||
};
|
||||
+61
@@ -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,
|
||||
};
|
||||
};
|
||||
+1
-1
@@ -94,7 +94,7 @@ export const GraphWidgetPieChart = ({
|
||||
return (
|
||||
<GraphWidgetTooltip
|
||||
items={[tooltipData.tooltipItem]}
|
||||
showClickHint={tooltipData.showClickHint}
|
||||
linkTo={tooltipData.linkTo}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+8
-3
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+48
@@ -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,
|
||||
});
|
||||
};
|
||||
+8
@@ -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;
|
||||
};
|
||||
+15
@@ -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),
|
||||
};
|
||||
};
|
||||
+16
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
+23
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
+18
@@ -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 };
|
||||
};
|
||||
+27
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user