Merge pull request #220 from buster-so/evals

Evals
This commit is contained in:
Nate Kelley 2025-04-22 16:53:01 -06:00 committed by GitHub
commit e3c1bb9d8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 9291 additions and 111 deletions

View File

@ -1,3 +1,4 @@
import { AppPageLayout } from '@/components/ui/layouts';
import { checkIfUserIsAdmin_server } from '@/context/Users/checkIfUserIsAdmin'; import { checkIfUserIsAdmin_server } from '@/context/Users/checkIfUserIsAdmin';
import { BusterRoutes, createBusterRoute } from '@/routes/busterRoutes'; import { BusterRoutes, createBusterRoute } from '@/routes/busterRoutes';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
@ -14,5 +15,5 @@ export default async function Layout({ children }: { children: React.ReactNode }
); );
} }
return <>{children}</>; return <AppPageLayout scrollable>{children}</AppPageLayout>;
} }

View File

@ -33,7 +33,7 @@ export default function Page() {
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
<div className="px-[30px] pt-[46px]"> <div className="px-[30px] pt-[46px]">
<SettingsPageHeader <SettingsPageHeader
title="User Management" title="User management"
description="Manage your organization's users and their permissions" description="Manage your organization's users and their permissions"
type="alternate" type="alternate"
/> />

View File

@ -88,8 +88,8 @@ export const LoginForm: React.FC<{}> = ({}) => {
if (res?.error) throw res.error; if (res?.error) throw res.error;
} catch (error: any) { } catch (error: any) {
errorFallback(error); errorFallback(error);
setLoading(null);
} }
setLoading('azure');
}); });
const onSignUp = useMemoizedFn(async (d: { email: string; password: string }) => { const onSignUp = useMemoizedFn(async (d: { email: string; password: string }) => {
@ -211,18 +211,11 @@ const LoginOptions: React.FC<{
<WelcomeText signUpFlow={signUpFlow} /> <WelcomeText signUpFlow={signUpFlow} />
</div> </div>
<form <div className="mt-6 mb-4 flex flex-col space-y-3">
className="my-6 space-y-3"
onSubmit={(v) => {
v.preventDefault();
onSubmitClickPreflight({
email,
password
});
}}>
<Button <Button
prefix={<Google />} prefix={<Google />}
size={'tall'} size={'tall'}
type="button"
onClick={() => { onClick={() => {
clearAllCookies(); clearAllCookies();
onSignInWithGoogle(); onSignInWithGoogle();
@ -235,6 +228,7 @@ const LoginOptions: React.FC<{
<Button <Button
prefix={<Github />} prefix={<Github />}
size={'tall'} size={'tall'}
type="button"
onClick={() => { onClick={() => {
clearAllCookies(); clearAllCookies();
onSignInWithGithub(); onSignInWithGithub();
@ -247,6 +241,7 @@ const LoginOptions: React.FC<{
<Button <Button
prefix={<Microsoft />} prefix={<Microsoft />}
size={'tall'} size={'tall'}
type="button"
onClick={() => { onClick={() => {
clearAllCookies(); clearAllCookies();
onSignInWithAzure(); onSignInWithAzure();
@ -256,8 +251,18 @@ const LoginOptions: React.FC<{
tabIndex={3}> tabIndex={3}>
{!signUpFlow ? `Continue with Azure` : `Sign up with Azure`} {!signUpFlow ? `Continue with Azure` : `Sign up with Azure`}
</Button> </Button>
</div>
<div className="bg-border h-[0.5px] w-full" /> <form
className="space-y-3"
onSubmit={(v) => {
v.preventDefault();
onSubmitClickPreflight({
email,
password
});
}}>
<div className="bg-border mb-4 h-[0.5px] w-full" />
<Input <Input
type="email" type="email"
@ -333,7 +338,7 @@ const LoginOptions: React.FC<{
</Button> </Button>
</form> </form>
<div className="flex flex-col gap-y-2 pt-0"> <div className="mt-2 flex flex-col gap-y-2">
<AlreadyHaveAccount <AlreadyHaveAccount
setErrorMessages={setErrorMessages} setErrorMessages={setErrorMessages}
setPassword2={setPassword2} setPassword2={setPassword2}

View File

@ -16,9 +16,11 @@ export const AvatarUserButton = React.forwardRef<
ref={ref} ref={ref}
className="hover:bg-item-hover active:bg-item-active flex w-full cursor-pointer items-center gap-x-2 rounded-md p-2"> className="hover:bg-item-hover active:bg-item-active flex w-full cursor-pointer items-center gap-x-2 rounded-md p-2">
<Avatar size={32} fallbackClassName="text-base" image={avatarUrl} name={username} /> <Avatar size={32} fallbackClassName="text-base" image={avatarUrl} name={username} />
<div className="flex flex-col gap-y-0.5"> <div className="flex w-full flex-col gap-y-0.5 overflow-hidden">
<Text className="flex-grow">{username}</Text> <Text truncate className="flex-grow">
<Text size={'sm'} variant={'secondary'}> {username}
</Text>
<Text truncate size={'sm'} variant={'secondary'}>
{email} {email}
</Text> </Text>
</div> </div>

View File

@ -22,13 +22,15 @@ export const BusterChartComponent: React.FC<BusterChartRenderComponentProps> = (
selectedChartType, selectedChartType,
selectedAxis selectedAxis
} = props; } = props;
const { const {
datasetOptions, datasetOptions,
dataTrendlineOptions, dataTrendlineOptions,
y2AxisKeys, y2AxisKeys,
yAxisKeys, yAxisKeys,
tooltipKeys, tooltipKeys,
hasMismatchedTooltipsAndMeasures hasMismatchedTooltipsAndMeasures,
isDownsampled
} = useDatasetOptions({ } = useDatasetOptions({
data: dataProp, data: dataProp,
selectedAxis, selectedAxis,
@ -52,7 +54,8 @@ export const BusterChartComponent: React.FC<BusterChartRenderComponentProps> = (
y2AxisKeys, y2AxisKeys,
yAxisKeys, yAxisKeys,
tooltipKeys, tooltipKeys,
hasMismatchedTooltipsAndMeasures hasMismatchedTooltipsAndMeasures,
isDownsampled
}), }),
[ [
props, props,
@ -62,7 +65,8 @@ export const BusterChartComponent: React.FC<BusterChartRenderComponentProps> = (
y2AxisKeys, y2AxisKeys,
yAxisKeys, yAxisKeys,
hasMismatchedTooltipsAndMeasures, hasMismatchedTooltipsAndMeasures,
tooltipKeys tooltipKeys,
isDownsampled
] ]
); );

View File

@ -6,7 +6,7 @@ import React, { useCallback, useRef, useState } from 'react';
import { DEFAULT_CHART_CONFIG, DEFAULT_COLUMN_METADATA } from '@/api/asset_interfaces/metric'; import { DEFAULT_CHART_CONFIG, DEFAULT_COLUMN_METADATA } from '@/api/asset_interfaces/metric';
import { BusterChartJSLegendWrapper } from './BusterChartJSLegendWrapper'; import { BusterChartJSLegendWrapper } from './BusterChartJSLegendWrapper';
import { ChartJSOrUndefined } from './core/types'; import { ChartJSOrUndefined } from './core/types';
import { useMemoizedFn, useMount } from '@/hooks'; import { useMemoizedFn } from '@/hooks';
import { BusterChartJSComponent } from './BusterChartJSComponent'; import { BusterChartJSComponent } from './BusterChartJSComponent';
import type { BusterChartComponentProps } from '../interfaces'; import type { BusterChartComponentProps } from '../interfaces';
@ -59,6 +59,7 @@ export const BusterChartJS: React.FC<BusterChartComponentProps> = ({
colors={colors} colors={colors}
chartRef={chartRef} chartRef={chartRef}
datasetOptions={datasetOptions} datasetOptions={datasetOptions}
isDownsampled={props.isDownsampled}
pieMinimumSlicePercentage={pieMinimumSlicePercentage}> pieMinimumSlicePercentage={pieMinimumSlicePercentage}>
<BusterChartJSComponent <BusterChartJSComponent
ref={chartRef} ref={chartRef}

View File

@ -17,7 +17,6 @@ import { useChartSpecificOptions } from './hooks/useChartSpecificOptions';
import { BusterChartTypeComponentProps } from '../interfaces/chartComponentInterfaces'; import { BusterChartTypeComponentProps } from '../interfaces/chartComponentInterfaces';
import { useTrendlines } from './hooks/useTrendlines'; import { useTrendlines } from './hooks/useTrendlines';
import { ScatterAxis } from '@/api/asset_interfaces/metric/charts'; import { ScatterAxis } from '@/api/asset_interfaces/metric/charts';
import { useMount } from '@/hooks';
export const BusterChartJSComponent = React.memo( export const BusterChartJSComponent = React.memo(
React.forwardRef<ChartJSOrUndefined, BusterChartTypeComponentProps>( React.forwardRef<ChartJSOrUndefined, BusterChartTypeComponentProps>(

View File

@ -24,6 +24,7 @@ interface BusterChartJSLegendWrapperProps {
chartRef: React.RefObject<ChartJSOrUndefined | null>; chartRef: React.RefObject<ChartJSOrUndefined | null>;
datasetOptions: DatasetOption[]; datasetOptions: DatasetOption[];
pieMinimumSlicePercentage: NonNullable<BusterChartProps['pieMinimumSlicePercentage']>; pieMinimumSlicePercentage: NonNullable<BusterChartProps['pieMinimumSlicePercentage']>;
isDownsampled: boolean;
} }
export const BusterChartJSLegendWrapper = React.memo<BusterChartJSLegendWrapperProps>( export const BusterChartJSLegendWrapper = React.memo<BusterChartJSLegendWrapperProps>(
@ -45,7 +46,8 @@ export const BusterChartJSLegendWrapper = React.memo<BusterChartJSLegendWrapperP
barGroupType, barGroupType,
colors, colors,
datasetOptions, datasetOptions,
pieMinimumSlicePercentage pieMinimumSlicePercentage,
isDownsampled
}) => { }) => {
const { const {
renderLegend, renderLegend,
@ -81,6 +83,7 @@ export const BusterChartJSLegendWrapper = React.memo<BusterChartJSLegendWrapperP
renderLegend={renderLegend} renderLegend={renderLegend}
legendItems={legendItems} legendItems={legendItems}
showLegend={showLegend} showLegend={showLegend}
isDownsampled={isDownsampled}
showLegendHeadline={showLegendHeadline} showLegendHeadline={showLegendHeadline}
inactiveDatasets={inactiveDatasets} inactiveDatasets={inactiveDatasets}
onHoverItem={onHoverItem} onHoverItem={onHoverItem}

View File

@ -113,7 +113,7 @@ ChartJS.defaults.font = {
scale.ticks.autoSkipPadding = 4; scale.ticks.autoSkipPadding = 4;
scale.ticks.align = 'center'; scale.ticks.align = 'center';
scale.ticks.callback = function (value, index, values) { scale.ticks.callback = function (value, index, values) {
return truncateText(this.getLabelForValue(index), 18); return truncateText(this.getLabelForValue(value as number), 18);
}; };
}); });

View File

@ -8,7 +8,7 @@ import {
ChartType, ChartType,
ComboChartAxis ComboChartAxis
} from '@/api/asset_interfaces/metric/charts'; } from '@/api/asset_interfaces/metric/charts';
import { useDebounceEffect, useMemoizedFn } from '@/hooks'; import { useDebounceEffect, useDebounceFn, useMemoizedFn } from '@/hooks';
import type { IBusterMetricChartConfig } from '@/api/asset_interfaces/metric'; import type { IBusterMetricChartConfig } from '@/api/asset_interfaces/metric';
import { import {
addLegendHeadlines, addLegendHeadlines,
@ -18,7 +18,8 @@ import {
} from '../../../BusterChartLegend'; } from '../../../BusterChartLegend';
import { getLegendItems } from './helper'; import { getLegendItems } from './helper';
import { DatasetOption } from '../../../chartHooks'; import { DatasetOption } from '../../../chartHooks';
import { ANIMATION_THRESHOLD } from '../../../config'; import { ANIMATION_THRESHOLD, LEGEND_ANIMATION_THRESHOLD } from '../../../config';
import { timeout } from '@/lib';
interface UseBusterChartJSLegendProps { interface UseBusterChartJSLegendProps {
chartRef: React.RefObject<ChartJSOrUndefined | null>; chartRef: React.RefObject<ChartJSOrUndefined | null>;
@ -57,6 +58,9 @@ export const useBusterChartJSLegend = ({
}: UseBusterChartJSLegendProps): UseChartLengendReturnValues => { }: UseBusterChartJSLegendProps): UseChartLengendReturnValues => {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [isUpdatingChart, setIsUpdatingChart] = useState(false); const [isUpdatingChart, setIsUpdatingChart] = useState(false);
const [numberOfDataPoints, setNumberOfDataPoints] = useState(0);
const isLargeDataset = numberOfDataPoints > LEGEND_ANIMATION_THRESHOLD;
const legendTimeoutDuration = isLargeDataset ? 95 : 0;
const { const {
inactiveDatasets, inactiveDatasets,
@ -105,6 +109,13 @@ export const useBusterChartJSLegend = ({
); );
} }
const numberOfPoints =
chartRef.current?.data.datasets.reduce<number>((acc, dataset) => {
if (dataset.hidden) return acc;
return acc + dataset.data.length;
}, 0) || 0;
setNumberOfDataPoints(numberOfPoints);
startTransition(() => { startTransition(() => {
setLegendItems(items); setLegendItems(items);
}); });
@ -144,19 +155,33 @@ export const useBusterChartJSLegend = ({
chartjs.update(); chartjs.update();
}); });
const onLegendItemClick = useMemoizedFn((item: BusterChartLegendItem) => { const { run: debouncedChartUpdate } = useDebounceFn(
useMemoizedFn((timeoutDuration: number) => {
const chartjs = chartRef.current;
if (!chartjs) return;
// Schedule the heavy update operation with minimal delay to allow UI to remain responsive
setTimeout(() => {
startTransition(() => {
chartjs.update();
// Set a timeout to turn off loading state after the update is complete
requestAnimationFrame(() => {
setIsUpdatingChart(false);
});
});
}, timeoutDuration);
}),
{ wait: isLargeDataset ? 250 : 0 }
);
const onLegendItemClick = useMemoizedFn(async (item: BusterChartLegendItem) => {
const chartjs = chartRef.current; const chartjs = chartRef.current;
if (!chartjs) return; if (!chartjs) return;
const data = chartjs.data; const data = chartjs.data;
const hasAnimation = chartjs.options.animation !== false;
const numberOfPoints = data.datasets.reduce((acc, dataset) => acc + dataset.data.length, 0);
const isLargeChart = numberOfPoints > ANIMATION_THRESHOLD;
const timeoutDuration = isLargeChart && hasAnimation ? 125 : 0;
// Set updating state // Set updating state
if (timeoutDuration) setIsUpdatingChart(true); if (legendTimeoutDuration) setIsUpdatingChart(true);
// Update dataset visibility state // Update dataset visibility state
setInactiveDatasets((prev) => ({ setInactiveDatasets((prev) => ({
@ -164,6 +189,8 @@ export const useBusterChartJSLegend = ({
[item.id]: prev[item.id] ? !prev[item.id] : true [item.id]: prev[item.id] ? !prev[item.id] : true
})); }));
await timeout(legendTimeoutDuration);
// Defer visual updates to prevent UI blocking // Defer visual updates to prevent UI blocking
requestAnimationFrame(() => { requestAnimationFrame(() => {
// This is a synchronous, lightweight operation that toggles visibility flags // This is a synchronous, lightweight operation that toggles visibility flags
@ -177,53 +204,48 @@ export const useBusterChartJSLegend = ({
} }
} }
// Schedule the heavy update operation with minimal delay to allow UI to remain responsive debouncedChartUpdate(legendTimeoutDuration);
setTimeout(() => {
startTransition(() => {
chartjs.update();
// Set a timeout to turn off loading state after the update is complete
requestAnimationFrame(() => {
setIsUpdatingChart(false);
});
});
}, timeoutDuration);
}); });
}); });
const onLegendItemFocus = useMemoizedFn((item: BusterChartLegendItem) => { const onLegendItemFocus = useMemoizedFn(async (item: BusterChartLegendItem) => {
const chartjs = chartRef.current; const chartjs = chartRef.current;
if (!chartjs) return; if (!chartjs) return;
const datasets = chartjs.data.datasets.filter((dataset) => !dataset.hidden); if (legendTimeoutDuration) setIsUpdatingChart(true);
const hasMultipleDatasets = datasets?.length > 1;
const assosciatedDatasetIndex = datasets?.findIndex((dataset) => dataset.label === item.id);
if (hasMultipleDatasets) { // Defer visual updates to prevent UI blocking
const hasOtherDatasetsVisible = datasets?.some( requestAnimationFrame(() => {
(dataset, index) => const datasets = chartjs.data.datasets.filter((dataset) => !dataset.hidden);
dataset.label !== item.id && chartjs.isDatasetVisible(index) && !dataset.hidden const hasMultipleDatasets = datasets?.length > 1;
); const assosciatedDatasetIndex = datasets?.findIndex((dataset) => dataset.label === item.id);
const inactiveDatasetsRecord: Record<string, boolean> = {};
if (hasOtherDatasetsVisible) { if (hasMultipleDatasets) {
datasets?.forEach((dataset, index) => { const hasOtherDatasetsVisible = datasets?.some(
const value = index === assosciatedDatasetIndex; (dataset, index) =>
chartjs.setDatasetVisibility(index, value); dataset.label !== item.id && chartjs.isDatasetVisible(index) && !dataset.hidden
inactiveDatasetsRecord[dataset.label!] = !value; );
}); const inactiveDatasetsRecord: Record<string, boolean> = {};
} else { if (hasOtherDatasetsVisible) {
datasets?.forEach((dataset, index) => { datasets?.forEach((dataset, index) => {
chartjs.setDatasetVisibility(index, true); const value = index === assosciatedDatasetIndex;
inactiveDatasetsRecord[dataset.label!] = false; chartjs.setDatasetVisibility(index, value);
}); inactiveDatasetsRecord[dataset.label!] = !value;
});
} else {
datasets?.forEach((dataset, index) => {
chartjs.setDatasetVisibility(index, true);
inactiveDatasetsRecord[dataset.label!] = false;
});
}
setInactiveDatasets((prev) => ({
...prev,
...inactiveDatasetsRecord
}));
} }
setInactiveDatasets((prev) => ({
...prev,
...inactiveDatasetsRecord
}));
chartjs.update(); debouncedChartUpdate(legendTimeoutDuration);
} });
}); });
useDebounceEffect( useDebounceEffect(
@ -231,7 +253,7 @@ export const useBusterChartJSLegend = ({
calculateLegendItems(); calculateLegendItems();
}, },
[selectedChartType], [selectedChartType],
{ wait: 3 } { wait: 4 }
); );
//immediate items //immediate items

View File

@ -167,8 +167,8 @@ export const useOptions = ({
const interaction = useInteractions({ selectedChartType, barLayout }); const interaction = useInteractions({ selectedChartType, barLayout });
const numberOfSources = useMemo(() => { const numberOfSources = useMemo(() => {
return datasetOptions.reduce((acc, curr) => { return datasetOptions.reduce((acc, dataset) => {
return acc + curr.source.length; return acc + dataset.source.length;
}, 0); }, 0);
}, [datasetOptions]); }, [datasetOptions]);

View File

@ -34,6 +34,8 @@ interface UseTooltipOptionsProps {
colors: string[]; colors: string[];
} }
const MAX_TOOLTIP_CACHE_SIZE = 30;
export const useTooltipOptions = ({ export const useTooltipOptions = ({
columnLabelFormats, columnLabelFormats,
selectedChartType, selectedChartType,
@ -129,7 +131,6 @@ export const useTooltipOptions = ({
selectedChartType, selectedChartType,
matchedCacheItem, matchedCacheItem,
keyToUsePercentage, keyToUsePercentage,
columnSettings,
hasCategoryAxis, hasCategoryAxis,
hasMultipleMeasures, hasMultipleMeasures,
barGroupType, barGroupType,
@ -137,6 +138,9 @@ export const useTooltipOptions = ({
); );
if (result) { if (result) {
if (Object.keys(tooltipCache.current).length > MAX_TOOLTIP_CACHE_SIZE) {
tooltipCache.current = {};
}
tooltipCache.current[key] = result; tooltipCache.current[key] = result;
} }
}); });
@ -225,7 +229,6 @@ const externalTooltip = (
selectedChartType: NonNullable<BusterChartProps['selectedChartType']>, selectedChartType: NonNullable<BusterChartProps['selectedChartType']>,
matchedCacheItem: string | undefined, matchedCacheItem: string | undefined,
keyToUsePercentage: string[], keyToUsePercentage: string[],
columnSettings: NonNullable<BusterChartProps['columnSettings']>,
hasCategoryAxis: boolean, hasCategoryAxis: boolean,
hasMultipleMeasures: boolean, hasMultipleMeasures: boolean,
barGroupType: BusterChartProps['barGroupType'], barGroupType: BusterChartProps['barGroupType'],

View File

@ -7,6 +7,7 @@ import {
} from '../chartHooks/useChartWrapperProvider'; } from '../chartHooks/useChartWrapperProvider';
import { cn } from '@/lib/classMerge'; import { cn } from '@/lib/classMerge';
import { CircleSpinnerLoader } from '../../loaders'; import { CircleSpinnerLoader } from '../../loaders';
import { DownsampleAlert } from './DownsampleAlert';
export type BusterChartLegendWrapper = { export type BusterChartLegendWrapper = {
children: React.ReactNode; children: React.ReactNode;
@ -18,6 +19,7 @@ export type BusterChartLegendWrapper = {
className: string | undefined; className: string | undefined;
animateLegend: boolean; animateLegend: boolean;
isUpdatingChart?: boolean; isUpdatingChart?: boolean;
isDownsampled: boolean;
onHoverItem: (item: BusterChartLegendItem, isHover: boolean) => void; onHoverItem: (item: BusterChartLegendItem, isHover: boolean) => void;
onLegendItemClick: (item: BusterChartLegendItem) => void; onLegendItemClick: (item: BusterChartLegendItem) => void;
onLegendItemFocus: ((item: BusterChartLegendItem) => void) | undefined; onLegendItemFocus: ((item: BusterChartLegendItem) => void) | undefined;
@ -34,6 +36,7 @@ export const BusterChartLegendWrapper: React.FC<BusterChartLegendWrapper> = Reac
animateLegend, animateLegend,
className, className,
isUpdatingChart, isUpdatingChart,
isDownsampled,
onHoverItem, onHoverItem,
onLegendItemClick, onLegendItemClick,
onLegendItemFocus onLegendItemFocus
@ -42,7 +45,8 @@ export const BusterChartLegendWrapper: React.FC<BusterChartLegendWrapper> = Reac
return ( return (
<ChartLegendWrapperProvider inactiveDatasets={inactiveDatasets}> <ChartLegendWrapperProvider inactiveDatasets={inactiveDatasets}>
<div className={cn(className, 'flex h-full w-full flex-col overflow-hidden')}> <div
className={cn('legend-wrapper flex h-full w-full flex-col overflow-hidden', className)}>
{renderLegend && ( {renderLegend && (
<BusterChartLegend <BusterChartLegend
show={showLegend} show={showLegend}
@ -56,9 +60,10 @@ export const BusterChartLegendWrapper: React.FC<BusterChartLegendWrapper> = Reac
/> />
)} )}
<div className="relative flex h-full w-full items-center justify-center overflow-hidden"> <div className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden">
{isUpdatingChart && <LoadingOverlay />} {isUpdatingChart && <LoadingOverlay />}
{children} {children}
{isDownsampled && <DownsampleAlert isDownsampled={isDownsampled} />}
</div> </div>
</div> </div>
</ChartLegendWrapperProvider> </ChartLegendWrapperProvider>

View File

@ -0,0 +1,65 @@
import React, { useEffect, useState } from 'react';
import { Text } from '../../typography/Text';
import { TriangleWarning } from '../../icons/NucleoIconFilled';
import { Popover } from '../../popover';
import { DOWNSIZE_SAMPLE_THRESHOLD } from '../config';
import { cn } from '@/lib/classMerge';
import { Xmark } from '../../icons';
export const DownsampleAlert = React.memo(({ isDownsampled }: { isDownsampled: boolean }) => {
const [close, setClose] = useState(false);
const [onHover, setOnHover] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
setClose(false);
}, [isDownsampled]);
if (close) {
return null;
}
return (
<div
className="absolute right-0 bottom-0 left-0 w-full px-1 pb-0"
onMouseEnter={() => setOnHover(true)}
onMouseLeave={() => setOnHover(false)}>
<Popover
align="center"
side="top"
open={open}
sideOffset={8}
onOpenChange={setOpen}
content={
<div className="max-w-68">
<Text>{`This chart has been downsampled to ${DOWNSIZE_SAMPLE_THRESHOLD} data points to improve performance. Click the results tab or download the data to see all points.`}</Text>
</div>
}>
<div
onClick={() => open && setClose(true)}
className={cn(
'group relative z-10 flex h-6 w-full cursor-pointer items-center justify-center overflow-hidden rounded-sm border border-yellow-300 bg-yellow-200 px-1.5 text-sm text-yellow-700 shadow transition-all duration-300 hover:bg-yellow-300'
)}>
<div
className={cn(
'absolute flex items-center space-x-1 transition-all duration-300',
open ? 'scale-50 opacity-0' : 'scale-100 opacity-100'
)}>
<TriangleWarning />
<span>Downsampled</span>
</div>
<div
className={cn(
'absolute flex items-center space-x-1 transition-all duration-300',
!open ? 'scale-50 opacity-0' : 'scale-100 opacity-100'
)}>
<Xmark />
<span>Close</span>
</div>
</div>
</Popover>
</div>
);
});
DownsampleAlert.displayName = 'DownsampleAlert';

View File

@ -1,8 +1,4 @@
import { import { type BusterChartProps, type ScatterAxis } from '@/api/asset_interfaces/metric/charts';
type BusterChartProps,
type ColumnLabelFormat,
type ScatterAxis
} from '@/api/asset_interfaces/metric/charts';
import { createDimension } from './datasetHelpers_BarLinePie'; import { createDimension } from './datasetHelpers_BarLinePie';
import { appendToKeyValueChain } from './groupingHelpers'; import { appendToKeyValueChain } from './groupingHelpers';
import { DatasetOption } from './interfaces'; import { DatasetOption } from './interfaces';
@ -68,10 +64,7 @@ export const processScatterData = (
measureFields.forEach((measure) => { measureFields.forEach((measure) => {
categories.forEach((category) => { categories.forEach((category) => {
const columnLabelFormat = columnLabelFormats[measure]; const columnLabelFormat = columnLabelFormats[measure];
const replaceMissingDataWith = const replaceMissingDataWith = defaultReplaceMissingDataWith;
columnLabelFormat?.replaceMissingDataWith !== undefined
? columnLabelFormat?.replaceMissingDataWith
: defaultReplaceMissingDataWith;
if (categoryKey === category) { if (categoryKey === category) {
const value = item[measure] || replaceMissingDataWith; const value = item[measure] || replaceMissingDataWith;
row.push(value as string | number); row.push(value as string | number);

View File

@ -34,6 +34,7 @@ import {
import { type TrendlineDataset, useDataTrendlineOptions } from './useDataTrendlineOptions'; import { type TrendlineDataset, useDataTrendlineOptions } from './useDataTrendlineOptions';
import type { DatasetOption } from './interfaces'; import type { DatasetOption } from './interfaces';
import { DEFAULT_COLUMN_LABEL_FORMAT } from '@/api/asset_interfaces/metric'; import { DEFAULT_COLUMN_LABEL_FORMAT } from '@/api/asset_interfaces/metric';
import { DOWNSIZE_SAMPLE_THRESHOLD } from '../../config';
type DatasetHookResult = { type DatasetHookResult = {
datasetOptions: DatasetOption[]; datasetOptions: DatasetOption[];
@ -42,6 +43,7 @@ type DatasetHookResult = {
y2AxisKeys: string[]; y2AxisKeys: string[];
tooltipKeys: string[]; tooltipKeys: string[];
hasMismatchedTooltipsAndMeasures: boolean; hasMismatchedTooltipsAndMeasures: boolean;
isDownsampled: boolean;
}; };
type DatasetHookParams = { type DatasetHookParams = {
@ -125,6 +127,10 @@ export const useDatasetOptions = (params: DatasetHookParams): DatasetHookResult
return uniq([...yAxisFields, ...y2AxisFields, ...tooltipFields]); return uniq([...yAxisFields, ...y2AxisFields, ...tooltipFields]);
}, [yAxisFieldsString, y2AxisFieldsString, tooltipFieldsString]); }, [yAxisFieldsString, y2AxisFieldsString, tooltipFieldsString]);
const isDownsampled = useMemo(() => {
return data.length > DOWNSIZE_SAMPLE_THRESHOLD;
}, [data]);
const sortedAndLimitedData = useMemo(() => { const sortedAndLimitedData = useMemo(() => {
if (isScatter) return downsampleScatterData(data); if (isScatter) return downsampleScatterData(data);
return sortLineBarData(data, columnMetadata, xFieldSorts, xFields); return sortLineBarData(data, columnMetadata, xFieldSorts, xFields);
@ -139,18 +145,25 @@ export const useDatasetOptions = (params: DatasetHookParams): DatasetHookResult
return measureFields return measureFields
.map((field) => { .map((field) => {
const value = columnLabelFormats[field]?.replaceMissingDataWith; const value = columnLabelFormats[field]?.replaceMissingDataWith;
if (value === undefined) return 0; if (value === undefined) return 'undefined';
if (value === null) return null; if (value === null) return 'null';
if (value === '') return ''; if (value === '') return 'empty';
return value; return value;
}) })
.join(','); .join(',');
}, [measureFields.join(''), columnLabelFormats]); }, [measureFields.join(''), columnLabelFormats]);
const dimensions: string[] = useMemo(() => {
if (isScatter) {
return getScatterDimensions(categoriesSet, xAxisField, measureFields, sizeField);
}
return getLineBarPieDimensions(categoriesSet, measureFields, xFields);
}, [categoriesSet, measureFields, xFieldsString, sizeFieldString, isScatter]);
const processedData = useMemo(() => { const processedData = useMemo(() => {
if (isScatter) { if (isScatter) {
return processScatterData( return processScatterData(
data, sortedAndLimitedData,
xAxisField, xAxisField,
measureFields, measureFields,
categoryFields, categoryFields,
@ -168,7 +181,7 @@ export const useDatasetOptions = (params: DatasetHookParams): DatasetHookResult
columnLabelFormats columnLabelFormats
); );
}, [ }, [
data, sortedAndLimitedData,
xFieldSortsString, xFieldSortsString,
xFieldsString, xFieldsString,
isScatter, isScatter,
@ -177,16 +190,10 @@ export const useDatasetOptions = (params: DatasetHookParams): DatasetHookResult
dataMap, dataMap,
measureFields, measureFields,
sizeFieldString, sizeFieldString,
dimensions,
measureFieldsReplaceDataWithKey //use this instead of columnLabelFormats measureFieldsReplaceDataWithKey //use this instead of columnLabelFormats
]); ]);
const dimensions: string[] = useMemo(() => {
if (isScatter) {
return getScatterDimensions(categoriesSet, xAxisField, measureFields, sizeField);
}
return getLineBarPieDimensions(categoriesSet, measureFields, xFields);
}, [categoriesSet, measureFields, xFieldsString, sizeFieldString, isScatter]);
const yAxisKeys = useMemo(() => { const yAxisKeys = useMemo(() => {
if (isScatter) return getLineBarPieYAxisKeys(categoriesSet, yAxisFields); //not a typo. I want to use the same function for both scatter and bar/line/pie if (isScatter) return getLineBarPieYAxisKeys(categoriesSet, yAxisFields); //not a typo. I want to use the same function for both scatter and bar/line/pie
return getLineBarPieYAxisKeys(categoriesSet, yAxisFields); return getLineBarPieYAxisKeys(categoriesSet, yAxisFields);
@ -258,11 +265,12 @@ export const useDatasetOptions = (params: DatasetHookParams): DatasetHookResult
}); });
return { return {
datasetOptions, datasetOptions, //this is a matrix of dimensions x measures
dataTrendlineOptions, dataTrendlineOptions,
yAxisKeys, yAxisKeys,
y2AxisKeys, y2AxisKeys,
tooltipKeys, tooltipKeys,
hasMismatchedTooltipsAndMeasures hasMismatchedTooltipsAndMeasures,
isDownsampled
}; };
}; };

View File

@ -1,6 +1,7 @@
export const DOWNSIZE_SAMPLE_THRESHOLD = 500; export const DOWNSIZE_SAMPLE_THRESHOLD = 1000;
export const LINE_DECIMATION_THRESHOLD = 300; export const LINE_DECIMATION_THRESHOLD = 300;
export const LINE_DECIMATION_SAMPLES = 450; export const LINE_DECIMATION_SAMPLES = 450;
export const ANIMATION_THRESHOLD = 150; export const LEGEND_ANIMATION_THRESHOLD = 200; //we use this when there is a large dataset and need to show a loading animation
export const ANIMATION_THRESHOLD = 125; //testing 175 with scatter chart
export const ANIMATION_DURATION = 1000; export const ANIMATION_DURATION = 1000;
export const TOOLTIP_THRESHOLD = 500; export const TOOLTIP_THRESHOLD = 2500;

View File

@ -23,6 +23,7 @@ export interface BusterChartComponentProps
>, >,
ReturnType<typeof useDatasetOptions> { ReturnType<typeof useDatasetOptions> {
selectedAxis: ChartEncodes; selectedAxis: ChartEncodes;
isDownsampled: boolean;
} }
export interface BusterChartRenderComponentProps export interface BusterChartRenderComponentProps

View File

@ -8,6 +8,7 @@ import React from 'react';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { useDebounceFn } from '@/hooks'; import { useDebounceFn } from '@/hooks';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { scatterConfig_problematic1, scatterDataProblematic1 } from './scatterData_problematic1';
type ScatterChartData = ReturnType<typeof generateScatterChartData>; type ScatterChartData = ReturnType<typeof generateScatterChartData>;
@ -25,7 +26,7 @@ export const Default: Story = {
data: generateScatterChartData(50), data: generateScatterChartData(50),
scatterAxis: { scatterAxis: {
x: ['x'], x: ['x'],
y: ['y'], y: ['y', 'y2'],
size: [], size: [],
category: ['category'] category: ['category']
}, },
@ -57,7 +58,7 @@ export const Default: Story = {
style: 'string' style: 'string'
} satisfies IColumnLabelFormat } satisfies IColumnLabelFormat
} satisfies Record<keyof ScatterChartData, IColumnLabelFormat>, } satisfies Record<keyof ScatterChartData, IColumnLabelFormat>,
className: 'w-[400px] h-[400px]' className: 'w-[400px] h-[400px] max-w-[400px] max-h-[400px]'
} }
}; };
@ -437,3 +438,18 @@ export const ScatterWithTrendline_DateXAxisLogarithmicRegression: Story = {
} }
} }
}; };
export const ProblematicDataset: Story = {
args: {
...Default.args,
data: scatterDataProblematic1.data,
columnMetadata: scatterDataProblematic1.data_metadata.column_metadata,
...(scatterConfig_problematic1 as any),
barAndLineAxis: {
x: ['eligible_orders'],
y: ['attach_rate'],
category: ['merchant']
},
selectedChartType: ChartType.Scatter
}
};

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ export const Sidebar: React.FC<SidebarProps> = React.memo(
))} ))}
</div> </div>
</div> </div>
{footer && <div className="mt-auto mb-2 pt-5">{footer}</div>} {footer && <div className="mt-auto mb-2 overflow-hidden pt-5">{footer}</div>}
</div> </div>
); );
} }

View File

@ -12,7 +12,7 @@ export interface TooltipProps
extends Pick<React.ComponentProps<typeof TooltipContentBase>, 'align' | 'side' | 'sideOffset'>, extends Pick<React.ComponentProps<typeof TooltipContentBase>, 'align' | 'side' | 'sideOffset'>,
Pick<React.ComponentProps<typeof TooltipProvider>, 'delayDuration' | 'skipDelayDuration'> { Pick<React.ComponentProps<typeof TooltipProvider>, 'delayDuration' | 'skipDelayDuration'> {
children: React.ReactNode; children: React.ReactNode;
title: string | undefined; title: string | React.ReactNode | undefined;
shortcuts?: string[]; shortcuts?: string[];
open?: boolean; open?: boolean;
} }
@ -55,7 +55,7 @@ export const Tooltip = React.memo(
); );
const TooltipContent: React.FC<{ const TooltipContent: React.FC<{
title: string; title: string | React.ReactNode;
shortcut?: string[]; shortcut?: string[];
}> = ({ title, shortcut }) => { }> = ({ title, shortcut }) => {
return ( return (

View File

@ -25,7 +25,7 @@ export const HomePageController: React.FC<{}> = () => {
return ( return (
<div className="flex flex-col items-center justify-center p-4.5"> <div className="flex flex-col items-center justify-center p-4.5">
<div className="mt-[150px] flex w-full max-w-[650px] flex-col space-y-6"> <div className="mt-[150px] flex w-full max-w-[650px] flex-col space-y-6">
<div className="flex flex-col justify-center gap-y-2.5 text-center"> <div className="flex flex-col justify-center gap-y-2 text-center">
<Title as="h1" className="mb-0!"> <Title as="h1" className="mb-0!">
{greeting} {greeting}
</Title> </Title>

View File

@ -62,7 +62,8 @@ export const generateScatterChartData = (pointCount = 30): IDataResult => {
const categories = ['Electronics', 'Clothing', 'Home Goods']; const categories = ['Electronics', 'Clothing', 'Home Goods'];
return Array.from({ length: pointCount }, (_, index) => ({ return Array.from({ length: pointCount }, (_, index) => ({
x: (index % 10) * 10, // Values from 0-90 in steps of 10 x: (index % 10) * 10, // Values from 0-90 in steps of 10
y: Math.floor(index / 10) * 10, // Creates a grid pattern y: Math.floor(index / 10) * 10, // Creates a grid pattern,
y2: Math.floor(index / 10) * 40, // Creates a grid pattern,
size: 10 + (index % 5) * 10, // Sizes cycle between 10-50 in steps of 10 size: 10 + (index % 5) * 10, // Sizes cycle between 10-50 in steps of 10
category: categories[index % categories.length] category: categories[index % categories.length]
})); }));