[Fix]: Vertical bar charts with max data range do not show data up to the top if above max data range (#18748)
fixes issue : #18606 we have removed the irrelevant use of filters in the backend which removes the values that are out of max, min range hence modifying the rawValues. - Modified relevant tests - fixed regressions (Horizontal bar charts rightLabel cropping) - removed dead utility filterByRange code Before : <img width="1253" height="722" alt="Screenshot 2026-03-18 at 10 24 14 PM" src="https://github.com/user-attachments/assets/20adb5bb-0f96-4952-9777-1f1a6ebe03eb" /> After: <img width="865" height="724" alt="Screenshot 2026-03-18 at 11 16 33 PM" src="https://github.com/user-attachments/assets/b2695d85-ca5e-4923-8f57-8d3c87c8212a" /> <img width="1260" height="689" alt="Screenshot 2026-03-19 at 12 16 41 AM" src="https://github.com/user-attachments/assets/fb25e15f-eec2-445e-9103-280d58664454" /> --------- Co-authored-by: Arun kumar <arunkumar@Aruns-MacBook-Air.local> Co-authored-by: ehconitin <nitinkoche03@gmail.com>
This commit is contained in:
+3
-1
@@ -104,7 +104,9 @@ export const getAxisLayerLayout = ({
|
||||
-margins.left + axisConfig.leftAxisLegendOffsetPadding;
|
||||
|
||||
const valueRange = valueDomain.max - valueDomain.min;
|
||||
const shouldRenderZeroLine = hasNegativeValues && valueRange !== 0;
|
||||
const zeroIsWithinDomain =
|
||||
valueDomain.min < 0 && valueDomain.max > 0 && valueRange !== 0;
|
||||
const shouldRenderZeroLine = hasNegativeValues && zeroIsWithinDomain;
|
||||
const zeroPosition = shouldRenderZeroLine
|
||||
? isVertical
|
||||
? innerHeight - ((0 - valueDomain.min) / valueRange) * innerHeight
|
||||
|
||||
+4
@@ -36,6 +36,10 @@ export const renderGridLayer = ({
|
||||
for (const tickValue of valueTickValues) {
|
||||
const normalizedPosition = (tickValue - valueDomain.min) / range;
|
||||
|
||||
if (normalizedPosition <= 0 || normalizedPosition >= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
if (isVertical) {
|
||||
const y = innerHeight * (1 - normalizedPosition);
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
export const TEXT_MARGIN_EXTRAS = {
|
||||
tickPaddingExtra: 4,
|
||||
bottomTickExtraNonRotated: 20,
|
||||
rightTickExtra: 16,
|
||||
} as const;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export const TEXT_MARGIN_LIMITS = {
|
||||
min: { top: 10, right: 10, bottom: 30, left: 40 },
|
||||
max: { top: 30, right: 30, bottom: 140, left: 160 },
|
||||
max: { top: 30, right: 160, bottom: 140, left: 160 },
|
||||
} as const;
|
||||
|
||||
+7
-1
@@ -1,7 +1,7 @@
|
||||
import { BarChartLayers } from '@/page-layout/widgets/graph/graph-widget-bar-chart/components/BarChartLayers';
|
||||
import { useBarChartLayout } from '@/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useBarChartLayout';
|
||||
import { useBarChartTheme } from '@/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useBarChartTheme';
|
||||
import { useMemoizedBarPositions } from '@/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useMemoizedBarPositions';
|
||||
import { useBarChartLayout } from '@/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useBarChartLayout';
|
||||
import { type BarChartDatum } from '@/page-layout/widgets/graph/graph-widget-bar-chart/types/BarChartDatum';
|
||||
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graph-widget-bar-chart/types/BarChartEnrichedKey';
|
||||
import { type BarChartSlice } from '@/page-layout/widgets/graph/graph-widget-bar-chart/types/BarChartSlice';
|
||||
@@ -30,12 +30,14 @@ type BarChartProps = {
|
||||
layout: BarChartLayout;
|
||||
groupMode: 'grouped' | 'stacked';
|
||||
effectiveValueRange: { minimum: number; maximum: number };
|
||||
hasExplicitRangeBounds: boolean;
|
||||
formatOptions: GraphValueFormatOptions;
|
||||
axisConfig?: {
|
||||
xAxisLabel?: string;
|
||||
yAxisLabel?: string;
|
||||
showGrid?: boolean;
|
||||
};
|
||||
rightTickLabels?: string[];
|
||||
dataLabelsConfig?: {
|
||||
show: boolean;
|
||||
omitNullValues: boolean;
|
||||
@@ -65,8 +67,10 @@ export const BarChart = ({
|
||||
layout,
|
||||
groupMode,
|
||||
effectiveValueRange,
|
||||
hasExplicitRangeBounds,
|
||||
formatOptions,
|
||||
axisConfig,
|
||||
rightTickLabels,
|
||||
dataLabelsConfig,
|
||||
hoveredSliceIndexValue,
|
||||
onSliceHover,
|
||||
@@ -102,11 +106,13 @@ export const BarChart = ({
|
||||
chartWidth,
|
||||
data,
|
||||
effectiveValueRange,
|
||||
hasExplicitRangeBounds,
|
||||
formatOptions,
|
||||
groupMode,
|
||||
indexBy,
|
||||
keys,
|
||||
layout,
|
||||
rightTickLabels,
|
||||
});
|
||||
|
||||
const isVerticalLayout = layout === BarChartLayout.VERTICAL;
|
||||
|
||||
+48
-9
@@ -1,4 +1,3 @@
|
||||
import { isSidePanelAnimatingState } from '@/side-panel/states/isSidePanelAnimatingState';
|
||||
import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState';
|
||||
import { pageLayoutResizingWidgetIdComponentState } from '@/page-layout/states/pageLayoutResizingWidgetIdComponentState';
|
||||
import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer';
|
||||
@@ -17,12 +16,17 @@ import { calculateValueRangeFromBarChartKeys } from '@/page-layout/widgets/graph
|
||||
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
|
||||
import { computeEffectiveValueRange } from '@/page-layout/widgets/graph/utils/computeEffectiveValueRange';
|
||||
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
|
||||
import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import { isSidePanelAnimatingState } from '@/side-panel/states/isSidePanelAnimatingState';
|
||||
import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { styled } from '@linaria/react';
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
import { useContext, useMemo, useRef, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
@@ -120,13 +124,16 @@ export const GraphWidgetBarChart = ({
|
||||
|
||||
const allowDataTransitions = !isLayoutAnimating;
|
||||
|
||||
const formatOptions: GraphValueFormatOptions = {
|
||||
customFormatter,
|
||||
decimals,
|
||||
displayType,
|
||||
prefix,
|
||||
suffix,
|
||||
};
|
||||
const formatOptions = useMemo<GraphValueFormatOptions>(
|
||||
() => ({
|
||||
customFormatter,
|
||||
decimals,
|
||||
displayType,
|
||||
prefix,
|
||||
suffix,
|
||||
}),
|
||||
[customFormatter, decimals, displayType, prefix, suffix],
|
||||
);
|
||||
|
||||
const { enrichedKeysMap, enrichedKeys, legendItems, visibleKeys } =
|
||||
useBarChartData({ keys, series, colorRegistry, seriesLabels, colorMode });
|
||||
@@ -151,6 +158,36 @@ export const GraphWidgetBarChart = ({
|
||||
rangeMin,
|
||||
});
|
||||
|
||||
const hasExplicitRangeBounds = isDefined(rangeMin) || isDefined(rangeMax);
|
||||
|
||||
const rightTickLabels = useMemo(() => {
|
||||
if (
|
||||
!hasExplicitRangeBounds ||
|
||||
!showValues ||
|
||||
layout !== BarChartLayout.HORIZONTAL
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const labels: string[] = [];
|
||||
for (const dataItem of data) {
|
||||
for (const visibleKey of visibleKeys) {
|
||||
const value = dataItem[visibleKey];
|
||||
|
||||
if (isNumber(value)) {
|
||||
labels.push(formatGraphValue(value, formatOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}, [
|
||||
data,
|
||||
visibleKeys,
|
||||
formatOptions,
|
||||
hasExplicitRangeBounds,
|
||||
showValues,
|
||||
layout,
|
||||
]);
|
||||
|
||||
const dataByIndexValue = useMemo(
|
||||
() => new Map(data.map((row) => [String(row[indexBy]), row])),
|
||||
[data, indexBy],
|
||||
@@ -222,8 +259,10 @@ export const GraphWidgetBarChart = ({
|
||||
maximum: effectiveMaximumValue,
|
||||
minimum: effectiveMinimumValue,
|
||||
}}
|
||||
hasExplicitRangeBounds={hasExplicitRangeBounds}
|
||||
enrichedKeysMap={enrichedKeysMap}
|
||||
formatOptions={formatOptions}
|
||||
rightTickLabels={rightTickLabels}
|
||||
groupMode={groupMode}
|
||||
hasNoData={hasNoData}
|
||||
hoveredSliceIndexValue={graphWidgetHoveredSliceIndex}
|
||||
|
||||
+6
@@ -21,11 +21,13 @@ type UseBarChartLayoutParams = {
|
||||
chartWidth: number;
|
||||
data: BarChartDatum[];
|
||||
effectiveValueRange: { minimum: number; maximum: number };
|
||||
hasExplicitRangeBounds: boolean;
|
||||
formatOptions: GraphValueFormatOptions;
|
||||
groupMode: 'grouped' | 'stacked';
|
||||
indexBy: string;
|
||||
keys: string[];
|
||||
layout: BarChartLayout;
|
||||
rightTickLabels?: string[];
|
||||
};
|
||||
|
||||
type UseBarChartLayoutResult = {
|
||||
@@ -52,11 +54,13 @@ export const useBarChartLayout = ({
|
||||
chartWidth,
|
||||
data,
|
||||
effectiveValueRange,
|
||||
hasExplicitRangeBounds,
|
||||
formatOptions,
|
||||
groupMode,
|
||||
indexBy,
|
||||
keys,
|
||||
layout,
|
||||
rightTickLabels,
|
||||
}: UseBarChartLayoutParams): UseBarChartLayoutResult => {
|
||||
const { tickFontSize, legendFontSize } = resolveAxisFontSizes(axisTheme);
|
||||
|
||||
@@ -73,9 +77,11 @@ export const useBarChartLayout = ({
|
||||
data,
|
||||
effectiveMaximumValue: effectiveValueRange.maximum,
|
||||
effectiveMinimumValue: effectiveValueRange.minimum,
|
||||
hasExplicitRangeBounds,
|
||||
formatOptions,
|
||||
indexBy,
|
||||
layout,
|
||||
rightTickLabels,
|
||||
xAxisLabel: axisConfig?.xAxisLabel,
|
||||
yAxisLabel: axisConfig?.yAxisLabel,
|
||||
});
|
||||
|
||||
+24
@@ -142,4 +142,28 @@ describe('computeBarPositions', () => {
|
||||
expect(bar2).toBeDefined();
|
||||
expect(bar1!.y).toBeGreaterThan(bar2!.y);
|
||||
});
|
||||
|
||||
it('clamps horizontal bars to value axis when value is above explicit max', () => {
|
||||
const chartWidth = 300;
|
||||
|
||||
const result = computeBarPositions({
|
||||
data: [{ category: 'A', value1: 15 }],
|
||||
indexBy: 'category',
|
||||
keys: ['value1'],
|
||||
enrichedKeysMap: defaultEnrichedKeysMap,
|
||||
chartWidth,
|
||||
chartHeight: 300,
|
||||
margins: defaultMargins,
|
||||
layout: BarChartLayout.HORIZONTAL,
|
||||
groupMode: 'grouped',
|
||||
valueDomain: { min: 0, max: 10 },
|
||||
innerPadding: 2,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].x).toBe(0);
|
||||
expect(result[0].width).toBe(
|
||||
chartWidth - defaultMargins.left - defaultMargins.right,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+17
@@ -124,6 +124,23 @@ describe('getBarChartLayout', () => {
|
||||
TEXT_MARGIN_LIMITS.max.left,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps explicit range bounds as value domain', () => {
|
||||
const result = getBarChartLayout({
|
||||
...baseParams,
|
||||
chartWidth: 280,
|
||||
layout: BarChartLayout.HORIZONTAL,
|
||||
effectiveMinimumValue: 0,
|
||||
effectiveMaximumValue: 10,
|
||||
hasExplicitRangeBounds: true,
|
||||
});
|
||||
|
||||
expect(result.valueDomain).toEqual({ min: 0, max: 10 });
|
||||
expect(result.valueTickValues[0]).toBe(0);
|
||||
expect(result.valueTickValues[result.valueTickValues.length - 1]).toBe(
|
||||
10,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
|
||||
+40
@@ -59,4 +59,44 @@ describe('getGroupedBarDimensions', () => {
|
||||
height: 19,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps vertical bar when value exceeds axis length', () => {
|
||||
const ctx = createContext({ isVertical: true });
|
||||
const layout = computeGroupedBarLayout(ctx, 2);
|
||||
|
||||
const dimensions = getGroupedBarDimensions({
|
||||
ctx,
|
||||
layout,
|
||||
categoryStart: 10,
|
||||
keyIndex: 1,
|
||||
value: 150,
|
||||
});
|
||||
|
||||
expect(dimensions).toEqual({
|
||||
x: 31,
|
||||
y: 0,
|
||||
width: 19,
|
||||
height: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps horizontal bar when value exceeds axis length', () => {
|
||||
const ctx = createContext({ isVertical: false });
|
||||
const layout = computeGroupedBarLayout(ctx, 2);
|
||||
|
||||
const dimensions = getGroupedBarDimensions({
|
||||
ctx,
|
||||
layout,
|
||||
categoryStart: 10,
|
||||
keyIndex: 0,
|
||||
value: 150,
|
||||
});
|
||||
|
||||
expect(dimensions).toEqual({
|
||||
x: 0,
|
||||
y: 10,
|
||||
width: 100,
|
||||
height: 19,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+88
@@ -114,4 +114,92 @@ describe('getStackedBarDimensions', () => {
|
||||
newNegativeStackPixel: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps vertical positive bar when stack exceeds axis length', () => {
|
||||
const ctx = createContext({ isVertical: true });
|
||||
|
||||
const dimensions = getStackedBarDimensions({
|
||||
ctx,
|
||||
layout,
|
||||
categoryStart: 20,
|
||||
value: 40,
|
||||
positiveStackPixel: 80,
|
||||
negativeStackPixel: 50,
|
||||
});
|
||||
|
||||
expect(dimensions).toEqual({
|
||||
x: 22,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 20,
|
||||
newPositiveStackPixel: 120,
|
||||
newNegativeStackPixel: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps vertical negative bar when stack goes below zero', () => {
|
||||
const ctx = createContext({ isVertical: true });
|
||||
|
||||
const dimensions = getStackedBarDimensions({
|
||||
ctx,
|
||||
layout,
|
||||
categoryStart: 20,
|
||||
value: -40,
|
||||
positiveStackPixel: 50,
|
||||
negativeStackPixel: 20,
|
||||
});
|
||||
|
||||
expect(dimensions).toEqual({
|
||||
x: 22,
|
||||
y: 80,
|
||||
width: 10,
|
||||
height: 20,
|
||||
newPositiveStackPixel: 50,
|
||||
newNegativeStackPixel: -20,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps horizontal positive bar when stack exceeds axis length', () => {
|
||||
const ctx = createContext({ isVertical: false });
|
||||
|
||||
const dimensions = getStackedBarDimensions({
|
||||
ctx,
|
||||
layout,
|
||||
categoryStart: 20,
|
||||
value: 40,
|
||||
positiveStackPixel: 80,
|
||||
negativeStackPixel: 50,
|
||||
});
|
||||
|
||||
expect(dimensions).toEqual({
|
||||
x: 80,
|
||||
y: 22,
|
||||
width: 20,
|
||||
height: 10,
|
||||
newPositiveStackPixel: 120,
|
||||
newNegativeStackPixel: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps horizontal negative bar when stack goes below zero', () => {
|
||||
const ctx = createContext({ isVertical: false });
|
||||
|
||||
const dimensions = getStackedBarDimensions({
|
||||
ctx,
|
||||
layout,
|
||||
categoryStart: 20,
|
||||
value: -40,
|
||||
positiveStackPixel: 50,
|
||||
negativeStackPixel: 20,
|
||||
});
|
||||
|
||||
expect(dimensions).toEqual({
|
||||
x: 0,
|
||||
y: 22,
|
||||
width: 20,
|
||||
height: 10,
|
||||
newPositiveStackPixel: 50,
|
||||
newNegativeStackPixel: -20,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+14
-3
@@ -31,6 +31,8 @@ type GetBarChartLayoutParams = {
|
||||
formatOptions: GraphValueFormatOptions;
|
||||
effectiveMinimumValue: number;
|
||||
effectiveMaximumValue: number;
|
||||
hasExplicitRangeBounds?: boolean;
|
||||
rightTickLabels?: string[];
|
||||
};
|
||||
|
||||
type BarChartLayoutResult = {
|
||||
@@ -127,6 +129,8 @@ export const getBarChartLayout = ({
|
||||
formatOptions,
|
||||
effectiveMinimumValue,
|
||||
effectiveMaximumValue,
|
||||
hasExplicitRangeBounds = false,
|
||||
rightTickLabels = [],
|
||||
}: GetBarChartLayoutParams): BarChartLayoutResult => {
|
||||
const { tickFontSize, legendFontSize } = resolveAxisFontSizes(axisTheme);
|
||||
|
||||
@@ -152,6 +156,7 @@ export const getBarChartLayout = ({
|
||||
minimum: effectiveMinimumValue,
|
||||
maximum: effectiveMaximumValue,
|
||||
tickCount: currentTickConfiguration.numberOfValueTicks,
|
||||
preserveDomainBounds: hasExplicitRangeBounds,
|
||||
}),
|
||||
getTickRotation: (currentTickConfiguration) =>
|
||||
currentTickConfiguration.bottomAxisTickRotation,
|
||||
@@ -161,13 +166,19 @@ export const getBarChartLayout = ({
|
||||
tickFontSize,
|
||||
tickRotation: parameters.tickConfiguration.bottomAxisTickRotation,
|
||||
}),
|
||||
resolveMarginInputs: (currentTickConfiguration, tickResult) =>
|
||||
resolveMarginInputs({
|
||||
resolveMarginInputs: (currentTickConfiguration, tickResult) => {
|
||||
const marginInputs = resolveMarginInputs({
|
||||
tickConfiguration: currentTickConfiguration,
|
||||
tickResult,
|
||||
layout,
|
||||
formatOptions,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...marginInputs,
|
||||
rightTickLabels,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const { tickValues: valueTickValues, domain: valueDomain } = valueTickResult;
|
||||
|
||||
+7
-3
@@ -52,9 +52,13 @@ export const getGroupedBarDimensions = ({
|
||||
const { isVertical, valueAxisLength, valueToPixel, zeroPixel } = ctx;
|
||||
const { barThickness, groupCenteringOffset, barStride } = layout;
|
||||
|
||||
const valuePixel = valueToPixel(value);
|
||||
const barStart = Math.min(zeroPixel, valuePixel);
|
||||
const barLength = Math.abs(valuePixel - zeroPixel);
|
||||
const valuePixel = Math.max(
|
||||
0,
|
||||
Math.min(valueAxisLength, valueToPixel(value)),
|
||||
);
|
||||
const clampedZeroPixel = Math.max(0, Math.min(valueAxisLength, zeroPixel));
|
||||
const barStart = Math.min(clampedZeroPixel, valuePixel);
|
||||
const barLength = Math.abs(valuePixel - clampedZeroPixel);
|
||||
|
||||
const categoryPosition =
|
||||
categoryStart + groupCenteringOffset + keyIndex * barStride;
|
||||
|
||||
+31
-12
@@ -74,45 +74,64 @@ export const getStackedBarDimensions = ({
|
||||
const valuePixelDelta =
|
||||
stackRange === 0 ? 0 : stackValueToPixel(Math.abs(value));
|
||||
|
||||
const clampToAxis = (pixel: number) =>
|
||||
Math.max(0, Math.min(valueAxisLength, pixel));
|
||||
|
||||
if (isVertical && isNegative) {
|
||||
const newStack = negativeStackPixel - valuePixelDelta;
|
||||
const clampedCurrent = clampToAxis(negativeStackPixel);
|
||||
const clampedNew = clampToAxis(newStack);
|
||||
|
||||
return {
|
||||
x: categoryPosition,
|
||||
y: valueAxisLength - negativeStackPixel,
|
||||
y: valueAxisLength - clampedCurrent,
|
||||
width: barThickness,
|
||||
height: valuePixelDelta,
|
||||
height: clampedCurrent - clampedNew,
|
||||
newPositiveStackPixel: positiveStackPixel,
|
||||
newNegativeStackPixel: negativeStackPixel - valuePixelDelta,
|
||||
newNegativeStackPixel: newStack,
|
||||
};
|
||||
}
|
||||
|
||||
if (isVertical) {
|
||||
const newStack = positiveStackPixel + valuePixelDelta;
|
||||
const clampedCurrent = clampToAxis(positiveStackPixel);
|
||||
const clampedNew = clampToAxis(newStack);
|
||||
|
||||
return {
|
||||
x: categoryPosition,
|
||||
y: valueAxisLength - (positiveStackPixel + valuePixelDelta),
|
||||
y: valueAxisLength - clampedNew,
|
||||
width: barThickness,
|
||||
height: valuePixelDelta,
|
||||
newPositiveStackPixel: positiveStackPixel + valuePixelDelta,
|
||||
height: clampedNew - clampedCurrent,
|
||||
newPositiveStackPixel: newStack,
|
||||
newNegativeStackPixel: negativeStackPixel,
|
||||
};
|
||||
}
|
||||
|
||||
if (isNegative) {
|
||||
const newStack = negativeStackPixel - valuePixelDelta;
|
||||
const clampedCurrent = clampToAxis(negativeStackPixel);
|
||||
const clampedNew = clampToAxis(newStack);
|
||||
|
||||
return {
|
||||
x: negativeStackPixel - valuePixelDelta,
|
||||
x: clampedNew,
|
||||
y: categoryPosition,
|
||||
width: valuePixelDelta,
|
||||
width: clampedCurrent - clampedNew,
|
||||
height: barThickness,
|
||||
newPositiveStackPixel: positiveStackPixel,
|
||||
newNegativeStackPixel: negativeStackPixel - valuePixelDelta,
|
||||
newNegativeStackPixel: newStack,
|
||||
};
|
||||
}
|
||||
|
||||
const newStack = positiveStackPixel + valuePixelDelta;
|
||||
const clampedCurrent = clampToAxis(positiveStackPixel);
|
||||
const clampedNew = clampToAxis(newStack);
|
||||
|
||||
return {
|
||||
x: positiveStackPixel,
|
||||
x: clampedCurrent,
|
||||
y: categoryPosition,
|
||||
width: valuePixelDelta,
|
||||
width: clampedNew - clampedCurrent,
|
||||
height: barThickness,
|
||||
newPositiveStackPixel: positiveStackPixel + valuePixelDelta,
|
||||
newPositiveStackPixel: newStack,
|
||||
newNegativeStackPixel: negativeStackPixel,
|
||||
};
|
||||
};
|
||||
|
||||
+26
@@ -46,4 +46,30 @@ describe('computeValueTickValues', () => {
|
||||
expect(result.domain.min).toBeLessThanOrEqual(-100);
|
||||
expect(result.domain.max).toBeGreaterThanOrEqual(-10);
|
||||
});
|
||||
|
||||
it('should preserve explicit domain bounds when requested', () => {
|
||||
const result = computeValueTickValues({
|
||||
minimum: 0,
|
||||
maximum: 10,
|
||||
tickCount: 2,
|
||||
preserveDomainBounds: true,
|
||||
});
|
||||
|
||||
expect(result.domain).toEqual({ min: 0, max: 10 });
|
||||
expect(result.tickValues[0]).toBe(0);
|
||||
expect(result.tickValues[result.tickValues.length - 1]).toBe(10);
|
||||
expect(result.tickValues.every((tick) => tick >= 0 && tick <= 10)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should still allow domain expansion when preserving bounds is disabled', () => {
|
||||
const result = computeValueTickValues({
|
||||
minimum: 0,
|
||||
maximum: 10,
|
||||
tickCount: 2,
|
||||
});
|
||||
|
||||
expect(result.domain.max).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
+36
@@ -24,6 +24,42 @@ describe('getChartMarginsFromText', () => {
|
||||
expect(result.right).toBe(18);
|
||||
});
|
||||
|
||||
it('clamps right margin when rightTickLabels are very long', () => {
|
||||
const longLabel = 'X'.repeat(200);
|
||||
|
||||
const result = getChartMarginsFromText({
|
||||
tickFontSize: 12,
|
||||
legendFontSize: 12,
|
||||
bottomTickLabels: ['a'],
|
||||
leftTickLabels: ['b'],
|
||||
rightTickLabels: [longLabel],
|
||||
xAxisLabel: 'x',
|
||||
yAxisLabel: 'y',
|
||||
tickRotation: COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE,
|
||||
bottomLegendOffset: 0,
|
||||
});
|
||||
|
||||
expect(result.right).toBe(TEXT_MARGIN_LIMITS.max.right);
|
||||
});
|
||||
|
||||
it('increases right margin when rightTickLabels are provided', () => {
|
||||
const topRightBase = Math.ceil(12 * 1.5);
|
||||
|
||||
const result = getChartMarginsFromText({
|
||||
tickFontSize: 12,
|
||||
legendFontSize: 12,
|
||||
bottomTickLabels: ['a'],
|
||||
leftTickLabels: ['b'],
|
||||
rightTickLabels: ['$10,000'],
|
||||
xAxisLabel: 'x',
|
||||
yAxisLabel: 'y',
|
||||
tickRotation: COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE,
|
||||
bottomLegendOffset: 0,
|
||||
});
|
||||
|
||||
expect(result.right).toBeGreaterThan(topRightBase);
|
||||
});
|
||||
|
||||
it('uses bottom legend offset when it exceeds tick and label blocks', () => {
|
||||
const bottomLegendOffset = 100;
|
||||
const result = getChartMarginsFromText({
|
||||
|
||||
+3
@@ -4,6 +4,7 @@ import { getChartMarginsFromText } from '@/page-layout/widgets/graph/utils/getCh
|
||||
type ChartMarginInputs = {
|
||||
bottomTickLabels?: string[];
|
||||
leftTickLabels?: string[];
|
||||
rightTickLabels?: string[];
|
||||
};
|
||||
|
||||
type ComputeChartMarginsParams<TTickConfig, TValueTickResult> = {
|
||||
@@ -76,6 +77,7 @@ export const computeChartMargins = <TTickConfig, TValueTickResult>({
|
||||
legendFontSize,
|
||||
bottomTickLabels: provisionalMarginInputs.bottomTickLabels,
|
||||
leftTickLabels: provisionalMarginInputs.leftTickLabels,
|
||||
rightTickLabels: provisionalMarginInputs.rightTickLabels,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
tickRotation: getTickRotation(provisionalTickConfiguration),
|
||||
@@ -98,6 +100,7 @@ export const computeChartMargins = <TTickConfig, TValueTickResult>({
|
||||
legendFontSize,
|
||||
bottomTickLabels: marginInputs.bottomTickLabels,
|
||||
leftTickLabels: marginInputs.leftTickLabels,
|
||||
rightTickLabels: marginInputs.rightTickLabels,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
tickRotation: getTickRotation(tickConfiguration),
|
||||
|
||||
+38
@@ -25,10 +25,12 @@ export const computeValueTickValues = ({
|
||||
minimum,
|
||||
maximum,
|
||||
tickCount,
|
||||
preserveDomainBounds = false,
|
||||
}: {
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
tickCount: number;
|
||||
preserveDomainBounds?: boolean;
|
||||
}): {
|
||||
tickValues: number[];
|
||||
domain: { min: number; max: number };
|
||||
@@ -52,6 +54,42 @@ export const computeValueTickValues = ({
|
||||
};
|
||||
}
|
||||
|
||||
if (preserveDomainBounds) {
|
||||
if (minimum > maximum) {
|
||||
return {
|
||||
tickValues: [minimum],
|
||||
domain: { min: minimum, max: minimum },
|
||||
};
|
||||
}
|
||||
|
||||
const tickValues: number[] = [Number(minimum.toFixed(12))];
|
||||
const firstInteriorTick =
|
||||
Math.ceil(minimum / niceStepInterval) * niceStepInterval;
|
||||
|
||||
for (
|
||||
let tickValue = firstInteriorTick;
|
||||
tickValue <= maximum + niceStepInterval / 2;
|
||||
tickValue += niceStepInterval
|
||||
) {
|
||||
const roundedTickValue = Number(tickValue.toFixed(12));
|
||||
|
||||
if (roundedTickValue > minimum && roundedTickValue < maximum) {
|
||||
tickValues.push(roundedTickValue);
|
||||
}
|
||||
}
|
||||
|
||||
const roundedMaximum = Number(maximum.toFixed(12));
|
||||
|
||||
if (tickValues[tickValues.length - 1] !== roundedMaximum) {
|
||||
tickValues.push(roundedMaximum);
|
||||
}
|
||||
|
||||
return {
|
||||
tickValues,
|
||||
domain: { min: minimum, max: maximum },
|
||||
};
|
||||
}
|
||||
|
||||
const niceMinimum = Math.floor(minimum / niceStepInterval) * niceStepInterval;
|
||||
const niceMaximum = Math.ceil(maximum / niceStepInterval) * niceStepInterval;
|
||||
const tickValues: number[] = [];
|
||||
|
||||
+15
-1
@@ -22,6 +22,7 @@ export const getChartMarginsFromText = ({
|
||||
legendFontSize,
|
||||
bottomTickLabels,
|
||||
leftTickLabels,
|
||||
rightTickLabels,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
tickRotation,
|
||||
@@ -31,6 +32,7 @@ export const getChartMarginsFromText = ({
|
||||
legendFontSize?: number;
|
||||
bottomTickLabels?: string[];
|
||||
leftTickLabels?: string[];
|
||||
rightTickLabels?: string[];
|
||||
xAxisLabel?: string;
|
||||
yAxisLabel?: string;
|
||||
tickRotation: number;
|
||||
@@ -40,6 +42,7 @@ export const getChartMarginsFromText = ({
|
||||
|
||||
const bottomMaxLabelLength = getMaxLabelLength(bottomTickLabels);
|
||||
const leftMaxLabelLength = getMaxLabelLength(leftTickLabels);
|
||||
const rightMaxLabelLength = getMaxLabelLength(rightTickLabels);
|
||||
|
||||
const tickPaddingExtra = xAxisLabel ? TEXT_MARGIN_EXTRAS.tickPaddingExtra : 0;
|
||||
const bottomTickHeight =
|
||||
@@ -92,6 +95,17 @@ export const getChartMarginsFromText = ({
|
||||
TEXT_MARGIN_LIMITS.max.left,
|
||||
);
|
||||
|
||||
const rightTickWidth =
|
||||
rightMaxLabelLength > 0
|
||||
? estimateLabelWidth(rightMaxLabelLength, tickFontSize)
|
||||
: 0;
|
||||
const rightTicksBlock =
|
||||
rightTickWidth > 0
|
||||
? rightTickWidth +
|
||||
COMMON_CHART_CONSTANTS.TICK_PADDING +
|
||||
TEXT_MARGIN_EXTRAS.rightTickExtra
|
||||
: 0;
|
||||
|
||||
const topRightBase = Math.ceil(tickFontSize * 1.5);
|
||||
|
||||
return {
|
||||
@@ -101,7 +115,7 @@ export const getChartMarginsFromText = ({
|
||||
TEXT_MARGIN_LIMITS.max.top,
|
||||
),
|
||||
right: clamp(
|
||||
topRightBase,
|
||||
Math.max(topRightBase, rightTicksBlock),
|
||||
TEXT_MARGIN_LIMITS.min.right,
|
||||
TEXT_MARGIN_LIMITS.max.right,
|
||||
),
|
||||
|
||||
-93
@@ -174,71 +174,6 @@ describe('BarChartDataService', () => {
|
||||
expect(result.data[2].amount).toBe(60);
|
||||
});
|
||||
|
||||
it('should filter by rangeMin when cumulative', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['a'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['b'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['c'], aggregateValue: 10 },
|
||||
]);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
isCumulative: true,
|
||||
rangeMin: 15,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data.map((d) => d.amount)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter by rangeMax when cumulative', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['a'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['b'], aggregateValue: 20 },
|
||||
{ groupByDimensionValues: ['c'], aggregateValue: 30 },
|
||||
]);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
isCumulative: true,
|
||||
rangeMax: 25,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data.map((d) => d.amount)).toEqual([10, 30]);
|
||||
});
|
||||
|
||||
it('should filter by both rangeMin and rangeMax when cumulative', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['a'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['b'], aggregateValue: 15 },
|
||||
{ groupByDimensionValues: ['c'], aggregateValue: 25 },
|
||||
{ groupByDimensionValues: ['d'], aggregateValue: 35 },
|
||||
]);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
isCumulative: true,
|
||||
rangeMin: 10,
|
||||
rangeMax: 30,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data.map((d) => d.amount)).toEqual([15, 40]);
|
||||
});
|
||||
|
||||
it('should handle null values when omitNullValues is true', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: [null], aggregateValue: 5 },
|
||||
@@ -404,34 +339,6 @@ describe('BarChartDataService', () => {
|
||||
expect(result.data[2]['Closed']).toBe(60);
|
||||
});
|
||||
|
||||
it('should filter stacked two-dimensional cumulative data by rangeMax', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['a', 'open'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['a', 'closed'], aggregateValue: 20 },
|
||||
{ groupByDimensionValues: ['b', 'open'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['b', 'closed'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['c', 'open'], aggregateValue: 50 },
|
||||
{ groupByDimensionValues: ['c', 'closed'], aggregateValue: 30 },
|
||||
]);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...twoDimConfiguration,
|
||||
isCumulative: true,
|
||||
rangeMax: 60,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data.length).toBe(2);
|
||||
expect(result.data[0]['Open']).toBe(10);
|
||||
expect(result.data[0]['Closed']).toBe(20);
|
||||
expect(result.data[1]['Open']).toBe(15);
|
||||
expect(result.data[1]['Closed']).toBe(25);
|
||||
});
|
||||
|
||||
it('should order keys correctly despite unordered raw results', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['Oct 16', 'open'], aggregateValue: 100 },
|
||||
|
||||
-83
@@ -174,48 +174,6 @@ describe('LineChartDataService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter by rangeMin when cumulative', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['a'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['b'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['c'], aggregateValue: 10 },
|
||||
]);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
isCumulative: true,
|
||||
rangeMin: 15,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.series[0].data.map((d) => d.y)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter by rangeMax when cumulative', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['a'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['b'], aggregateValue: 20 },
|
||||
{ groupByDimensionValues: ['c'], aggregateValue: 30 },
|
||||
]);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
isCumulative: true,
|
||||
rangeMax: 25,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.series[0].data.map((d) => d.y)).toEqual([10, 30]);
|
||||
});
|
||||
|
||||
it('should handle null y values by keeping running total', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['a'], aggregateValue: 10 },
|
||||
@@ -387,47 +345,6 @@ describe('LineChartDataService', () => {
|
||||
expect(seriesB?.data[1].y).toBe(300);
|
||||
});
|
||||
|
||||
it('should filter stacked two-dimensional cumulative data by rangeMax', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['2024-01-01', 'A'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['2024-01-01', 'B'], aggregateValue: 20 },
|
||||
{ groupByDimensionValues: ['2024-02-01', 'A'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['2024-02-01', 'B'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['2024-03-01', 'A'], aggregateValue: 50 },
|
||||
{ groupByDimensionValues: ['2024-03-01', 'B'], aggregateValue: 30 },
|
||||
]);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...twoDimConfiguration,
|
||||
isStacked: true,
|
||||
isCumulative: true,
|
||||
rangeMax: 60,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
const seriesA = result.series.find((s) => s.label === 'A');
|
||||
const seriesB = result.series.find((s) => s.label === 'B');
|
||||
|
||||
expect(seriesA).toBeDefined();
|
||||
expect(seriesB).toBeDefined();
|
||||
expect(seriesA!.data).toHaveLength(2);
|
||||
expect(seriesB!.data).toHaveLength(2);
|
||||
|
||||
const seriesAXValues = seriesA!.data.map((point) => point.x);
|
||||
const seriesBXValues = seriesB!.data.map((point) => point.x);
|
||||
|
||||
expect(seriesAXValues).toEqual(seriesBXValues);
|
||||
expect(new Set(seriesAXValues).size).toBe(2);
|
||||
expect(seriesA!.data[0].y).toBe(10);
|
||||
expect(seriesA!.data[1].y).toBe(15);
|
||||
expect(seriesB!.data[0].y).toBe(20);
|
||||
expect(seriesB!.data[1].y).toBe(25);
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([]);
|
||||
|
||||
|
||||
+2
-35
@@ -30,8 +30,6 @@ import { FieldMetadataOption } from 'src/modules/dashboard/chart-data/types/fiel
|
||||
import { GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
|
||||
import { RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type';
|
||||
import { applyGapFilling } from 'src/modules/dashboard/chart-data/utils/apply-gap-filling.util';
|
||||
import { filterByRange } from 'src/modules/dashboard/chart-data/utils/filter-by-range.util';
|
||||
import { filterTwoDimensionalDataByRange } from 'src/modules/dashboard/chart-data/utils/filter-two-dimensional-data-by-range.util';
|
||||
import { getAggregateOperationLabel } from 'src/modules/dashboard/chart-data/utils/get-aggregate-operation-label.util';
|
||||
import { getFieldMetadata } from 'src/modules/dashboard/chart-data/utils/get-field-metadata.util';
|
||||
import { getSelectOptions } from 'src/modules/dashboard/chart-data/utils/get-select-options.util';
|
||||
@@ -230,21 +228,12 @@ export class BarChartDataService {
|
||||
)
|
||||
: rawResults;
|
||||
|
||||
const rangeFilteredResults =
|
||||
isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax)
|
||||
? filterByRange(
|
||||
filteredResults,
|
||||
configuration.rangeMin,
|
||||
configuration.rangeMax,
|
||||
)
|
||||
: filteredResults;
|
||||
|
||||
const isDescOrder =
|
||||
configuration.primaryAxisOrderBy === GraphOrderBy.FIELD_DESC;
|
||||
|
||||
const { data: gapFilledResults, wasTruncated: dateRangeWasTruncated } =
|
||||
applyGapFilling({
|
||||
data: rangeFilteredResults,
|
||||
data: filteredResults,
|
||||
primaryAxisGroupByField,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
omitNullValues: configuration.omitNullValues ?? false,
|
||||
@@ -370,22 +359,12 @@ export class BarChartDataService {
|
||||
configuration.groupMode ?? BarChartGroupMode.STACKED;
|
||||
const isStacked = effectiveGroupMode === BarChartGroupMode.STACKED;
|
||||
|
||||
const rangeFilteredResults =
|
||||
!isStacked &&
|
||||
(isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax))
|
||||
? filterByRange(
|
||||
filteredResults,
|
||||
configuration.rangeMin,
|
||||
configuration.rangeMax,
|
||||
)
|
||||
: filteredResults;
|
||||
|
||||
const isDescOrder =
|
||||
configuration.primaryAxisOrderBy === GraphOrderBy.FIELD_DESC;
|
||||
|
||||
const { data: gapFilledResults, wasTruncated: dateRangeWasTruncated } =
|
||||
applyGapFilling({
|
||||
data: rangeFilteredResults,
|
||||
data: filteredResults,
|
||||
primaryAxisGroupByField,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
omitNullValues: configuration.omitNullValues ?? false,
|
||||
@@ -508,18 +487,6 @@ export class BarChartDataService {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isStacked &&
|
||||
(isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax))
|
||||
) {
|
||||
finalLimitedData = filterTwoDimensionalDataByRange(
|
||||
finalLimitedData,
|
||||
limitedKeys,
|
||||
configuration.rangeMin,
|
||||
configuration.rangeMax,
|
||||
);
|
||||
}
|
||||
|
||||
const finalData = configuration.isCumulative
|
||||
? this.applyCumulativeTwoDimensional(finalLimitedData, limitedKeys)
|
||||
: finalLimitedData;
|
||||
|
||||
+3
-36
@@ -28,8 +28,6 @@ import { FieldMetadataOption } from 'src/modules/dashboard/chart-data/types/fiel
|
||||
import { GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
|
||||
import { RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type';
|
||||
import { applyGapFilling } from 'src/modules/dashboard/chart-data/utils/apply-gap-filling.util';
|
||||
import { filterByRange } from 'src/modules/dashboard/chart-data/utils/filter-by-range.util';
|
||||
import { filterLineChartXValuesByRange } from 'src/modules/dashboard/chart-data/utils/filter-line-chart-x-values-by-range.util';
|
||||
import { getAggregateOperationLabel } from 'src/modules/dashboard/chart-data/utils/get-aggregate-operation-label.util';
|
||||
import { getFieldMetadata } from 'src/modules/dashboard/chart-data/utils/get-field-metadata.util';
|
||||
import { getSelectOptions } from 'src/modules/dashboard/chart-data/utils/get-select-options.util';
|
||||
@@ -237,21 +235,12 @@ export class LineChartDataService {
|
||||
)
|
||||
: rawResults;
|
||||
|
||||
const rangeFilteredResults =
|
||||
isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax)
|
||||
? filterByRange(
|
||||
filteredResults,
|
||||
configuration.rangeMin,
|
||||
configuration.rangeMax,
|
||||
)
|
||||
: filteredResults;
|
||||
|
||||
const isDescOrder =
|
||||
configuration.primaryAxisOrderBy === GraphOrderBy.FIELD_DESC;
|
||||
|
||||
const { data: gapFilledResults, wasTruncated: dateRangeWasTruncated } =
|
||||
applyGapFilling({
|
||||
data: rangeFilteredResults,
|
||||
data: filteredResults,
|
||||
primaryAxisGroupByField,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
omitNullValues: configuration.omitNullValues ?? false,
|
||||
@@ -366,22 +355,12 @@ export class LineChartDataService {
|
||||
|
||||
const isStacked = configuration.isStacked ?? false;
|
||||
|
||||
const rangeFilteredResults =
|
||||
!isStacked &&
|
||||
(isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax))
|
||||
? filterByRange(
|
||||
filteredResults,
|
||||
configuration.rangeMin,
|
||||
configuration.rangeMax,
|
||||
)
|
||||
: filteredResults;
|
||||
|
||||
const isDescOrder =
|
||||
configuration.primaryAxisOrderBy === GraphOrderBy.FIELD_DESC;
|
||||
|
||||
const { data: gapFilledResults, wasTruncated: dateRangeWasTruncated } =
|
||||
applyGapFilling({
|
||||
data: rangeFilteredResults,
|
||||
data: filteredResults,
|
||||
primaryAxisGroupByField,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
omitNullValues: configuration.omitNullValues ?? false,
|
||||
@@ -496,23 +475,11 @@ export class LineChartDataService {
|
||||
|
||||
const limitedSeriesIds = sortedSeriesIds.slice(0, maxSeries);
|
||||
|
||||
const filteredXValues =
|
||||
isStacked &&
|
||||
(isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax))
|
||||
? filterLineChartXValuesByRange(
|
||||
limitedXValues,
|
||||
seriesMap,
|
||||
limitedSeriesIds,
|
||||
configuration.rangeMin,
|
||||
configuration.rangeMax,
|
||||
)
|
||||
: limitedXValues;
|
||||
|
||||
const series = limitedSeriesIds.map((seriesId) => {
|
||||
const xToYMap = seriesMap.get(seriesId) ?? new Map();
|
||||
const prefixedSeriesId = `${seriesIdPrefix}${seriesId}`;
|
||||
|
||||
let dataPoints = filteredXValues.map((xValue) => ({
|
||||
let dataPoints = limitedXValues.map((xValue) => ({
|
||||
x: xValue,
|
||||
y: xToYMap.get(xValue) ?? 0,
|
||||
}));
|
||||
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
import { filterByRange } from 'src/modules/dashboard/chart-data/utils/filter-by-range.util';
|
||||
|
||||
describe('filterByRange', () => {
|
||||
describe('rangeMin filtering', () => {
|
||||
it('should filter out results below rangeMin', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, 1000);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].aggregateValue).toBe(1500);
|
||||
expect(filtered[1].aggregateValue).toBe(2500);
|
||||
});
|
||||
|
||||
it('should include values equal to rangeMin', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1000 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 1500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, 1000);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].aggregateValue).toBe(1000);
|
||||
expect(filtered[1].aggregateValue).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeMax filtering', () => {
|
||||
it('should filter out results above rangeMax', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, undefined, 2000);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].aggregateValue).toBe(500);
|
||||
expect(filtered[1].aggregateValue).toBe(1500);
|
||||
});
|
||||
|
||||
it('should include values equal to rangeMax', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 2000 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, undefined, 2000);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].aggregateValue).toBe(1500);
|
||||
expect(filtered[1].aggregateValue).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined range filtering', () => {
|
||||
it('should keep only results within range', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, 1000, 2000);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].aggregateValue).toBe(1500);
|
||||
});
|
||||
|
||||
it('should include boundary values', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1000 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['D'], aggregateValue: 2000 },
|
||||
{ groupByDimensionValues: ['E'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, 1000, 2000);
|
||||
|
||||
expect(filtered).toHaveLength(3);
|
||||
expect(filtered[0].aggregateValue).toBe(1000);
|
||||
expect(filtered[1].aggregateValue).toBe(1500);
|
||||
expect(filtered[2].aggregateValue).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no filters', () => {
|
||||
it('should return all results when no range is specified', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results);
|
||||
|
||||
expect(filtered).toEqual(results);
|
||||
});
|
||||
|
||||
it('should return all results when ranges are null', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, null, null);
|
||||
|
||||
expect(filtered).toEqual(results);
|
||||
});
|
||||
|
||||
it('should return all results when ranges are undefined', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, undefined, undefined);
|
||||
|
||||
expect(filtered).toEqual(results);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty results array', () => {
|
||||
const filtered = filterByRange([], 1000, 2000);
|
||||
|
||||
expect(filtered).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 0 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 100 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, 0);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle negative values', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: -100 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 0 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 100 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, -50, 50);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].aggregateValue).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
-225
@@ -1,225 +0,0 @@
|
||||
import { filterTwoDimensionalDataByRange } from 'src/modules/dashboard/chart-data/utils/filter-two-dimensional-data-by-range.util';
|
||||
|
||||
describe('filterTwoDimensionalDataByRange', () => {
|
||||
describe('no filters', () => {
|
||||
it('should return all data when no range is specified', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 200 },
|
||||
{ category: 'B', seriesA: 300, seriesB: 400 },
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys);
|
||||
|
||||
expect(filtered).toEqual(data);
|
||||
});
|
||||
|
||||
it('should return all data when ranges are null', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 200 },
|
||||
{ category: 'B', seriesA: 300, seriesB: 400 },
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, null, null);
|
||||
|
||||
expect(filtered).toEqual(data);
|
||||
});
|
||||
|
||||
it('should return all data when ranges are undefined', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 200 },
|
||||
{ category: 'B', seriesA: 300, seriesB: 400 },
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(
|
||||
data,
|
||||
keys,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(filtered).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeMin filtering', () => {
|
||||
it('should filter out data with total below rangeMin', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
|
||||
{ category: 'B', seriesA: 300, seriesB: 200 }, // total: 500
|
||||
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 400);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].category).toBe('B');
|
||||
expect(filtered[1].category).toBe('C');
|
||||
});
|
||||
|
||||
it('should include data with total equal to rangeMin', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
|
||||
{ category: 'B', seriesA: 200, seriesB: 200 }, // total: 400
|
||||
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 400);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].category).toBe('B');
|
||||
expect(filtered[1].category).toBe('C');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeMax filtering', () => {
|
||||
it('should filter out data with total above rangeMax', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
|
||||
{ category: 'B', seriesA: 300, seriesB: 200 }, // total: 500
|
||||
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(
|
||||
data,
|
||||
keys,
|
||||
undefined,
|
||||
600,
|
||||
);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].category).toBe('A');
|
||||
expect(filtered[1].category).toBe('B');
|
||||
});
|
||||
|
||||
it('should include data with total equal to rangeMax', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
|
||||
{ category: 'B', seriesA: 300, seriesB: 200 }, // total: 500
|
||||
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(
|
||||
data,
|
||||
keys,
|
||||
undefined,
|
||||
500,
|
||||
);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].category).toBe('A');
|
||||
expect(filtered[1].category).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined range filtering', () => {
|
||||
it('should keep only data within range', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
|
||||
{ category: 'B', seriesA: 300, seriesB: 200 }, // total: 500
|
||||
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 300, 800);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].category).toBe('B');
|
||||
});
|
||||
|
||||
it('should include boundary values', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
|
||||
{ category: 'B', seriesA: 150, seriesB: 150 }, // total: 300
|
||||
{ category: 'C', seriesA: 300, seriesB: 200 }, // total: 500
|
||||
{ category: 'D', seriesA: 400, seriesB: 400 }, // total: 800
|
||||
{ category: 'E', seriesA: 500, seriesB: 500 }, // total: 1000
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 300, 800);
|
||||
|
||||
expect(filtered).toHaveLength(3);
|
||||
expect(filtered[0].category).toBe('B');
|
||||
expect(filtered[1].category).toBe('C');
|
||||
expect(filtered[2].category).toBe('D');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty data array', () => {
|
||||
const filtered = filterTwoDimensionalDataByRange(
|
||||
[],
|
||||
['seriesA'],
|
||||
100,
|
||||
200,
|
||||
);
|
||||
|
||||
expect(filtered).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty keys array', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100 },
|
||||
{ category: 'B', seriesA: 200 },
|
||||
];
|
||||
|
||||
// With empty keys, total is always 0
|
||||
const filtered = filterTwoDimensionalDataByRange(data, [], 0, 0);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should ignore non-numeric values when summing', () => {
|
||||
const data = [
|
||||
{
|
||||
category: 'A',
|
||||
seriesA: 100,
|
||||
seriesB: 'invalid' as unknown as number,
|
||||
},
|
||||
{ category: 'B', seriesA: 300, seriesB: 200 },
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
// category A total is 100 (invalid is treated as 0)
|
||||
// category B total is 500
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 200);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].category).toBe('B');
|
||||
});
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 0, seriesB: 0 }, // total: 0
|
||||
{ category: 'B', seriesA: 0, seriesB: 100 }, // total: 100
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 0, 50);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].category).toBe('A');
|
||||
});
|
||||
|
||||
it('should handle single key', () => {
|
||||
const data = [
|
||||
{ category: 'A', value: 100 },
|
||||
{ category: 'B', value: 300 },
|
||||
{ category: 'C', value: 500 },
|
||||
];
|
||||
const keys = ['value'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 200, 400);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].category).toBe('B');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
|
||||
|
||||
export const filterByRange = (
|
||||
results: GroupByRawResult[],
|
||||
rangeMin?: number | null,
|
||||
rangeMax?: number | null,
|
||||
): GroupByRawResult[] => {
|
||||
return results.filter((result) => {
|
||||
const value = result.aggregateValue;
|
||||
|
||||
if (isDefined(rangeMin) && value < rangeMin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDefined(rangeMax) && value > rangeMax) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const filterLineChartXValuesByRange = (
|
||||
xValues: string[],
|
||||
seriesMap: Map<string, Map<string, number>>,
|
||||
seriesIds: string[],
|
||||
rangeMin?: number | null,
|
||||
rangeMax?: number | null,
|
||||
): string[] => {
|
||||
if (!isDefined(rangeMin) && !isDefined(rangeMax)) {
|
||||
return xValues;
|
||||
}
|
||||
|
||||
return xValues.filter((xValue) => {
|
||||
const totalValue = seriesIds.reduce((sum, seriesId) => {
|
||||
const xToYMap = seriesMap.get(seriesId) ?? new Map();
|
||||
const yValue = xToYMap.get(xValue) ?? 0;
|
||||
|
||||
return sum + (isNumber(yValue) ? yValue : 0);
|
||||
}, 0);
|
||||
|
||||
if (isDefined(rangeMin) && totalValue < rangeMin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDefined(rangeMax) && totalValue > rangeMax) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const filterTwoDimensionalDataByRange = <
|
||||
T extends Record<string, string | number>,
|
||||
>(
|
||||
data: T[],
|
||||
keys: string[],
|
||||
rangeMin?: number | null,
|
||||
rangeMax?: number | null,
|
||||
): T[] => {
|
||||
if (!isDefined(rangeMin) && !isDefined(rangeMax)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data.filter((datum) => {
|
||||
const totalValue = keys.reduce((sum, key) => {
|
||||
const value = datum[key];
|
||||
|
||||
return sum + (isNumber(value) ? value : 0);
|
||||
}, 0);
|
||||
|
||||
if (isDefined(rangeMin) && totalValue < rangeMin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDefined(rangeMax) && totalValue > rangeMax) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user