mirror of https://github.com/buster-so/buster.git
commit
e3c1bb9d8f
|
@ -1,3 +1,4 @@
|
|||
import { AppPageLayout } from '@/components/ui/layouts';
|
||||
import { checkIfUserIsAdmin_server } from '@/context/Users/checkIfUserIsAdmin';
|
||||
import { BusterRoutes, createBusterRoute } from '@/routes/busterRoutes';
|
||||
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>;
|
||||
}
|
||||
|
|
|
@ -33,7 +33,7 @@ export default function Page() {
|
|||
<div className="flex flex-col space-y-4">
|
||||
<div className="px-[30px] pt-[46px]">
|
||||
<SettingsPageHeader
|
||||
title="User Management"
|
||||
title="User management"
|
||||
description="Manage your organization's users and their permissions"
|
||||
type="alternate"
|
||||
/>
|
||||
|
|
|
@ -88,8 +88,8 @@ export const LoginForm: React.FC<{}> = ({}) => {
|
|||
if (res?.error) throw res.error;
|
||||
} catch (error: any) {
|
||||
errorFallback(error);
|
||||
setLoading(null);
|
||||
}
|
||||
setLoading('azure');
|
||||
});
|
||||
|
||||
const onSignUp = useMemoizedFn(async (d: { email: string; password: string }) => {
|
||||
|
@ -211,18 +211,11 @@ const LoginOptions: React.FC<{
|
|||
<WelcomeText signUpFlow={signUpFlow} />
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="my-6 space-y-3"
|
||||
onSubmit={(v) => {
|
||||
v.preventDefault();
|
||||
onSubmitClickPreflight({
|
||||
email,
|
||||
password
|
||||
});
|
||||
}}>
|
||||
<div className="mt-6 mb-4 flex flex-col space-y-3">
|
||||
<Button
|
||||
prefix={<Google />}
|
||||
size={'tall'}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearAllCookies();
|
||||
onSignInWithGoogle();
|
||||
|
@ -235,6 +228,7 @@ const LoginOptions: React.FC<{
|
|||
<Button
|
||||
prefix={<Github />}
|
||||
size={'tall'}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearAllCookies();
|
||||
onSignInWithGithub();
|
||||
|
@ -247,6 +241,7 @@ const LoginOptions: React.FC<{
|
|||
<Button
|
||||
prefix={<Microsoft />}
|
||||
size={'tall'}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearAllCookies();
|
||||
onSignInWithAzure();
|
||||
|
@ -256,8 +251,18 @@ const LoginOptions: React.FC<{
|
|||
tabIndex={3}>
|
||||
{!signUpFlow ? `Continue with Azure` : `Sign up with Azure`}
|
||||
</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
|
||||
type="email"
|
||||
|
@ -333,7 +338,7 @@ const LoginOptions: React.FC<{
|
|||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-y-2 pt-0">
|
||||
<div className="mt-2 flex flex-col gap-y-2">
|
||||
<AlreadyHaveAccount
|
||||
setErrorMessages={setErrorMessages}
|
||||
setPassword2={setPassword2}
|
||||
|
|
|
@ -16,9 +16,11 @@ export const AvatarUserButton = React.forwardRef<
|
|||
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">
|
||||
<Avatar size={32} fallbackClassName="text-base" image={avatarUrl} name={username} />
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<Text className="flex-grow">{username}</Text>
|
||||
<Text size={'sm'} variant={'secondary'}>
|
||||
<div className="flex w-full flex-col gap-y-0.5 overflow-hidden">
|
||||
<Text truncate className="flex-grow">
|
||||
{username}
|
||||
</Text>
|
||||
<Text truncate size={'sm'} variant={'secondary'}>
|
||||
{email}
|
||||
</Text>
|
||||
</div>
|
||||
|
|
|
@ -22,13 +22,15 @@ export const BusterChartComponent: React.FC<BusterChartRenderComponentProps> = (
|
|||
selectedChartType,
|
||||
selectedAxis
|
||||
} = props;
|
||||
|
||||
const {
|
||||
datasetOptions,
|
||||
dataTrendlineOptions,
|
||||
y2AxisKeys,
|
||||
yAxisKeys,
|
||||
tooltipKeys,
|
||||
hasMismatchedTooltipsAndMeasures
|
||||
hasMismatchedTooltipsAndMeasures,
|
||||
isDownsampled
|
||||
} = useDatasetOptions({
|
||||
data: dataProp,
|
||||
selectedAxis,
|
||||
|
@ -52,7 +54,8 @@ export const BusterChartComponent: React.FC<BusterChartRenderComponentProps> = (
|
|||
y2AxisKeys,
|
||||
yAxisKeys,
|
||||
tooltipKeys,
|
||||
hasMismatchedTooltipsAndMeasures
|
||||
hasMismatchedTooltipsAndMeasures,
|
||||
isDownsampled
|
||||
}),
|
||||
[
|
||||
props,
|
||||
|
@ -62,7 +65,8 @@ export const BusterChartComponent: React.FC<BusterChartRenderComponentProps> = (
|
|||
y2AxisKeys,
|
||||
yAxisKeys,
|
||||
hasMismatchedTooltipsAndMeasures,
|
||||
tooltipKeys
|
||||
tooltipKeys,
|
||||
isDownsampled
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import React, { useCallback, useRef, useState } from 'react';
|
|||
import { DEFAULT_CHART_CONFIG, DEFAULT_COLUMN_METADATA } from '@/api/asset_interfaces/metric';
|
||||
import { BusterChartJSLegendWrapper } from './BusterChartJSLegendWrapper';
|
||||
import { ChartJSOrUndefined } from './core/types';
|
||||
import { useMemoizedFn, useMount } from '@/hooks';
|
||||
import { useMemoizedFn } from '@/hooks';
|
||||
import { BusterChartJSComponent } from './BusterChartJSComponent';
|
||||
import type { BusterChartComponentProps } from '../interfaces';
|
||||
|
||||
|
@ -59,6 +59,7 @@ export const BusterChartJS: React.FC<BusterChartComponentProps> = ({
|
|||
colors={colors}
|
||||
chartRef={chartRef}
|
||||
datasetOptions={datasetOptions}
|
||||
isDownsampled={props.isDownsampled}
|
||||
pieMinimumSlicePercentage={pieMinimumSlicePercentage}>
|
||||
<BusterChartJSComponent
|
||||
ref={chartRef}
|
||||
|
|
|
@ -17,7 +17,6 @@ import { useChartSpecificOptions } from './hooks/useChartSpecificOptions';
|
|||
import { BusterChartTypeComponentProps } from '../interfaces/chartComponentInterfaces';
|
||||
import { useTrendlines } from './hooks/useTrendlines';
|
||||
import { ScatterAxis } from '@/api/asset_interfaces/metric/charts';
|
||||
import { useMount } from '@/hooks';
|
||||
|
||||
export const BusterChartJSComponent = React.memo(
|
||||
React.forwardRef<ChartJSOrUndefined, BusterChartTypeComponentProps>(
|
||||
|
|
|
@ -24,6 +24,7 @@ interface BusterChartJSLegendWrapperProps {
|
|||
chartRef: React.RefObject<ChartJSOrUndefined | null>;
|
||||
datasetOptions: DatasetOption[];
|
||||
pieMinimumSlicePercentage: NonNullable<BusterChartProps['pieMinimumSlicePercentage']>;
|
||||
isDownsampled: boolean;
|
||||
}
|
||||
|
||||
export const BusterChartJSLegendWrapper = React.memo<BusterChartJSLegendWrapperProps>(
|
||||
|
@ -45,7 +46,8 @@ export const BusterChartJSLegendWrapper = React.memo<BusterChartJSLegendWrapperP
|
|||
barGroupType,
|
||||
colors,
|
||||
datasetOptions,
|
||||
pieMinimumSlicePercentage
|
||||
pieMinimumSlicePercentage,
|
||||
isDownsampled
|
||||
}) => {
|
||||
const {
|
||||
renderLegend,
|
||||
|
@ -81,6 +83,7 @@ export const BusterChartJSLegendWrapper = React.memo<BusterChartJSLegendWrapperP
|
|||
renderLegend={renderLegend}
|
||||
legendItems={legendItems}
|
||||
showLegend={showLegend}
|
||||
isDownsampled={isDownsampled}
|
||||
showLegendHeadline={showLegendHeadline}
|
||||
inactiveDatasets={inactiveDatasets}
|
||||
onHoverItem={onHoverItem}
|
||||
|
|
|
@ -113,7 +113,7 @@ ChartJS.defaults.font = {
|
|||
scale.ticks.autoSkipPadding = 4;
|
||||
scale.ticks.align = 'center';
|
||||
scale.ticks.callback = function (value, index, values) {
|
||||
return truncateText(this.getLabelForValue(index), 18);
|
||||
return truncateText(this.getLabelForValue(value as number), 18);
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
ChartType,
|
||||
ComboChartAxis
|
||||
} 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 {
|
||||
addLegendHeadlines,
|
||||
|
@ -18,7 +18,8 @@ import {
|
|||
} from '../../../BusterChartLegend';
|
||||
import { getLegendItems } from './helper';
|
||||
import { DatasetOption } from '../../../chartHooks';
|
||||
import { ANIMATION_THRESHOLD } from '../../../config';
|
||||
import { ANIMATION_THRESHOLD, LEGEND_ANIMATION_THRESHOLD } from '../../../config';
|
||||
import { timeout } from '@/lib';
|
||||
|
||||
interface UseBusterChartJSLegendProps {
|
||||
chartRef: React.RefObject<ChartJSOrUndefined | null>;
|
||||
|
@ -57,6 +58,9 @@ export const useBusterChartJSLegend = ({
|
|||
}: UseBusterChartJSLegendProps): UseChartLengendReturnValues => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isUpdatingChart, setIsUpdatingChart] = useState(false);
|
||||
const [numberOfDataPoints, setNumberOfDataPoints] = useState(0);
|
||||
const isLargeDataset = numberOfDataPoints > LEGEND_ANIMATION_THRESHOLD;
|
||||
const legendTimeoutDuration = isLargeDataset ? 95 : 0;
|
||||
|
||||
const {
|
||||
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(() => {
|
||||
setLegendItems(items);
|
||||
});
|
||||
|
@ -144,19 +155,33 @@ export const useBusterChartJSLegend = ({
|
|||
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;
|
||||
|
||||
if (!chartjs) return;
|
||||
|
||||
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
|
||||
if (timeoutDuration) setIsUpdatingChart(true);
|
||||
if (legendTimeoutDuration) setIsUpdatingChart(true);
|
||||
|
||||
// Update dataset visibility state
|
||||
setInactiveDatasets((prev) => ({
|
||||
|
@ -164,6 +189,8 @@ export const useBusterChartJSLegend = ({
|
|||
[item.id]: prev[item.id] ? !prev[item.id] : true
|
||||
}));
|
||||
|
||||
await timeout(legendTimeoutDuration);
|
||||
|
||||
// Defer visual updates to prevent UI blocking
|
||||
requestAnimationFrame(() => {
|
||||
// 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
|
||||
setTimeout(() => {
|
||||
startTransition(() => {
|
||||
chartjs.update();
|
||||
|
||||
// Set a timeout to turn off loading state after the update is complete
|
||||
requestAnimationFrame(() => {
|
||||
setIsUpdatingChart(false);
|
||||
});
|
||||
});
|
||||
}, timeoutDuration);
|
||||
debouncedChartUpdate(legendTimeoutDuration);
|
||||
});
|
||||
});
|
||||
|
||||
const onLegendItemFocus = useMemoizedFn((item: BusterChartLegendItem) => {
|
||||
const onLegendItemFocus = useMemoizedFn(async (item: BusterChartLegendItem) => {
|
||||
const chartjs = chartRef.current;
|
||||
if (!chartjs) return;
|
||||
|
||||
const datasets = chartjs.data.datasets.filter((dataset) => !dataset.hidden);
|
||||
const hasMultipleDatasets = datasets?.length > 1;
|
||||
const assosciatedDatasetIndex = datasets?.findIndex((dataset) => dataset.label === item.id);
|
||||
if (legendTimeoutDuration) setIsUpdatingChart(true);
|
||||
|
||||
if (hasMultipleDatasets) {
|
||||
const hasOtherDatasetsVisible = datasets?.some(
|
||||
(dataset, index) =>
|
||||
dataset.label !== item.id && chartjs.isDatasetVisible(index) && !dataset.hidden
|
||||
);
|
||||
const inactiveDatasetsRecord: Record<string, boolean> = {};
|
||||
if (hasOtherDatasetsVisible) {
|
||||
datasets?.forEach((dataset, index) => {
|
||||
const value = index === assosciatedDatasetIndex;
|
||||
chartjs.setDatasetVisibility(index, value);
|
||||
inactiveDatasetsRecord[dataset.label!] = !value;
|
||||
});
|
||||
} else {
|
||||
datasets?.forEach((dataset, index) => {
|
||||
chartjs.setDatasetVisibility(index, true);
|
||||
inactiveDatasetsRecord[dataset.label!] = false;
|
||||
});
|
||||
// Defer visual updates to prevent UI blocking
|
||||
requestAnimationFrame(() => {
|
||||
const datasets = chartjs.data.datasets.filter((dataset) => !dataset.hidden);
|
||||
const hasMultipleDatasets = datasets?.length > 1;
|
||||
const assosciatedDatasetIndex = datasets?.findIndex((dataset) => dataset.label === item.id);
|
||||
|
||||
if (hasMultipleDatasets) {
|
||||
const hasOtherDatasetsVisible = datasets?.some(
|
||||
(dataset, index) =>
|
||||
dataset.label !== item.id && chartjs.isDatasetVisible(index) && !dataset.hidden
|
||||
);
|
||||
const inactiveDatasetsRecord: Record<string, boolean> = {};
|
||||
if (hasOtherDatasetsVisible) {
|
||||
datasets?.forEach((dataset, index) => {
|
||||
const value = index === assosciatedDatasetIndex;
|
||||
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(
|
||||
|
@ -231,7 +253,7 @@ export const useBusterChartJSLegend = ({
|
|||
calculateLegendItems();
|
||||
},
|
||||
[selectedChartType],
|
||||
{ wait: 3 }
|
||||
{ wait: 4 }
|
||||
);
|
||||
|
||||
//immediate items
|
||||
|
|
|
@ -167,8 +167,8 @@ export const useOptions = ({
|
|||
const interaction = useInteractions({ selectedChartType, barLayout });
|
||||
|
||||
const numberOfSources = useMemo(() => {
|
||||
return datasetOptions.reduce((acc, curr) => {
|
||||
return acc + curr.source.length;
|
||||
return datasetOptions.reduce((acc, dataset) => {
|
||||
return acc + dataset.source.length;
|
||||
}, 0);
|
||||
}, [datasetOptions]);
|
||||
|
||||
|
|
|
@ -34,6 +34,8 @@ interface UseTooltipOptionsProps {
|
|||
colors: string[];
|
||||
}
|
||||
|
||||
const MAX_TOOLTIP_CACHE_SIZE = 30;
|
||||
|
||||
export const useTooltipOptions = ({
|
||||
columnLabelFormats,
|
||||
selectedChartType,
|
||||
|
@ -129,7 +131,6 @@ export const useTooltipOptions = ({
|
|||
selectedChartType,
|
||||
matchedCacheItem,
|
||||
keyToUsePercentage,
|
||||
columnSettings,
|
||||
hasCategoryAxis,
|
||||
hasMultipleMeasures,
|
||||
barGroupType,
|
||||
|
@ -137,6 +138,9 @@ export const useTooltipOptions = ({
|
|||
);
|
||||
|
||||
if (result) {
|
||||
if (Object.keys(tooltipCache.current).length > MAX_TOOLTIP_CACHE_SIZE) {
|
||||
tooltipCache.current = {};
|
||||
}
|
||||
tooltipCache.current[key] = result;
|
||||
}
|
||||
});
|
||||
|
@ -225,7 +229,6 @@ const externalTooltip = (
|
|||
selectedChartType: NonNullable<BusterChartProps['selectedChartType']>,
|
||||
matchedCacheItem: string | undefined,
|
||||
keyToUsePercentage: string[],
|
||||
columnSettings: NonNullable<BusterChartProps['columnSettings']>,
|
||||
hasCategoryAxis: boolean,
|
||||
hasMultipleMeasures: boolean,
|
||||
barGroupType: BusterChartProps['barGroupType'],
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from '../chartHooks/useChartWrapperProvider';
|
||||
import { cn } from '@/lib/classMerge';
|
||||
import { CircleSpinnerLoader } from '../../loaders';
|
||||
import { DownsampleAlert } from './DownsampleAlert';
|
||||
|
||||
export type BusterChartLegendWrapper = {
|
||||
children: React.ReactNode;
|
||||
|
@ -18,6 +19,7 @@ export type BusterChartLegendWrapper = {
|
|||
className: string | undefined;
|
||||
animateLegend: boolean;
|
||||
isUpdatingChart?: boolean;
|
||||
isDownsampled: boolean;
|
||||
onHoverItem: (item: BusterChartLegendItem, isHover: boolean) => void;
|
||||
onLegendItemClick: (item: BusterChartLegendItem) => void;
|
||||
onLegendItemFocus: ((item: BusterChartLegendItem) => void) | undefined;
|
||||
|
@ -34,6 +36,7 @@ export const BusterChartLegendWrapper: React.FC<BusterChartLegendWrapper> = Reac
|
|||
animateLegend,
|
||||
className,
|
||||
isUpdatingChart,
|
||||
isDownsampled,
|
||||
onHoverItem,
|
||||
onLegendItemClick,
|
||||
onLegendItemFocus
|
||||
|
@ -42,7 +45,8 @@ export const BusterChartLegendWrapper: React.FC<BusterChartLegendWrapper> = Reac
|
|||
|
||||
return (
|
||||
<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 && (
|
||||
<BusterChartLegend
|
||||
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 />}
|
||||
{children}
|
||||
{isDownsampled && <DownsampleAlert isDownsampled={isDownsampled} />}
|
||||
</div>
|
||||
</div>
|
||||
</ChartLegendWrapperProvider>
|
||||
|
|
|
@ -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';
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
type BusterChartProps,
|
||||
type ColumnLabelFormat,
|
||||
type ScatterAxis
|
||||
} from '@/api/asset_interfaces/metric/charts';
|
||||
import { type BusterChartProps, type ScatterAxis } from '@/api/asset_interfaces/metric/charts';
|
||||
import { createDimension } from './datasetHelpers_BarLinePie';
|
||||
import { appendToKeyValueChain } from './groupingHelpers';
|
||||
import { DatasetOption } from './interfaces';
|
||||
|
@ -68,10 +64,7 @@ export const processScatterData = (
|
|||
measureFields.forEach((measure) => {
|
||||
categories.forEach((category) => {
|
||||
const columnLabelFormat = columnLabelFormats[measure];
|
||||
const replaceMissingDataWith =
|
||||
columnLabelFormat?.replaceMissingDataWith !== undefined
|
||||
? columnLabelFormat?.replaceMissingDataWith
|
||||
: defaultReplaceMissingDataWith;
|
||||
const replaceMissingDataWith = defaultReplaceMissingDataWith;
|
||||
if (categoryKey === category) {
|
||||
const value = item[measure] || replaceMissingDataWith;
|
||||
row.push(value as string | number);
|
||||
|
|
|
@ -34,6 +34,7 @@ import {
|
|||
import { type TrendlineDataset, useDataTrendlineOptions } from './useDataTrendlineOptions';
|
||||
import type { DatasetOption } from './interfaces';
|
||||
import { DEFAULT_COLUMN_LABEL_FORMAT } from '@/api/asset_interfaces/metric';
|
||||
import { DOWNSIZE_SAMPLE_THRESHOLD } from '../../config';
|
||||
|
||||
type DatasetHookResult = {
|
||||
datasetOptions: DatasetOption[];
|
||||
|
@ -42,6 +43,7 @@ type DatasetHookResult = {
|
|||
y2AxisKeys: string[];
|
||||
tooltipKeys: string[];
|
||||
hasMismatchedTooltipsAndMeasures: boolean;
|
||||
isDownsampled: boolean;
|
||||
};
|
||||
|
||||
type DatasetHookParams = {
|
||||
|
@ -125,6 +127,10 @@ export const useDatasetOptions = (params: DatasetHookParams): DatasetHookResult
|
|||
return uniq([...yAxisFields, ...y2AxisFields, ...tooltipFields]);
|
||||
}, [yAxisFieldsString, y2AxisFieldsString, tooltipFieldsString]);
|
||||
|
||||
const isDownsampled = useMemo(() => {
|
||||
return data.length > DOWNSIZE_SAMPLE_THRESHOLD;
|
||||
}, [data]);
|
||||
|
||||
const sortedAndLimitedData = useMemo(() => {
|
||||
if (isScatter) return downsampleScatterData(data);
|
||||
return sortLineBarData(data, columnMetadata, xFieldSorts, xFields);
|
||||
|
@ -139,18 +145,25 @@ export const useDatasetOptions = (params: DatasetHookParams): DatasetHookResult
|
|||
return measureFields
|
||||
.map((field) => {
|
||||
const value = columnLabelFormats[field]?.replaceMissingDataWith;
|
||||
if (value === undefined) return 0;
|
||||
if (value === null) return null;
|
||||
if (value === '') return '';
|
||||
if (value === undefined) return 'undefined';
|
||||
if (value === null) return 'null';
|
||||
if (value === '') return 'empty';
|
||||
return value;
|
||||
})
|
||||
.join(',');
|
||||
}, [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(() => {
|
||||
if (isScatter) {
|
||||
return processScatterData(
|
||||
data,
|
||||
sortedAndLimitedData,
|
||||
xAxisField,
|
||||
measureFields,
|
||||
categoryFields,
|
||||
|
@ -168,7 +181,7 @@ export const useDatasetOptions = (params: DatasetHookParams): DatasetHookResult
|
|||
columnLabelFormats
|
||||
);
|
||||
}, [
|
||||
data,
|
||||
sortedAndLimitedData,
|
||||
xFieldSortsString,
|
||||
xFieldsString,
|
||||
isScatter,
|
||||
|
@ -177,16 +190,10 @@ export const useDatasetOptions = (params: DatasetHookParams): DatasetHookResult
|
|||
dataMap,
|
||||
measureFields,
|
||||
sizeFieldString,
|
||||
dimensions,
|
||||
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(() => {
|
||||
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);
|
||||
|
@ -258,11 +265,12 @@ export const useDatasetOptions = (params: DatasetHookParams): DatasetHookResult
|
|||
});
|
||||
|
||||
return {
|
||||
datasetOptions,
|
||||
datasetOptions, //this is a matrix of dimensions x measures
|
||||
dataTrendlineOptions,
|
||||
yAxisKeys,
|
||||
y2AxisKeys,
|
||||
tooltipKeys,
|
||||
hasMismatchedTooltipsAndMeasures
|
||||
hasMismatchedTooltipsAndMeasures,
|
||||
isDownsampled
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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_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 TOOLTIP_THRESHOLD = 500;
|
||||
export const TOOLTIP_THRESHOLD = 2500;
|
||||
|
|
|
@ -23,6 +23,7 @@ export interface BusterChartComponentProps
|
|||
>,
|
||||
ReturnType<typeof useDatasetOptions> {
|
||||
selectedAxis: ChartEncodes;
|
||||
isDownsampled: boolean;
|
||||
}
|
||||
|
||||
export interface BusterChartRenderComponentProps
|
||||
|
|
|
@ -8,6 +8,7 @@ import React from 'react';
|
|||
import { Slider } from '@/components/ui/slider';
|
||||
import { useDebounceFn } from '@/hooks';
|
||||
import dayjs from 'dayjs';
|
||||
import { scatterConfig_problematic1, scatterDataProblematic1 } from './scatterData_problematic1';
|
||||
|
||||
type ScatterChartData = ReturnType<typeof generateScatterChartData>;
|
||||
|
||||
|
@ -25,7 +26,7 @@ export const Default: Story = {
|
|||
data: generateScatterChartData(50),
|
||||
scatterAxis: {
|
||||
x: ['x'],
|
||||
y: ['y'],
|
||||
y: ['y', 'y2'],
|
||||
size: [],
|
||||
category: ['category']
|
||||
},
|
||||
|
@ -57,7 +58,7 @@ export const Default: Story = {
|
|||
style: 'string'
|
||||
} satisfies 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
|
@ -15,7 +15,7 @@ export const Sidebar: React.FC<SidebarProps> = React.memo(
|
|||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ export interface TooltipProps
|
|||
extends Pick<React.ComponentProps<typeof TooltipContentBase>, 'align' | 'side' | 'sideOffset'>,
|
||||
Pick<React.ComponentProps<typeof TooltipProvider>, 'delayDuration' | 'skipDelayDuration'> {
|
||||
children: React.ReactNode;
|
||||
title: string | undefined;
|
||||
title: string | React.ReactNode | undefined;
|
||||
shortcuts?: string[];
|
||||
open?: boolean;
|
||||
}
|
||||
|
@ -55,7 +55,7 @@ export const Tooltip = React.memo(
|
|||
);
|
||||
|
||||
const TooltipContent: React.FC<{
|
||||
title: string;
|
||||
title: string | React.ReactNode;
|
||||
shortcut?: string[];
|
||||
}> = ({ title, shortcut }) => {
|
||||
return (
|
||||
|
|
|
@ -25,7 +25,7 @@ export const HomePageController: React.FC<{}> = () => {
|
|||
return (
|
||||
<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="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!">
|
||||
{greeting}
|
||||
</Title>
|
||||
|
|
|
@ -62,7 +62,8 @@ export const generateScatterChartData = (pointCount = 30): IDataResult => {
|
|||
const categories = ['Electronics', 'Clothing', 'Home Goods'];
|
||||
return Array.from({ length: pointCount }, (_, index) => ({
|
||||
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
|
||||
category: categories[index % categories.length]
|
||||
}));
|
||||
|
|
Loading…
Reference in New Issue