[Dashboards] add No data layer on charts (#16397)
video qa https://github.com/user-attachments/assets/01c790de-eae7-406e-9308-af036f53ab2e
This commit is contained in:
+34
@@ -0,0 +1,34 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
type NoDataLayerProps = {
|
||||
innerWidth: number;
|
||||
innerHeight: number;
|
||||
hasNoData: boolean;
|
||||
};
|
||||
|
||||
export const NoDataLayer = ({
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
hasNoData,
|
||||
}: NoDataLayerProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useLingui();
|
||||
|
||||
if (!hasNoData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<text
|
||||
x={innerWidth / 2}
|
||||
y={innerHeight / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={theme.font.color.tertiary}
|
||||
fontSize={theme.font.size.md}
|
||||
>
|
||||
{t`No data`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
+2
@@ -3,12 +3,14 @@ import { type Meta, type StoryObj } from '@storybook/react';
|
||||
import { GraphWidgetTestWrapper } from '@/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper';
|
||||
import { GraphWidgetBarChart } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart';
|
||||
import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
|
||||
|
||||
const meta: Meta<typeof GraphWidgetBarChart> = {
|
||||
title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart',
|
||||
component: GraphWidgetBarChart,
|
||||
decorators: [
|
||||
I18nFrontDecorator,
|
||||
(Story) => (
|
||||
<GraphWidgetTestWrapper>
|
||||
<Story />
|
||||
|
||||
+2
@@ -3,12 +3,14 @@ import { type ComponentProps } from 'react';
|
||||
|
||||
import { GraphWidgetTestWrapper } from '@/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper';
|
||||
import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
|
||||
|
||||
const meta: Meta<typeof GraphWidgetLineChart> = {
|
||||
title: 'Modules/PageLayout/Widgets/GraphWidgetLineChart',
|
||||
component: GraphWidgetLineChart,
|
||||
decorators: [
|
||||
I18nFrontDecorator,
|
||||
(Story) => (
|
||||
<GraphWidgetTestWrapper>
|
||||
<Story />
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export const NICE_STEP_MULTIPLIERS = {
|
||||
LARGE: 10,
|
||||
MEDIUM: 5,
|
||||
SMALL: 2,
|
||||
DEFAULT: 1,
|
||||
} as const;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export const NICE_STEP_NORMALIZED_VALUE_THRESHOLDS = {
|
||||
LARGE: 5,
|
||||
MEDIUM: 2,
|
||||
SMALL: 1,
|
||||
} as const;
|
||||
+85
-25
@@ -1,5 +1,6 @@
|
||||
import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer';
|
||||
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
|
||||
import { NoDataLayer } from '@/page-layout/widgets/graph/components/NoDataLayer';
|
||||
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';
|
||||
@@ -14,6 +15,9 @@ import { getBarChartAxisConfigs } from '@/page-layout/widgets/graph/graphWidgetB
|
||||
import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor';
|
||||
import { getBarChartInnerPadding } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartInnerPadding';
|
||||
import { getBarChartMargins } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins';
|
||||
import { getBarChartTickConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig';
|
||||
import { computeEffectiveValueRange } from '@/page-layout/widgets/graph/utils/computeEffectiveValueRange';
|
||||
import { computeValueTickValues } from '@/page-layout/widgets/graph/utils/computeValueTickValues';
|
||||
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
|
||||
import {
|
||||
formatGraphValue,
|
||||
@@ -25,6 +29,7 @@ import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
ResponsiveBar,
|
||||
type BarCustomLayerProps,
|
||||
type BarDatum,
|
||||
type BarItemProps,
|
||||
type ComputedBarDatum,
|
||||
@@ -37,6 +42,8 @@ import { useDebouncedCallback } from 'use-debounce';
|
||||
import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState';
|
||||
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
|
||||
|
||||
type NoDataLayerWrapperProps = BarCustomLayerProps<BarDatum>;
|
||||
|
||||
type GraphWidgetBarChartProps = {
|
||||
data: BarDatum[];
|
||||
indexBy: string;
|
||||
@@ -120,6 +127,38 @@ export const GraphWidgetBarChart = ({
|
||||
seriesLabels,
|
||||
});
|
||||
|
||||
const calculatedValueRange =
|
||||
groupMode === 'stacked'
|
||||
? calculateStackedBarChartValueRange(data, keys)
|
||||
: calculateValueRangeFromBarChartKeys(data, keys);
|
||||
|
||||
const { effectiveMinimumValue, effectiveMaximumValue, hasNoData } =
|
||||
computeEffectiveValueRange({
|
||||
calculatedMinimum: calculatedValueRange.minimum,
|
||||
calculatedMaximum: calculatedValueRange.maximum,
|
||||
rangeMin,
|
||||
rangeMax,
|
||||
dataLength: data.length,
|
||||
});
|
||||
|
||||
const tickConfig = getBarChartTickConfig({
|
||||
width: chartWidth,
|
||||
height: chartHeight,
|
||||
data,
|
||||
indexBy,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
axisFontSize: chartTheme.axis.ticks.text.fontSize,
|
||||
layout,
|
||||
});
|
||||
|
||||
const { tickValues: valueTickValues, domain: valueDomain } =
|
||||
computeValueTickValues({
|
||||
minimum: effectiveMinimumValue,
|
||||
maximum: effectiveMaximumValue,
|
||||
tickCount: tickConfig.numberOfValueTicks,
|
||||
});
|
||||
|
||||
const hasClickableItems = isDefined(onBarClick);
|
||||
|
||||
const hideTooltip = () => setActiveBarTooltip(null);
|
||||
@@ -157,6 +196,8 @@ export const GraphWidgetBarChart = ({
|
||||
yAxisLabel,
|
||||
formatOptions,
|
||||
axisFontSize: chartTheme.axis.ticks.text.fontSize,
|
||||
valueTickValues,
|
||||
tickConfig,
|
||||
});
|
||||
|
||||
const BarItemWithContext = useMemo(
|
||||
@@ -179,25 +220,32 @@ export const GraphWidgetBarChart = ({
|
||||
bars,
|
||||
}: {
|
||||
bars: readonly ComputedBarDatum<BarDatum>[];
|
||||
}) => (
|
||||
<CustomTotalsLayer
|
||||
bars={bars}
|
||||
formatValue={(value) => formatGraphValue(value, formatOptions)}
|
||||
offset={theme.spacingMultiplicator * 2}
|
||||
layout={layout}
|
||||
groupMode={groupMode}
|
||||
omitNullValues={omitNullValues}
|
||||
showValues={showValues}
|
||||
}) => {
|
||||
if (hasNoData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomTotalsLayer
|
||||
bars={bars}
|
||||
formatValue={(value) => formatGraphValue(value, formatOptions)}
|
||||
offset={theme.spacingMultiplicator * 2}
|
||||
layout={layout}
|
||||
groupMode={groupMode}
|
||||
omitNullValues={omitNullValues}
|
||||
showValues={showValues}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const NoDataLayerWrapper = (layerProps: NoDataLayerWrapperProps) => (
|
||||
<NoDataLayer
|
||||
innerWidth={layerProps.innerWidth}
|
||||
innerHeight={layerProps.innerHeight}
|
||||
hasNoData={hasNoData}
|
||||
/>
|
||||
);
|
||||
|
||||
const calculatedValueRange =
|
||||
groupMode === 'stacked'
|
||||
? calculateStackedBarChartValueRange(data, keys)
|
||||
: calculateValueRangeFromBarChartKeys(data, keys);
|
||||
const effectiveMinimumValue = rangeMin ?? calculatedValueRange.minimum;
|
||||
const effectiveMaximumValue = rangeMax ?? calculatedValueRange.maximum;
|
||||
|
||||
const hasNegativeValues = calculatedValueRange.minimum < 0;
|
||||
const zeroMarker = hasNegativeValues
|
||||
? [
|
||||
@@ -239,13 +287,21 @@ export const GraphWidgetBarChart = ({
|
||||
layout={layout}
|
||||
valueScale={{
|
||||
type: 'linear',
|
||||
min: effectiveMinimumValue,
|
||||
max: effectiveMaximumValue,
|
||||
min: valueDomain.min,
|
||||
max: valueDomain.max,
|
||||
clamp: true,
|
||||
}}
|
||||
indexScale={{ type: 'band', round: true }}
|
||||
colors={(datum) => getBarChartColor(datum, barConfigs, theme)}
|
||||
layers={['grid', 'markers', 'axes', 'bars', 'legends', TotalsLayer]}
|
||||
layers={[
|
||||
'grid',
|
||||
'markers',
|
||||
'axes',
|
||||
'bars',
|
||||
'legends',
|
||||
TotalsLayer,
|
||||
NoDataLayerWrapper,
|
||||
]}
|
||||
markers={zeroMarker}
|
||||
axisTop={null}
|
||||
axisRight={null}
|
||||
@@ -253,8 +309,12 @@ export const GraphWidgetBarChart = ({
|
||||
axisLeft={axisLeftConfig}
|
||||
enableGridX={layout === BarChartLayout.HORIZONTAL && showGrid}
|
||||
enableGridY={layout === BarChartLayout.VERTICAL && showGrid}
|
||||
gridXValues={layout === BarChartLayout.HORIZONTAL ? 5 : undefined}
|
||||
gridYValues={layout === BarChartLayout.VERTICAL ? 5 : undefined}
|
||||
gridXValues={
|
||||
layout === BarChartLayout.HORIZONTAL ? valueTickValues : undefined
|
||||
}
|
||||
gridYValues={
|
||||
layout === BarChartLayout.VERTICAL ? valueTickValues : undefined
|
||||
}
|
||||
enableLabel={false}
|
||||
labelSkipWidth={12}
|
||||
innerPadding={getBarChartInnerPadding({
|
||||
@@ -275,9 +335,9 @@ export const GraphWidgetBarChart = ({
|
||||
formatGraphValue(Number(barDatumCandidate.value), formatOptions)
|
||||
}
|
||||
tooltip={() => null}
|
||||
onMouseEnter={handleBarEnter}
|
||||
onMouseLeave={handleBarLeave}
|
||||
onClick={onBarClick}
|
||||
onMouseEnter={hasNoData ? undefined : handleBarEnter}
|
||||
onMouseLeave={hasNoData ? undefined : handleBarLeave}
|
||||
onClick={hasNoData ? undefined : onBarClick}
|
||||
theme={chartTheme}
|
||||
borderRadius={parseInt(theme.border.radius.sm)}
|
||||
/>
|
||||
@@ -294,7 +354,7 @@ export const GraphWidgetBarChart = ({
|
||||
onMouseLeave={handleTooltipMouseLeave}
|
||||
/>
|
||||
<GraphWidgetLegend
|
||||
show={showLegend}
|
||||
show={showLegend && !hasNoData}
|
||||
items={enrichedKeys.map((item) => {
|
||||
return {
|
||||
id: item.key,
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const BAR_CHART_MAXIMUM_VALUE_TICK_COUNT = 6;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const BAR_CHART_MINIMUM_VALUE_TICK_COUNT = 2;
|
||||
+6
@@ -5,6 +5,12 @@ export const useBarChartTheme = () => {
|
||||
|
||||
return {
|
||||
axis: {
|
||||
domain: {
|
||||
line: {
|
||||
stroke: theme.border.color.light,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
line: {
|
||||
stroke: theme.border.color.light,
|
||||
|
||||
+26
-13
@@ -1,6 +1,9 @@
|
||||
import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout';
|
||||
import { getBarChartMargins } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins';
|
||||
import { getBarChartTickConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig';
|
||||
import {
|
||||
type BarChartTickConfig,
|
||||
getBarChartTickConfig,
|
||||
} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig';
|
||||
import { truncateTickLabel } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/truncateTickLabel';
|
||||
import {
|
||||
formatGraphValue,
|
||||
@@ -29,6 +32,8 @@ type GetBarChartAxisConfigsProps = {
|
||||
yAxisLabel?: string;
|
||||
formatOptions?: GraphValueFormatOptions;
|
||||
axisFontSize?: number;
|
||||
valueTickValues?: number[];
|
||||
tickConfig?: BarChartTickConfig;
|
||||
};
|
||||
|
||||
export const getBarChartAxisConfigs = ({
|
||||
@@ -41,24 +46,32 @@ export const getBarChartAxisConfigs = ({
|
||||
yAxisLabel,
|
||||
formatOptions,
|
||||
axisFontSize = 11,
|
||||
valueTickValues,
|
||||
tickConfig,
|
||||
}: GetBarChartAxisConfigsProps) => {
|
||||
const {
|
||||
categoryTickValues,
|
||||
numberOfValueTicks,
|
||||
maxBottomAxisTickLabelLength,
|
||||
maxLeftAxisTickLabelLength,
|
||||
} = getBarChartTickConfig({
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
indexBy,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
axisFontSize,
|
||||
layout,
|
||||
});
|
||||
} =
|
||||
tickConfig ??
|
||||
getBarChartTickConfig({
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
indexBy,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
axisFontSize,
|
||||
layout,
|
||||
});
|
||||
|
||||
const margins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout });
|
||||
const resolvedValueTickValues =
|
||||
valueTickValues && valueTickValues.length > 0
|
||||
? valueTickValues
|
||||
: numberOfValueTicks;
|
||||
|
||||
if (layout === BarChartLayout.VERTICAL) {
|
||||
return {
|
||||
@@ -72,7 +85,7 @@ export const getBarChartAxisConfigs = ({
|
||||
},
|
||||
axisLeft: {
|
||||
...COMMON_AXIS_CONFIG,
|
||||
tickValues: numberOfValueTicks,
|
||||
tickValues: resolvedValueTickValues,
|
||||
legend: yAxisLabel,
|
||||
legendOffset: -margins.left + LEFT_AXIS_LEGEND_OFFSET_PADDING,
|
||||
format: (value: number) =>
|
||||
@@ -87,7 +100,7 @@ export const getBarChartAxisConfigs = ({
|
||||
return {
|
||||
axisBottom: {
|
||||
...COMMON_AXIS_CONFIG,
|
||||
tickValues: numberOfValueTicks,
|
||||
tickValues: resolvedValueTickValues,
|
||||
legend: yAxisLabel,
|
||||
legendOffset: BOTTOM_AXIS_LEGEND_OFFSET,
|
||||
format: (value: number) =>
|
||||
|
||||
+16
-6
@@ -1,3 +1,5 @@
|
||||
import { BAR_CHART_MAXIMUM_VALUE_TICK_COUNT } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMaximumValueTickCount';
|
||||
import { BAR_CHART_MINIMUM_VALUE_TICK_COUNT } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMinimumValueTickCount';
|
||||
import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout';
|
||||
import { calculateMaxTickLabelLength } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateMaxTickLabelLength';
|
||||
import { calculateWidthPerTick } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateWidthPerTick';
|
||||
@@ -34,6 +36,12 @@ export const getBarChartTickConfig = ({
|
||||
axisFontSize: number;
|
||||
layout: BarChartLayout;
|
||||
}): BarChartTickConfig => {
|
||||
const clampValueTickCount = (tickCount: number) =>
|
||||
Math.min(
|
||||
BAR_CHART_MAXIMUM_VALUE_TICK_COUNT,
|
||||
Math.max(BAR_CHART_MINIMUM_VALUE_TICK_COUNT, tickCount),
|
||||
);
|
||||
|
||||
const categoryTickValues = computeBarChartCategoryTickValues({
|
||||
axisSize: layout === BarChartLayout.VERTICAL ? width : height,
|
||||
axisFontSize,
|
||||
@@ -49,12 +57,14 @@ export const getBarChartTickConfig = ({
|
||||
const availableWidth = width - (margins.left + margins.right);
|
||||
const availableHeight = height - (margins.top + margins.bottom);
|
||||
|
||||
const numberOfValueTicks = computeBarChartValueTickCount({
|
||||
axisSize:
|
||||
layout === BarChartLayout.VERTICAL ? availableHeight : availableWidth,
|
||||
axisFontSize,
|
||||
layout,
|
||||
});
|
||||
const numberOfValueTicks = clampValueTickCount(
|
||||
computeBarChartValueTickCount({
|
||||
axisSize:
|
||||
layout === BarChartLayout.VERTICAL ? availableHeight : availableWidth,
|
||||
axisFontSize,
|
||||
layout,
|
||||
}),
|
||||
);
|
||||
|
||||
const widthPerTick = calculateWidthPerTick({
|
||||
layout,
|
||||
|
||||
+51
-38
@@ -44,13 +44,14 @@ type TransformGroupByDataToBarChartDataResult = {
|
||||
formattedToRawLookup: Map<string, RawDimensionValue>;
|
||||
};
|
||||
|
||||
const EMPTY_BAR_CHART_RESULT: TransformGroupByDataToBarChartDataResult = {
|
||||
const EMPTY_BAR_CHART_RESULT: Omit<
|
||||
TransformGroupByDataToBarChartDataResult,
|
||||
'xAxisLabel' | 'yAxisLabel'
|
||||
> = {
|
||||
data: [],
|
||||
indexBy: '',
|
||||
keys: [],
|
||||
series: [],
|
||||
xAxisLabel: undefined,
|
||||
yAxisLabel: undefined,
|
||||
showDataLabels: false,
|
||||
showLegend: true,
|
||||
layout: BarChartLayout.VERTICAL,
|
||||
@@ -65,10 +66,6 @@ export const transformGroupByDataToBarChartData = ({
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
}: TransformGroupByDataToBarChartDataParams): TransformGroupByDataToBarChartDataResult => {
|
||||
if (!isDefined(groupByData)) {
|
||||
return EMPTY_BAR_CHART_RESULT;
|
||||
}
|
||||
|
||||
const groupByFieldX = objectMetadataItem.fields.find(
|
||||
(field: FieldMetadataItem) =>
|
||||
field.id === configuration.primaryAxisGroupByFieldMetadataId,
|
||||
@@ -88,13 +85,53 @@ export const transformGroupByDataToBarChartData = ({
|
||||
field.id === configuration.aggregateFieldMetadataId,
|
||||
);
|
||||
|
||||
const queryResultGqlFieldName =
|
||||
getGroupByQueryResultGqlFieldName(objectMetadataItem);
|
||||
const rawResults = groupByData?.[queryResultGqlFieldName];
|
||||
const hasNoData =
|
||||
!isDefined(groupByData) ||
|
||||
!isDefined(rawResults) ||
|
||||
!Array.isArray(rawResults) ||
|
||||
rawResults.length === 0;
|
||||
|
||||
const showXAxis =
|
||||
hasNoData ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.X ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
|
||||
const showYAxis =
|
||||
hasNoData ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.Y ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
|
||||
const xAxisLabel =
|
||||
showXAxis && isDefined(groupByFieldX) ? groupByFieldX.label : undefined;
|
||||
|
||||
const yAxisLabel =
|
||||
showYAxis && isDefined(aggregateField)
|
||||
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
|
||||
: undefined;
|
||||
|
||||
const layout =
|
||||
configuration.graphType === GraphType.HORIZONTAL_BAR
|
||||
? BarChartLayout.HORIZONTAL
|
||||
: BarChartLayout.VERTICAL;
|
||||
|
||||
if (!isDefined(groupByData)) {
|
||||
return {
|
||||
...EMPTY_BAR_CHART_RESULT,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
layout,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
|
||||
return {
|
||||
...EMPTY_BAR_CHART_RESULT,
|
||||
layout:
|
||||
configuration.graphType === GraphType.HORIZONTAL_BAR
|
||||
? BarChartLayout.HORIZONTAL
|
||||
: BarChartLayout.VERTICAL,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
layout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,18 +145,13 @@ export const transformGroupByDataToBarChartData = ({
|
||||
subFieldName: primaryAxisSubFieldName,
|
||||
});
|
||||
|
||||
const queryResultGqlFieldName =
|
||||
getGroupByQueryResultGqlFieldName(objectMetadataItem);
|
||||
const rawResults = groupByData[queryResultGqlFieldName];
|
||||
|
||||
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
|
||||
return {
|
||||
...EMPTY_BAR_CHART_RESULT,
|
||||
indexBy: indexByKey,
|
||||
layout:
|
||||
configuration.graphType === GraphType.HORIZONTAL_BAR
|
||||
? BarChartLayout.HORIZONTAL
|
||||
: BarChartLayout.VERTICAL,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
layout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,20 +173,6 @@ export const transformGroupByDataToBarChartData = ({
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
const showXAxis =
|
||||
configuration.axisNameDisplay === AxisNameDisplay.X ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
|
||||
const showYAxis =
|
||||
configuration.axisNameDisplay === AxisNameDisplay.Y ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
|
||||
const xAxisLabel = showXAxis ? groupByFieldX.label : undefined;
|
||||
|
||||
const yAxisLabel = showYAxis
|
||||
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
|
||||
: undefined;
|
||||
|
||||
const showDataLabels = configuration.displayDataLabel ?? false;
|
||||
const showLegend = configuration.displayLegend ?? true;
|
||||
|
||||
@@ -235,11 +253,6 @@ export const transformGroupByDataToBarChartData = ({
|
||||
primaryAxisSubFieldName,
|
||||
});
|
||||
|
||||
const layout =
|
||||
configuration.graphType === GraphType.HORIZONTAL_BAR
|
||||
? BarChartLayout.HORIZONTAL
|
||||
: BarChartLayout.VERTICAL;
|
||||
|
||||
return {
|
||||
...baseResult,
|
||||
xAxisLabel,
|
||||
|
||||
+101
-42
@@ -1,5 +1,6 @@
|
||||
import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer';
|
||||
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
|
||||
import { NoDataLayer } from '@/page-layout/widgets/graph/components/NoDataLayer';
|
||||
import {
|
||||
CustomCrosshairLayer,
|
||||
type SliceHoverData,
|
||||
@@ -19,6 +20,8 @@ import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLin
|
||||
import { calculateValueRangeFromLineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries';
|
||||
import { getLineChartAxisBottomConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig';
|
||||
import { getLineChartAxisLeftConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisLeftConfig';
|
||||
import { computeEffectiveValueRange } from '@/page-layout/widgets/graph/utils/computeEffectiveValueRange';
|
||||
import { computeValueTickValues } from '@/page-layout/widgets/graph/utils/computeValueTickValues';
|
||||
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
|
||||
import {
|
||||
formatGraphValue,
|
||||
@@ -42,6 +45,9 @@ import { useDebouncedCallback } from 'use-debounce';
|
||||
type CrosshairLayerProps = LineCustomSvgLayerProps<LineSeries>;
|
||||
type PointLabelsLayerProps = LineCustomSvgLayerProps<LineSeries>;
|
||||
type StackedAreasLayerProps = LineCustomSvgLayerProps<LineSeries>;
|
||||
type NoDataLayerWrapperProps = LineCustomSvgLayerProps<LineSeries>;
|
||||
|
||||
const LINE_CHART_DEFAULT_TICK_COUNT = 5;
|
||||
|
||||
type GraphWidgetLineChartProps = {
|
||||
data: LineChartSeries[];
|
||||
@@ -103,8 +109,15 @@ export const GraphWidgetLineChart = ({
|
||||
};
|
||||
|
||||
const calculatedValueRange = calculateValueRangeFromLineChartSeries(data);
|
||||
const effectiveMinimumValue = rangeMin ?? calculatedValueRange.minimum;
|
||||
const effectiveMaximumValue = rangeMax ?? calculatedValueRange.maximum;
|
||||
|
||||
const { effectiveMinimumValue, effectiveMaximumValue, hasNoData } =
|
||||
computeEffectiveValueRange({
|
||||
calculatedMinimum: calculatedValueRange.minimum,
|
||||
calculatedMaximum: calculatedValueRange.maximum,
|
||||
rangeMin,
|
||||
rangeMax,
|
||||
dataLength: data.length,
|
||||
});
|
||||
|
||||
const { enrichedSeries, nivoData, colors, legendItems } = useLineChartData({
|
||||
data,
|
||||
@@ -164,41 +177,67 @@ export const GraphWidgetLineChart = ({
|
||||
});
|
||||
};
|
||||
|
||||
const PointLabelsLayer = (layerProps: PointLabelsLayerProps) => (
|
||||
<CustomPointLabelsLayer
|
||||
points={layerProps.points}
|
||||
formatValue={(value) => formatGraphValue(value, formatOptions)}
|
||||
offset={theme.spacingMultiplicator * 2}
|
||||
groupMode={groupMode}
|
||||
omitNullValues={_omitNullValues}
|
||||
enablePointLabel={enablePointLabel}
|
||||
/>
|
||||
);
|
||||
const PointLabelsLayer = (layerProps: PointLabelsLayerProps) => {
|
||||
if (hasNoData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const CrosshairLayer = (layerProps: CrosshairLayerProps) => (
|
||||
<CustomCrosshairLayer
|
||||
key="custom-crosshair-layer"
|
||||
points={layerProps.points}
|
||||
innerHeight={layerProps.innerHeight}
|
||||
return (
|
||||
<CustomPointLabelsLayer
|
||||
points={layerProps.points}
|
||||
formatValue={(value) => formatGraphValue(value, formatOptions)}
|
||||
offset={theme.spacingMultiplicator * 2}
|
||||
groupMode={groupMode}
|
||||
omitNullValues={_omitNullValues}
|
||||
enablePointLabel={enablePointLabel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CrosshairLayer = (layerProps: CrosshairLayerProps) => {
|
||||
if (hasNoData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomCrosshairLayer
|
||||
key="custom-crosshair-layer"
|
||||
points={layerProps.points}
|
||||
innerHeight={layerProps.innerHeight}
|
||||
innerWidth={layerProps.innerWidth}
|
||||
onSliceHover={handleSliceEnter}
|
||||
onSliceClick={
|
||||
isDefined(onSliceClick)
|
||||
? (sliceData) => onSliceClick(sliceData.closestPoint)
|
||||
: undefined
|
||||
}
|
||||
onRectLeave={handleSliceLeave}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StackedAreasLayer = (layerProps: StackedAreasLayerProps) => {
|
||||
if (hasNoData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomStackedAreasLayer
|
||||
series={layerProps.series}
|
||||
innerHeight={layerProps.innerHeight}
|
||||
enrichedSeries={enrichedSeries}
|
||||
enableArea={enableArea}
|
||||
yScale={layerProps.yScale}
|
||||
isStacked={groupMode === 'stacked'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const NoDataLayerWrapper = (layerProps: NoDataLayerWrapperProps) => (
|
||||
<NoDataLayer
|
||||
innerWidth={layerProps.innerWidth}
|
||||
onSliceHover={handleSliceEnter}
|
||||
onSliceClick={
|
||||
isDefined(onSliceClick)
|
||||
? (sliceData) => onSliceClick(sliceData.closestPoint)
|
||||
: undefined
|
||||
}
|
||||
onRectLeave={handleSliceLeave}
|
||||
/>
|
||||
);
|
||||
|
||||
const StackedAreasLayer = (layerProps: StackedAreasLayerProps) => (
|
||||
<CustomStackedAreasLayer
|
||||
series={layerProps.series}
|
||||
innerHeight={layerProps.innerHeight}
|
||||
enrichedSeries={enrichedSeries}
|
||||
enableArea={enableArea}
|
||||
yScale={layerProps.yScale}
|
||||
isStacked={groupMode === 'stacked'}
|
||||
hasNoData={hasNoData}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -207,7 +246,25 @@ export const GraphWidgetLineChart = ({
|
||||
chartWidth,
|
||||
data,
|
||||
);
|
||||
const axisLeftConfig = getLineChartAxisLeftConfig(yAxisLabel, formatOptions);
|
||||
const chartMargins = {
|
||||
top: LINE_CHART_MARGIN_TOP,
|
||||
right: LINE_CHART_MARGIN_RIGHT,
|
||||
bottom: LINE_CHART_MARGIN_BOTTOM,
|
||||
left: LINE_CHART_MARGIN_LEFT,
|
||||
};
|
||||
const { tickValues: valueTickValues, domain: valueDomain } =
|
||||
computeValueTickValues({
|
||||
minimum: effectiveMinimumValue,
|
||||
maximum: effectiveMaximumValue,
|
||||
tickCount: LINE_CHART_DEFAULT_TICK_COUNT,
|
||||
});
|
||||
|
||||
const axisLeftConfig = getLineChartAxisLeftConfig(
|
||||
yAxisLabel,
|
||||
formatOptions,
|
||||
valueTickValues,
|
||||
chartMargins.left,
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer id={id}>
|
||||
@@ -225,16 +282,16 @@ export const GraphWidgetLineChart = ({
|
||||
<ResponsiveLine
|
||||
data={nivoData}
|
||||
margin={{
|
||||
top: LINE_CHART_MARGIN_TOP,
|
||||
right: LINE_CHART_MARGIN_RIGHT,
|
||||
bottom: LINE_CHART_MARGIN_BOTTOM,
|
||||
left: LINE_CHART_MARGIN_LEFT,
|
||||
top: chartMargins.top,
|
||||
right: chartMargins.right,
|
||||
bottom: chartMargins.bottom,
|
||||
left: chartMargins.left,
|
||||
}}
|
||||
xScale={{ type: 'point' }}
|
||||
yScale={{
|
||||
type: 'linear',
|
||||
min: effectiveMinimumValue,
|
||||
max: effectiveMaximumValue,
|
||||
min: valueDomain.min,
|
||||
max: valueDomain.max,
|
||||
stacked: groupMode === 'stacked',
|
||||
clamp: true,
|
||||
}}
|
||||
@@ -251,6 +308,7 @@ export const GraphWidgetLineChart = ({
|
||||
axisLeft={axisLeftConfig}
|
||||
enableGridX={showGrid}
|
||||
enableGridY={showGrid}
|
||||
gridYValues={valueTickValues}
|
||||
enableSlices={'x'}
|
||||
sliceTooltip={() => null}
|
||||
tooltip={() => null}
|
||||
@@ -264,6 +322,7 @@ export const GraphWidgetLineChart = ({
|
||||
'points',
|
||||
PointLabelsLayer,
|
||||
'legends',
|
||||
NoDataLayerWrapper,
|
||||
]}
|
||||
theme={chartTheme}
|
||||
/>
|
||||
@@ -276,7 +335,7 @@ export const GraphWidgetLineChart = ({
|
||||
onMouseEnter={handleTooltipMouseEnter}
|
||||
onMouseLeave={handleTooltipMouseLeave}
|
||||
/>
|
||||
<GraphWidgetLegend show={showLegend} items={legendItems} />
|
||||
<GraphWidgetLegend show={showLegend && !hasNoData} items={legendItems} />
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const LINE_CHART_MARGIN_LEFT = 70;
|
||||
export const LINE_CHART_MARGIN_LEFT = 80;
|
||||
|
||||
+9
-2
@@ -1,17 +1,24 @@
|
||||
import { LINE_CHART_MARGIN_LEFT } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginLeft';
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
|
||||
const LEFT_AXIS_LEGEND_OFFSET_PADDING = 5;
|
||||
const TICK_PADDING = 5;
|
||||
|
||||
export const getLineChartAxisLeftConfig = (
|
||||
yAxisLabel?: string,
|
||||
formatOptions?: GraphValueFormatOptions,
|
||||
tickValues?: number[],
|
||||
marginLeft: number = LINE_CHART_MARGIN_LEFT,
|
||||
) => ({
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickPadding: TICK_PADDING,
|
||||
tickRotation: 0,
|
||||
tickValues,
|
||||
legend: yAxisLabel,
|
||||
legendPosition: 'middle' as const,
|
||||
legendOffset: -50,
|
||||
legendOffset: -marginLeft + LEFT_AXIS_LEGEND_OFFSET_PADDING,
|
||||
format: (value: number) => formatGraphValue(value, formatOptions || {}),
|
||||
});
|
||||
|
||||
+1
@@ -174,6 +174,7 @@ export const GraphWidgetPieChart = ({
|
||||
objectMetadataItemId={objectMetadataItemId}
|
||||
configuration={configuration}
|
||||
show={showCenterMetric && !hasNoData}
|
||||
hasNoData={hasNoData}
|
||||
/>
|
||||
</StyledPieChartWrapper>
|
||||
</GraphWidgetChartContainer>
|
||||
|
||||
+21
-4
@@ -1,6 +1,7 @@
|
||||
import { usePieChartCenterMetricData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartCenterMetricData';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { type PieChartConfiguration } from '~/generated/graphql';
|
||||
|
||||
@@ -8,6 +9,7 @@ type PieChartCenterMetricProps = {
|
||||
objectMetadataItemId: string;
|
||||
configuration: PieChartConfiguration;
|
||||
show: boolean;
|
||||
hasNoData?: boolean;
|
||||
};
|
||||
|
||||
const StyledCenterMetricContainer = styled(motion.div)`
|
||||
@@ -33,22 +35,31 @@ const StyledLabel = styled.span`
|
||||
font-size: clamp(10px, 5cqmin, 24px);
|
||||
`;
|
||||
|
||||
const StyledNoDataText = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
`;
|
||||
|
||||
export const PieChartCenterMetric = ({
|
||||
objectMetadataItemId,
|
||||
configuration,
|
||||
show,
|
||||
hasNoData = false,
|
||||
}: PieChartCenterMetricProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useLingui();
|
||||
|
||||
const { centerMetricValue, centerMetricLabel } = usePieChartCenterMetricData({
|
||||
objectMetadataItemId,
|
||||
configuration,
|
||||
skip: !show,
|
||||
skip: !show && !hasNoData,
|
||||
});
|
||||
|
||||
const shouldShowContent = show || hasNoData;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
{shouldShowContent && (
|
||||
<StyledCenterMetricContainer
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -58,8 +69,14 @@ export const PieChartCenterMetric = ({
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<StyledValue>{centerMetricValue}</StyledValue>
|
||||
<StyledLabel>{centerMetricLabel}</StyledLabel>
|
||||
{hasNoData ? (
|
||||
<StyledNoDataText>{t`No data`}</StyledNoDataText>
|
||||
) : (
|
||||
<>
|
||||
<StyledValue>{centerMetricValue}</StyledValue>
|
||||
<StyledLabel>{centerMetricLabel}</StyledLabel>
|
||||
</>
|
||||
)}
|
||||
</StyledCenterMetricContainer>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { computeEffectiveValueRange } from '../computeEffectiveValueRange';
|
||||
|
||||
describe('computeEffectiveValueRange', () => {
|
||||
it('should return hasNoData true when dataLength is 0', () => {
|
||||
const result = computeEffectiveValueRange({
|
||||
calculatedMinimum: 0,
|
||||
calculatedMaximum: 0,
|
||||
dataLength: 0,
|
||||
});
|
||||
|
||||
expect(result.hasNoData).toBe(true);
|
||||
});
|
||||
|
||||
it('should start from 0 for non-negative values', () => {
|
||||
const result = computeEffectiveValueRange({
|
||||
calculatedMinimum: 5,
|
||||
calculatedMaximum: 100,
|
||||
dataLength: 10,
|
||||
});
|
||||
|
||||
expect(result.effectiveMinimumValue).toBe(0);
|
||||
expect(result.hasNoData).toBe(false);
|
||||
});
|
||||
|
||||
it('should use calculated minimum for negative values', () => {
|
||||
const result = computeEffectiveValueRange({
|
||||
calculatedMinimum: -50,
|
||||
calculatedMaximum: 100,
|
||||
dataLength: 10,
|
||||
});
|
||||
|
||||
expect(result.effectiveMinimumValue).toBe(-50);
|
||||
});
|
||||
|
||||
it('should respect explicit rangeMin and rangeMax', () => {
|
||||
const result = computeEffectiveValueRange({
|
||||
calculatedMinimum: 0,
|
||||
calculatedMaximum: 100,
|
||||
rangeMin: 10,
|
||||
rangeMax: 50,
|
||||
dataLength: 10,
|
||||
});
|
||||
|
||||
expect(result.effectiveMinimumValue).toBe(10);
|
||||
expect(result.effectiveMaximumValue).toBe(50);
|
||||
});
|
||||
|
||||
it('should add padding when min equals max and no explicit range', () => {
|
||||
const result = computeEffectiveValueRange({
|
||||
calculatedMinimum: 50,
|
||||
calculatedMaximum: 50,
|
||||
dataLength: 1,
|
||||
});
|
||||
|
||||
expect(result.effectiveMaximumValue).toBeGreaterThan(
|
||||
result.effectiveMinimumValue,
|
||||
);
|
||||
});
|
||||
});
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { computeValueTickValues } from '../computeValueTickValues';
|
||||
|
||||
describe('computeValueTickValues', () => {
|
||||
it('should return empty array for non-finite inputs', () => {
|
||||
const result = computeValueTickValues({
|
||||
minimum: Infinity,
|
||||
maximum: 100,
|
||||
tickCount: 5,
|
||||
});
|
||||
|
||||
expect(result.tickValues).toEqual([]);
|
||||
expect(result.domain).toEqual({ min: 0, max: 0 });
|
||||
});
|
||||
|
||||
it('should return single tick when min equals max', () => {
|
||||
const result = computeValueTickValues({
|
||||
minimum: 50,
|
||||
maximum: 50,
|
||||
tickCount: 5,
|
||||
});
|
||||
|
||||
expect(result.tickValues).toEqual([50]);
|
||||
expect(result.domain).toEqual({ min: 50, max: 50 });
|
||||
});
|
||||
|
||||
it('should generate nice tick values for positive range', () => {
|
||||
const result = computeValueTickValues({
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
tickCount: 5,
|
||||
});
|
||||
|
||||
expect(result.tickValues.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.domain.min).toBeLessThanOrEqual(0);
|
||||
expect(result.domain.max).toBeGreaterThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('should handle negative ranges', () => {
|
||||
const result = computeValueTickValues({
|
||||
minimum: -100,
|
||||
maximum: -10,
|
||||
tickCount: 5,
|
||||
});
|
||||
|
||||
expect(result.tickValues.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.domain.min).toBeLessThanOrEqual(-100);
|
||||
expect(result.domain.max).toBeGreaterThanOrEqual(-10);
|
||||
});
|
||||
});
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const POSITIVE_RANGE_PADDING_RATIO = 0.1;
|
||||
const MINIMUM_POSITIVE_RANGE_PADDING = 1;
|
||||
|
||||
type ComputeEffectiveValueRangeParams = {
|
||||
calculatedMinimum: number;
|
||||
calculatedMaximum: number;
|
||||
rangeMin?: number;
|
||||
rangeMax?: number;
|
||||
dataLength: number;
|
||||
};
|
||||
|
||||
type EffectiveValueRangeResult = {
|
||||
effectiveMinimumValue: number;
|
||||
effectiveMaximumValue: number;
|
||||
hasNoData: boolean;
|
||||
};
|
||||
|
||||
export const computeEffectiveValueRange = ({
|
||||
calculatedMinimum,
|
||||
calculatedMaximum,
|
||||
rangeMin,
|
||||
rangeMax,
|
||||
dataLength,
|
||||
}: ComputeEffectiveValueRangeParams): EffectiveValueRangeResult => {
|
||||
const hasOnlyNonNegativeValues =
|
||||
calculatedMinimum >= 0 && calculatedMaximum >= 0;
|
||||
const hasNoData = dataLength === 0;
|
||||
|
||||
const baseMinimumValue = isDefined(rangeMin)
|
||||
? rangeMin
|
||||
: hasOnlyNonNegativeValues
|
||||
? 0
|
||||
: calculatedMinimum;
|
||||
|
||||
const positiveRangePaddingTarget = isDefined(rangeMax)
|
||||
? rangeMax
|
||||
: calculatedMaximum;
|
||||
|
||||
const paddedMaximumForNonNegative =
|
||||
isDefined(rangeMax) || !hasOnlyNonNegativeValues
|
||||
? positiveRangePaddingTarget
|
||||
: positiveRangePaddingTarget +
|
||||
Math.max(
|
||||
Math.abs(positiveRangePaddingTarget) * POSITIVE_RANGE_PADDING_RATIO,
|
||||
MINIMUM_POSITIVE_RANGE_PADDING,
|
||||
);
|
||||
|
||||
let effectiveMinimumValue = baseMinimumValue;
|
||||
let effectiveMaximumValue = paddedMaximumForNonNegative;
|
||||
|
||||
if (!isDefined(rangeMax) && !isDefined(rangeMin)) {
|
||||
if (effectiveMinimumValue === effectiveMaximumValue) {
|
||||
effectiveMaximumValue =
|
||||
effectiveMinimumValue + MINIMUM_POSITIVE_RANGE_PADDING;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
effectiveMinimumValue,
|
||||
effectiveMaximumValue,
|
||||
hasNoData,
|
||||
};
|
||||
};
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { NICE_STEP_MULTIPLIERS } from '@/page-layout/widgets/graph/constants/NiceStepMultipliers';
|
||||
import { NICE_STEP_NORMALIZED_VALUE_THRESHOLDS } from '@/page-layout/widgets/graph/constants/NiceStepNormalizedValueThresholds';
|
||||
|
||||
const computeNiceStepInterval = (roughStepInterval: number): number => {
|
||||
if (roughStepInterval === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const stepMagnitude = Math.pow(
|
||||
10,
|
||||
Math.floor(Math.log10(Math.abs(roughStepInterval))),
|
||||
);
|
||||
const normalizedStepValue = roughStepInterval / stepMagnitude;
|
||||
|
||||
if (normalizedStepValue >= NICE_STEP_NORMALIZED_VALUE_THRESHOLDS.LARGE)
|
||||
return NICE_STEP_MULTIPLIERS.LARGE * stepMagnitude;
|
||||
if (normalizedStepValue >= NICE_STEP_NORMALIZED_VALUE_THRESHOLDS.MEDIUM)
|
||||
return NICE_STEP_MULTIPLIERS.MEDIUM * stepMagnitude;
|
||||
if (normalizedStepValue >= NICE_STEP_NORMALIZED_VALUE_THRESHOLDS.SMALL)
|
||||
return NICE_STEP_MULTIPLIERS.SMALL * stepMagnitude;
|
||||
return NICE_STEP_MULTIPLIERS.DEFAULT * stepMagnitude;
|
||||
};
|
||||
|
||||
export const computeValueTickValues = ({
|
||||
minimum,
|
||||
maximum,
|
||||
tickCount,
|
||||
}: {
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
tickCount: number;
|
||||
}): {
|
||||
tickValues: number[];
|
||||
domain: { min: number; max: number };
|
||||
} => {
|
||||
if (!Number.isFinite(minimum) || !Number.isFinite(maximum)) {
|
||||
return { tickValues: [], domain: { min: 0, max: 0 } };
|
||||
}
|
||||
|
||||
if (minimum === maximum) {
|
||||
return { tickValues: [minimum], domain: { min: minimum, max: minimum } };
|
||||
}
|
||||
|
||||
const safeTickCount = Math.max(2, tickCount);
|
||||
const roughStepInterval = (maximum - minimum) / (safeTickCount - 1);
|
||||
const niceStepInterval = computeNiceStepInterval(roughStepInterval);
|
||||
|
||||
if (niceStepInterval === 0) {
|
||||
return {
|
||||
tickValues: [minimum, maximum],
|
||||
domain: { min: minimum, max: maximum },
|
||||
};
|
||||
}
|
||||
|
||||
const niceMinimum = Math.floor(minimum / niceStepInterval) * niceStepInterval;
|
||||
const niceMaximum = Math.ceil(maximum / niceStepInterval) * niceStepInterval;
|
||||
const tickValues: number[] = [];
|
||||
|
||||
for (
|
||||
let tickValue = niceMinimum;
|
||||
tickValue <= niceMaximum + niceStepInterval / 2;
|
||||
tickValue += niceStepInterval
|
||||
) {
|
||||
tickValues.push(Number(tickValue.toFixed(12)));
|
||||
}
|
||||
|
||||
return {
|
||||
tickValues,
|
||||
domain: { min: niceMinimum, max: niceMaximum },
|
||||
};
|
||||
};
|
||||
+49
-27
@@ -34,10 +34,11 @@ type TransformGroupByDataToLineChartDataResult = {
|
||||
formattedToRawLookup: Map<string, RawDimensionValue>;
|
||||
};
|
||||
|
||||
const EMPTY_LINE_CHART_RESULT: TransformGroupByDataToLineChartDataResult = {
|
||||
const EMPTY_LINE_CHART_RESULT: Omit<
|
||||
TransformGroupByDataToLineChartDataResult,
|
||||
'xAxisLabel' | 'yAxisLabel'
|
||||
> = {
|
||||
series: [],
|
||||
xAxisLabel: undefined,
|
||||
yAxisLabel: undefined,
|
||||
showDataLabels: false,
|
||||
showLegend: true,
|
||||
hasTooManyGroups: false,
|
||||
@@ -51,10 +52,6 @@ export const transformGroupByDataToLineChartData = ({
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
}: TransformGroupByDataToLineChartDataParams): TransformGroupByDataToLineChartDataResult => {
|
||||
if (!isDefined(groupByData)) {
|
||||
return EMPTY_LINE_CHART_RESULT;
|
||||
}
|
||||
|
||||
const groupByFieldX = objectMetadataItem.fields.find(
|
||||
(field: FieldMetadataItem) =>
|
||||
field.id === configuration.primaryAxisGroupByFieldMetadataId,
|
||||
@@ -74,8 +71,47 @@ export const transformGroupByDataToLineChartData = ({
|
||||
field.id === configuration.aggregateFieldMetadataId,
|
||||
);
|
||||
|
||||
const queryResultGqlFieldName =
|
||||
getGroupByQueryResultGqlFieldName(objectMetadataItem);
|
||||
const rawResults = groupByData?.[queryResultGqlFieldName];
|
||||
const hasNoData =
|
||||
!isDefined(groupByData) ||
|
||||
!isDefined(rawResults) ||
|
||||
!Array.isArray(rawResults) ||
|
||||
rawResults.length === 0;
|
||||
|
||||
const showXAxis =
|
||||
hasNoData ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.X ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
|
||||
const showYAxis =
|
||||
hasNoData ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.Y ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
|
||||
const xAxisLabel =
|
||||
showXAxis && isDefined(groupByFieldX) ? groupByFieldX.label : undefined;
|
||||
|
||||
const yAxisLabel =
|
||||
showYAxis && isDefined(aggregateField)
|
||||
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
|
||||
: undefined;
|
||||
|
||||
if (!isDefined(groupByData)) {
|
||||
return {
|
||||
...EMPTY_LINE_CHART_RESULT,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
|
||||
return EMPTY_LINE_CHART_RESULT;
|
||||
return {
|
||||
...EMPTY_LINE_CHART_RESULT,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
};
|
||||
}
|
||||
|
||||
const primaryAxisSubFieldName =
|
||||
@@ -83,12 +119,12 @@ export const transformGroupByDataToLineChartData = ({
|
||||
const secondaryAxisSubFieldName =
|
||||
configuration.secondaryAxisGroupBySubFieldName ?? undefined;
|
||||
|
||||
const queryResultGqlFieldName =
|
||||
getGroupByQueryResultGqlFieldName(objectMetadataItem);
|
||||
const rawResults = groupByData[queryResultGqlFieldName];
|
||||
|
||||
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
|
||||
return EMPTY_LINE_CHART_RESULT;
|
||||
return {
|
||||
...EMPTY_LINE_CHART_RESULT,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
};
|
||||
}
|
||||
|
||||
const filteredResults = filterGroupByResults({
|
||||
@@ -109,20 +145,6 @@ export const transformGroupByDataToLineChartData = ({
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
const showXAxis =
|
||||
configuration.axisNameDisplay === AxisNameDisplay.X ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
|
||||
const showYAxis =
|
||||
configuration.axisNameDisplay === AxisNameDisplay.Y ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
|
||||
const xAxisLabel = showXAxis ? groupByFieldX.label : undefined;
|
||||
|
||||
const yAxisLabel = showYAxis
|
||||
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
|
||||
: undefined;
|
||||
|
||||
const showDataLabels = configuration.displayDataLabel ?? false;
|
||||
const showLegend = configuration.displayLegend ?? true;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user